From b8bbe8061bf7d28e1c5888f3cdb45de4cbb7d404 Mon Sep 17 00:00:00 2001 From: Jonas Wielage Date: Tue, 6 Oct 2026 11:58:30 +0200 Subject: [PATCH 1/8] Automatically add fix version to SQS Integration ticket --- .github/workflows/automated-release.yml | 1 + .../test-create-integration-ticket.yml | 38 +- .okf/actions/create-integration-ticket.md | 11 +- .okf/log.md | 12 + create-integration-ticket/README.md | 20 + create-integration-ticket/action.yml | 37 ++ .../create_integration_ticket.py | 114 ++++++ create-integration-ticket/requirements.txt | 2 +- .../test_create_integration_ticket.py | 381 +++++++++++++++++- docs/AUTOMATED_RELEASE.md | 1 + test-fixtures/jira/README.md | 14 +- test-fixtures/jira/assert_ticket_fields.py | 47 ++- 12 files changed, 647 insertions(+), 31 deletions(-) diff --git a/.github/workflows/automated-release.yml b/.github/workflows/automated-release.yml index fa83571c..bedf7b6d 100644 --- a/.github/workflows/automated-release.yml +++ b/.github/workflows/automated-release.yml @@ -888,6 +888,7 @@ jobs: parent-epic: ${{ steps.resolve-ktlo-epic.outputs.epic-key }} edition: ${{ inputs.sqs-ticket-edition }} team: ${{ inputs.sqs-sqc-ticket-team }} + secret-name: ${{ inputs.release-automation-secret-name || format('sonar-{0}-release-automation', inputs.plugin-name) }} - name: Summary if: ${{ inputs.verbose }} diff --git a/.github/workflows/test-create-integration-ticket.yml b/.github/workflows/test-create-integration-ticket.yml index 59553276..285201da 100644 --- a/.github/workflows/test-create-integration-ticket.yml +++ b/.github/workflows/test-create-integration-ticket.yml @@ -91,8 +91,10 @@ jobs: --use-sandbox "true" \ --ticket-key "$TICKET_KEY" \ --team "f1da89c9-3712-4d15-b194-a4b24406e3e4" \ - --edition NONE + --edition NONE \ + --fix-versions NONE + # Sandbox only proves fixVersions is stored; which version wins is unit-tested. - name: Create ticket with edition and team id: with-edition uses: ./create-integration-ticket @@ -104,7 +106,7 @@ jobs: team: 'f1da89c9-3712-4d15-b194-a4b24406e3e4' use-jira-sandbox: 'true' - - name: Verify edition and team were set in step before + - name: Verify edition, team and fix versions were set in step before env: JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} @@ -115,7 +117,32 @@ jobs: --use-sandbox "true" \ --ticket-key "$TICKET_KEY" \ --team "f1da89c9-3712-4d15-b194-a4b24406e3e4" \ - --edition "Community Build & Server" + --edition "Community Build & Server" \ + --fix-versions ANY + + - name: Create ticket with N/A edition + id: with-na-edition + uses: ./create-integration-ticket + with: + target-jira-project: SONAR + release-ticket-key: 'SONAR-22193' + ticket-summary: 'Integration ticket test ${{ github.run_id }} — N/A edition' + edition: 'N/A' + use-jira-sandbox: 'true' + + - name: Verify N/A edition sets no fix versions + env: + JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} + JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} + TICKET_KEY: ${{ steps.with-na-edition.outputs.ticket-key }} + run: | + set -euo pipefail + python test-fixtures/jira/assert_ticket_fields.py \ + --use-sandbox "true" \ + --ticket-key "$TICKET_KEY" \ + --team NONE \ + --edition "N/A" \ + --fix-versions NONE - name: Create ticket without edition or team id: without-fields @@ -139,7 +166,8 @@ jobs: --use-sandbox "true" \ --ticket-key "$TICKET_KEY" \ --team NONE \ - --edition NONE + --edition NONE \ + --fix-versions NONE # Deleting the created tickets also removes the issue links they added to SONAR-22193. - name: Clean up created tickets @@ -148,7 +176,7 @@ jobs: JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} CREATED_KEYS: >- - ${{ steps.with-team.outputs.ticket-key }},${{ steps.with-edition.outputs.ticket-key }},${{ steps.without-fields.outputs.ticket-key }} + ${{ steps.with-team.outputs.ticket-key }},${{ steps.with-edition.outputs.ticket-key }},${{ steps.with-na-edition.outputs.ticket-key }},${{ steps.without-fields.outputs.ticket-key }} run: | python test-fixtures/jira/cleanup.py \ --use-sandbox "true" \ diff --git a/.okf/actions/create-integration-ticket.md b/.okf/actions/create-integration-ticket.md index cc0705fa..6602e3b1 100644 --- a/.okf/actions/create-integration-ticket.md +++ b/.okf/actions/create-integration-ticket.md @@ -36,7 +36,16 @@ dropped. Availability differs per project (`SONAR`: both; `SC`: team only), whic Covered by a real Jira sandbox job that re-reads the created tickets and asserts the stored values — the only check that catches a wrong custom field ID or value shape. It runs against fixed sandbox state (`SONAR-22193`, a fixed team UUID) instead of a setup script, like -[get-jira-release-notes](/actions/get-jira-release-notes.md). +[get-jira-release-notes](/actions/get-jira-release-notes.md). `fixVersions` is only asserted as +set, since which version wins is unit-tested. + +# Automatic Fix versions + +When `edition` is set, `fixVersions` gets the lowest open `major.minor` version per prefix: +`sqcb-` (Community Build), `sqs-` (Server) or both; `N/A` sets nothing. Released, archived and +`sonar-enterprise`-tagged versions are skipped. The tag lookup uses a token resolved like +[create-pull-request](/actions/create-pull-request.md) (vault `SonarSource-` if the `secret-name` input is set, else `-release-automation`, else the `token` +input; no token only warns). Any failure falls back to Jira-only or no fix version, never blocking. # Citations diff --git a/.okf/log.md b/.okf/log.md index 50273fff..1e1140b2 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -1,5 +1,17 @@ # Update Log +## 2026-10-06 +* **Fix versions skip sonar-enterprise tags**: `create-integration-ticket` skips versions already + tagged in `sonar-enterprise`, via a vault or new `token` input (see + [actions/create-integration-ticket.md](/actions/create-integration-ticket.md)). +* **Configurable vault secret for integration tickets**: `create-integration-ticket` takes a + `secret-name` input, passed from `automated-release.yml` like the update-analyzer steps. + +## 2026-10-05 +* **Automatic Fix versions for integration tickets**: With `edition` set, `create-integration-ticket` + fills `fixVersions` with the lowest open `sqcb-*`/`sqs-*` version (see + [actions/create-integration-ticket.md](/actions/create-integration-ticket.md)). + ## 2026-09-17 * **Repo-specific repox status preferred**: [get-release-version](/actions/get-release-version.md) now reads the exact `repox--` context first, falling back to the generic diff --git a/create-integration-ticket/README.md b/create-integration-ticket/README.md index d128ebca..b82e8425 100644 --- a/create-integration-ticket/README.md +++ b/create-integration-ticket/README.md @@ -34,6 +34,8 @@ This action requires: | `parent-epic` | Jira issue key to set as parent of the created ticket (e.g. CPP-7858) | No | - | | `edition` | Jira "Edition" value. One of: `N/A`, `Community Build`, `Server`, `Community Build & Server` | No | - | | `team` | Atlassian team **UUID** for the Jira "Team" field | No | - | +| `secret-name` | Release automation vault secret name (read as `SonarSource-`) with access to `sonar-enterprise`; defaults to `{REPO_OWNER_NAME_DASH}-release-automation` | No | - | +| `token` | GitHub token for reading `sonar-enterprise` tags; fallback if the vault token is unavailable | No | `${{ github.token }}` | **Note:** Either `ticket-summary` must be provided, or both `plugin-name` and `release-version` must be provided. If `ticket-summary` is not provided, it will be automatically generated as "Update {plugin-name} to {release-version}". @@ -46,6 +48,24 @@ request and the action fails. `edition` is available on `SONAR`, not on `SC`; `t `team` takes the team UUID, not the name (find it via `customfield_10001.id` on an existing ticket's `/rest/api/2/issue/`). UUIDs differ between production and sandbox. +### Automatic 'Fix versions' + +When `edition` is set, `Fix versions` is filled with the lowest open version per prefix: + +| `edition` | Fix versions set | +|-------------------------------|-------------------------------------| +| `N/A` | none | +| `Community Build` | next unreleased `sqcb-*` | +| `Server` | next unreleased `sqs-*` | +| `Community Build & Server` | next unreleased `sqcb-*` and `sqs-*`| + +Released, archived and `sonar-enterprise`-tagged versions (tag `sqs-2026.5.2.1` ⇒ `sqs-2026.5`) are +skipped. Failures never block ticket creation. + +The tag lookup uses the vault `SonarSource-` token if `secret-name` is set, else +`{REPO_OWNER_NAME_DASH}-release-automation`, falling back to `token`, as in +[`create-pull-request`](../create-pull-request). Without access to `sonar-enterprise`, only Jira is consulted. + ## Outputs | Output | Description | diff --git a/create-integration-ticket/action.yml b/create-integration-ticket/action.yml index 90440342..aa4a2074 100644 --- a/create-integration-ticket/action.yml +++ b/create-integration-ticket/action.yml @@ -34,6 +34,13 @@ inputs: team: description: 'Optional team UUID for the Jira "Team" field.' required: false + secret-name: + description: 'Release automation vault secret name with read access to sonar-enterprise (e.g. sonar-foo-release-automation). Defaults to {REPO_OWNER_NAME_DASH}-release-automation.' + required: false + token: + description: 'GitHub token for reading sonar-enterprise tags; fallback if the vault token is unavailable.' + required: false + default: ${{ github.token }} outputs: ticket-key: @@ -54,6 +61,35 @@ runs: development/kv/data/jira user | JIRA_USER; development/kv/data/jira token | JIRA_TOKEN; + - name: Get GitHub token from Vault + id: github_secrets + if: ${{ inputs.edition != '' }} + continue-on-error: true + uses: SonarSource/vault-action-wrapper@320bd31b03e5dacaac6be51bbbb15adf7caccc32 # v3.1 + with: + secrets: | + development/github/token/${{ inputs.secret-name && format('SonarSource-{0}', inputs.secret-name) || '{REPO_OWNER_NAME_DASH}-release-automation' }} token | VAULT_GITHUB_TOKEN; + + - name: Resolve GitHub token + id: resolve_github_token + if: ${{ inputs.edition != '' }} + shell: bash + env: + VAULT_OUTCOME: ${{ steps.github_secrets.outcome }} + VAULT_TOKEN: ${{ steps.github_secrets.outcome == 'success' && fromJSON(steps.github_secrets.outputs.vault).VAULT_GITHUB_TOKEN || '' }} + INPUT_TOKEN: ${{ inputs.token }} + run: | + set -euo pipefail + if [[ "$VAULT_OUTCOME" == "success" && -n "$VAULT_TOKEN" ]]; then + echo "Using vault token" + echo "token=${VAULT_TOKEN}" >> "$GITHUB_OUTPUT" + elif [[ -n "$INPUT_TOKEN" ]]; then + echo "Vault token not available, using input token" + echo "token=${INPUT_TOKEN}" >> "$GITHUB_OUTPUT" + else + echo "::warning::No GitHub token available, Fix versions will not be cross-checked against sonar-enterprise tags." + fi + - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -94,6 +130,7 @@ runs: env: JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} + GITHUB_TOKEN: ${{ steps.resolve_github_token.outputs.token }} USE_SANDBOX: ${{ inputs.use-jira-sandbox || env.USE_JIRA_SANDBOX }} TICKET_DESCRIPTION: | ${{ inputs.ticket-description }}${{ inputs.jira-release-url && inputs.ticket-description && ' diff --git a/create-integration-ticket/create_integration_ticket.py b/create-integration-ticket/create_integration_ticket.py index ac0de43c..a67c50e7 100644 --- a/create-integration-ticket/create_integration_ticket.py +++ b/create-integration-ticket/create_integration_ticket.py @@ -8,13 +8,122 @@ import argparse import os +import re import sys import time +import requests sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'shared')) from jira_common import eprint, get_jira_instance, CUSTOM_FIELDS from jira.exceptions import JIRAError +# Fix-version name prefixes per Edition; editions not listed (e.g. 'N/A') get none. +EDITION_VERSION_PREFIXES = { + 'Community Build': ('sqcb-',), + 'Server': ('sqs-',), + 'Community Build & Server': ('sqcb-', 'sqs-'), +} + +SONAR_ENTERPRISE_TAGS_URL = 'https://api.github.com/repos/SonarSource/sonar-enterprise/git/matching-refs/tags/' + +# Jira versions are 'major.minor' only (no bugfix or '-M1' suffix). +VERSION_NAME_PATTERN = re.compile(r'^(\d+)\.(\d+)$') + + +def version_sort_key(name): + """Numeric sort key for 'major.minor', so '26.9' < '26.10'.""" + major, minor = VERSION_NAME_PATTERN.match(name).groups() + return int(major), int(minor) + + +def find_lowest_version(versions, prefix): + """Lowest 'prefix' + 'major.minor' version name, or None.""" + candidates = [ + v.name for v in versions + if v.name.startswith(prefix) and VERSION_NAME_PATTERN.match(v.name[len(prefix):]) + ] + if not candidates: + eprint(f"No open '{prefix}*' version found.") + return None + candidates.sort(key=lambda name: version_sort_key(name[len(prefix):])) + eprint(f"Found '{prefix}*' versions {candidates}, using '{candidates[0]}'.") + return candidates[0] + + +def list_tag_refs(github_token, prefix): + """sonar-enterprise tag refs starting with prefix; raises on HTTP/JSON errors.""" + refs = [] + url, params = SONAR_ENTERPRISE_TAGS_URL + prefix, {'per_page': 100} + while url: + response = requests.get( + url, + headers={'Authorization': f'Bearer {github_token}', 'Accept': 'application/vnd.github+json'}, + params=params, + timeout=30, + ) + response.raise_for_status() + refs.extend(item['ref'] for item in response.json()) + url, params = response.links.get('next', {}).get('url'), None + return refs + + +def parse_shipped_versions(refs, prefix): + """Maps tag refs to Jira names, e.g. 'refs/tags/sqs-2026.5.2.1' -> 'sqs-2026.5'.""" + tag_pattern = re.compile(rf'^refs/tags/{re.escape(prefix)}(\d+)\.(\d+)\.') + matches = (tag_pattern.match(ref) for ref in refs) + return {f'{prefix}{m.group(1)}.{m.group(2)}' for m in matches if m} + + +def fetch_shipped_versions(github_token, prefix): + """Jira version names already tagged in sonar-enterprise, or None on failure.""" + try: + return parse_shipped_versions(list_tag_refs(github_token, prefix), prefix) + except (requests.RequestException, ValueError) as e: + eprint(f"Warning: Failed to list sonar-enterprise '{prefix}*' tags: {e}") + return None + + +def fetch_open_versions(jira_client, project_key): + """Unreleased, non-archived project versions, or None on failure.""" + try: + versions = jira_client.project_versions(project_key) + except JIRAError as e: + eprint(f"Warning: Failed to fetch versions for project '{project_key}'. Status: {e.status_code}") + eprint("Warning: Skipping automatic 'Fix versions' assignment.") + return None + return [ + v for v in versions + if not getattr(v, 'released', False) and not getattr(v, 'archived', False) + ] + + +def exclude_shipped_versions(versions, github_token, prefix): + """Drops versions already tagged in sonar-enterprise; unchanged if the lookup fails.""" + shipped = fetch_shipped_versions(github_token, prefix) + if not shipped: + return versions + eprint(f"Skipping '{prefix}*' versions already tagged in sonar-enterprise: {sorted(shipped)}") + return [v for v in versions if v.name not in shipped] + + +def resolve_fix_versions(jira_client, project_key, edition, github_token): + """Fix version names for the edition, skipping tagged ones; [] on failure, never blocks.""" + prefixes = EDITION_VERSION_PREFIXES.get(edition) + if not prefixes: + return [] + + open_versions = fetch_open_versions(jira_client, project_key) + if open_versions is None: + return [] + + if not github_token: + eprint("Warning: No GITHUB_TOKEN, not cross-referencing versions with sonar-enterprise tags.") + + fix_versions = [] + for prefix in prefixes: + candidates = exclude_shipped_versions(open_versions, github_token, prefix) if github_token else open_versions + fix_versions.append(find_lowest_version(candidates, prefix)) + return [name for name in fix_versions if name] def validate_release_ticket(jira_client, release_ticket_key): @@ -82,6 +191,11 @@ def create_integration_ticket(jira_client, args): if getattr(args, 'edition', None): ticket_details[CUSTOM_FIELDS['EDITION']] = {'value': args.edition} + fix_versions = resolve_fix_versions( + jira_client, args.target_jira_project, args.edition, os.environ.get('GITHUB_TOKEN') + ) + if fix_versions: + ticket_details['fixVersions'] = [{'name': name} for name in fix_versions] if getattr(args, 'team', None): ticket_details[CUSTOM_FIELDS['TEAM']] = args.team diff --git a/create-integration-ticket/requirements.txt b/create-integration-ticket/requirements.txt index 81a1a938..c737785c 100644 --- a/create-integration-ticket/requirements.txt +++ b/create-integration-ticket/requirements.txt @@ -1,2 +1,2 @@ jira==3.10.5 - +requests==2.34.2 diff --git a/create-integration-ticket/test_create_integration_ticket.py b/create-integration-ticket/test_create_integration_ticket.py index 5223edf2..d64754e2 100644 --- a/create-integration-ticket/test_create_integration_ticket.py +++ b/create-integration-ticket/test_create_integration_ticket.py @@ -6,6 +6,7 @@ """ import unittest +import requests from unittest.mock import Mock, patch import sys import os @@ -16,12 +17,22 @@ from create_integration_ticket import ( validate_release_ticket, create_integration_ticket, - link_tickets, main + link_tickets, main, version_sort_key, find_lowest_version, resolve_fix_versions, + fetch_shipped_versions, list_tag_refs, parse_shipped_versions, fetch_open_versions, + exclude_shipped_versions ) from jira_common import CUSTOM_FIELDS from jira.exceptions import JIRAError +def make_version(name, released=False, archived=False): + version = Mock() + version.name = name + version.released = released + version.archived = archived + return version + + class TestCreateIntegrationTicket(unittest.TestCase): def setUp(self): @@ -650,6 +661,7 @@ def test_create_integration_ticket_edition_and_team_combinations(self): mock_jira.createmeta.return_value = { 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] } + mock_jira.project_versions.return_value = [] mock_ticket = Mock() mock_ticket.key = 'SQS-44' mock_jira.create_issue.return_value = mock_ticket @@ -674,6 +686,373 @@ def test_create_integration_ticket_edition_and_team_combinations(self): else: self.assertNotIn(CUSTOM_FIELDS['TEAM'], call_args) + def test_version_sort_key_ordering(self): + """'major.minor' versions sort numerically, not lexicographically (so '26.10' > '26.9').""" + names = ['26.11', '26.9', '26.10'] + self.assertEqual(sorted(names, key=version_sort_key), ['26.9', '26.10', '26.11']) + + def test_find_lowest_version_picks_lowest(self): + versions = [ + make_version('sqcb-26.11'), + make_version('sqcb-26.9'), + make_version('sqcb-26.10'), + ] + self.assertEqual(find_lowest_version(versions, 'sqcb-'), 'sqcb-26.9') + + def test_find_lowest_version_filters_by_prefix(self): + versions = [make_version('sqs-2026.5'), make_version('2026.4')] + self.assertIsNone(find_lowest_version(versions, 'sqcb-')) + + def test_find_lowest_version_no_candidates(self): + self.assertIsNone(find_lowest_version([], 'sqcb-')) + + def test_find_lowest_version_skips_bugfix_versions(self): + """Bugfix versions ('major.minor.patch') are ignored.""" + versions = [make_version('sqs-2025.4.9'), make_version('sqs-2025.5')] + self.assertEqual(find_lowest_version(versions, 'sqs-'), 'sqs-2025.5') + + def test_find_lowest_version_skips_non_matching_format(self): + versions = [make_version('sqcb-26.10-RC1'), make_version('sqcb-26.11')] + self.assertEqual(find_lowest_version(versions, 'sqcb-'), 'sqcb-26.11') + + def test_resolve_fix_versions_community_build(self): + mock_jira = Mock() + mock_jira.project_versions.return_value = [ + make_version('sqcb-26.10'), make_version('sqcb-26.9'), make_version('sqs-2026.5'), + ] + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), ['sqcb-26.9']) + + def test_resolve_fix_versions_server(self): + mock_jira = Mock() + mock_jira.project_versions.return_value = [ + make_version('sqcb-26.9'), make_version('sqs-2026.6'), make_version('sqs-2026.5'), + ] + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Server', None), ['sqs-2026.5']) + + def test_resolve_fix_versions_community_build_and_server_orders_sqcb_first(self): + mock_jira = Mock() + mock_jira.project_versions.return_value = [ + make_version('sqs-2026.5'), make_version('sqcb-26.9'), + ] + self.assertEqual( + resolve_fix_versions(mock_jira, 'SONAR', 'Community Build & Server', None), + ['sqcb-26.9', 'sqs-2026.5'] + ) + mock_jira.project_versions.assert_called_once_with('SONAR') + + def test_resolve_fix_versions_na_skips_lookup_entirely(self): + mock_jira = Mock() + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'N/A', None), []) + mock_jira.project_versions.assert_not_called() + + def test_resolve_fix_versions_unknown_edition(self): + mock_jira = Mock() + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Something Else', None), []) + mock_jira.project_versions.assert_not_called() + + def test_resolve_fix_versions_no_matching_version(self): + mock_jira = Mock() + mock_jira.project_versions.return_value = [make_version('sqs-2026.5')] + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), []) + + def test_resolve_fix_versions_skips_released_and_archived(self): + mock_jira = Mock() + mock_jira.project_versions.return_value = [ + make_version('sqcb-26.9', released=True), + make_version('sqcb-26.10', archived=True), + make_version('sqcb-26.11'), + ] + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), ['sqcb-26.11']) + + @patch('create_integration_ticket.eprint') + def test_resolve_fix_versions_lookup_error_is_non_fatal(self, mock_eprint): + mock_jira = Mock() + mock_jira.project_versions.side_effect = JIRAError(status_code=500, text="Server Error") + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), []) + warnings = ' '.join(call.args[0] for call in mock_eprint.call_args_list) + self.assertIn('Failed to fetch versions', warnings) + self.assertIn("Skipping automatic 'Fix versions' assignment", warnings) + + def test_find_lowest_version_accepts_single_digit_major(self): + """A single-digit major (e.g. '9.1') must not be rejected by the name pattern.""" + versions = [make_version('sqcb-9.1'), make_version('sqcb-26.9')] + self.assertEqual(find_lowest_version(versions, 'sqcb-'), 'sqcb-9.1') + + def test_find_lowest_version_accepts_three_digit_minor(self): + """A three-digit minor like '2026.100' is accepted.""" + versions = [make_version('sqs-2026.100'), make_version('sqs-2026.99')] + self.assertEqual(find_lowest_version(versions, 'sqs-'), 'sqs-2026.99') + + def test_find_lowest_version_rejects_prefix_collision(self): + """Names with extra text after the prefix (e.g. 'sqcb-lts-26.9') are ignored.""" + versions = [make_version('sqcb-lts-26.9'), make_version('sqs-next'), make_version('sqcb-26.10')] + self.assertEqual(find_lowest_version(versions, 'sqcb-'), 'sqcb-26.10') + self.assertIsNone(find_lowest_version(versions, 'sqs-')) + + @patch.dict(os.environ, {'GITHUB_TOKEN': ''}) + def test_create_integration_ticket_sets_fix_versions_for_edition(self): + """Community Build & Server pulls both an sqcb- and an sqs- fix version onto the ticket.""" + mock_jira = Mock() + mock_jira.createmeta.return_value = { + 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] + } + mock_jira.project_versions.return_value = [ + make_version('sqcb-26.9'), make_version('sqs-2026.5'), + ] + mock_ticket = Mock() + mock_ticket.key = 'SQS-50' + mock_jira.create_issue.return_value = mock_ticket + + args = Mock() + args.target_jira_project = 'SONAR' + args.ticket_summary = 'Update sonar-security to 1.0.0' + args.ticket_description = None + args.parent_epic = None + args.edition = 'Community Build & Server' + args.team = None + + create_integration_ticket(mock_jira, args) + + call_args = mock_jira.create_issue.call_args[1]['fields'] + self.assertEqual(call_args['fixVersions'], [{'name': 'sqcb-26.9'}, {'name': 'sqs-2026.5'}]) + + @patch.dict(os.environ, {'GITHUB_TOKEN': ''}) + def test_create_integration_ticket_sets_edition_team_and_fix_versions_together(self): + """Edition, team and fix versions coexist in a single create_issue payload.""" + mock_jira = Mock() + mock_jira.createmeta.return_value = { + 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] + } + mock_jira.project_versions.return_value = [ + make_version('sqcb-26.9'), make_version('sqs-2026.5'), + ] + mock_ticket = Mock() + mock_ticket.key = 'SQS-54' + mock_jira.create_issue.return_value = mock_ticket + + args = Mock() + args.target_jira_project = 'SONAR' + args.ticket_summary = 'Update sonar-security to 1.0.0' + args.ticket_description = None + args.parent_epic = None + args.edition = 'Community Build & Server' + args.team = 'f1da89c9-3712-4d15-b194-a4b24406e3e4' + + create_integration_ticket(mock_jira, args) + + call_args = mock_jira.create_issue.call_args[1]['fields'] + self.assertEqual(call_args[CUSTOM_FIELDS['EDITION']], {'value': 'Community Build & Server'}) + self.assertEqual(call_args[CUSTOM_FIELDS['TEAM']], 'f1da89c9-3712-4d15-b194-a4b24406e3e4') + self.assertEqual(call_args['fixVersions'], [{'name': 'sqcb-26.9'}, {'name': 'sqs-2026.5'}]) + + def test_create_integration_ticket_na_edition_sets_no_fix_versions(self): + mock_jira = Mock() + mock_jira.createmeta.return_value = { + 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] + } + mock_ticket = Mock() + mock_ticket.key = 'SQS-51' + mock_jira.create_issue.return_value = mock_ticket + + args = Mock() + args.target_jira_project = 'SONAR' + args.ticket_summary = 'Update sonar-security to 1.0.0' + args.ticket_description = None + args.parent_epic = None + args.edition = 'N/A' + args.team = None + + create_integration_ticket(mock_jira, args) + + call_args = mock_jira.create_issue.call_args[1]['fields'] + self.assertNotIn('fixVersions', call_args) + mock_jira.project_versions.assert_not_called() + + def test_create_integration_ticket_no_edition_sets_no_fix_versions(self): + mock_jira = Mock() + mock_jira.createmeta.return_value = { + 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] + } + mock_ticket = Mock() + mock_ticket.key = 'SQS-52' + mock_jira.create_issue.return_value = mock_ticket + + args = Mock() + args.target_jira_project = 'SONAR' + args.ticket_summary = 'Update sonar-security to 1.0.0' + args.ticket_description = None + args.parent_epic = None + args.edition = None + args.team = None + + create_integration_ticket(mock_jira, args) + + call_args = mock_jira.create_issue.call_args[1]['fields'] + self.assertNotIn('fixVersions', call_args) + mock_jira.project_versions.assert_not_called() + + def test_create_integration_ticket_no_matching_version_sets_no_fix_versions(self): + mock_jira = Mock() + mock_jira.createmeta.return_value = { + 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] + } + mock_jira.project_versions.return_value = [] + mock_ticket = Mock() + mock_ticket.key = 'SQS-53' + mock_jira.create_issue.return_value = mock_ticket + + args = Mock() + args.target_jira_project = 'SONAR' + args.ticket_summary = 'Update sonar-security to 1.0.0' + args.ticket_description = None + args.parent_epic = None + args.edition = 'Community Build' + args.team = None + + create_integration_ticket(mock_jira, args) + + call_args = mock_jira.create_issue.call_args[1]['fields'] + self.assertNotIn('fixVersions', call_args) + + def test_resolve_fix_versions_skips_versions_already_tagged(self): + """An open Jira version with a sonar-enterprise tag is already shipped, so the next one wins.""" + mock_jira = Mock() + mock_jira.project_versions.return_value = [make_version('sqcb-26.9'), make_version('sqcb-26.10')] + with patch('create_integration_ticket.fetch_shipped_versions', return_value={'sqcb-26.9'}): + self.assertEqual( + resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', 'token'), ['sqcb-26.10'] + ) + + def test_resolve_fix_versions_filters_each_prefix_by_its_own_tags(self): + mock_jira = Mock() + mock_jira.project_versions.return_value = [ + make_version('sqcb-26.9'), make_version('sqcb-26.10'), + make_version('sqs-2026.5'), make_version('sqs-2026.6'), + ] + shipped = {'sqcb-': {'sqcb-26.9'}, 'sqs-': set()} + with patch('create_integration_ticket.fetch_shipped_versions', + side_effect=lambda token, prefix: shipped[prefix]): + self.assertEqual( + resolve_fix_versions(mock_jira, 'SONAR', 'Community Build & Server', 'token'), + ['sqcb-26.10', 'sqs-2026.5'] + ) + + def test_resolve_fix_versions_all_candidates_tagged_yields_none(self): + mock_jira = Mock() + mock_jira.project_versions.return_value = [make_version('sqs-2026.5')] + with patch('create_integration_ticket.fetch_shipped_versions', return_value={'sqs-2026.5'}): + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Server', 'token'), []) + + def test_resolve_fix_versions_tag_lookup_failure_falls_back_to_jira(self): + mock_jira = Mock() + mock_jira.project_versions.return_value = [make_version('sqcb-26.9'), make_version('sqcb-26.10')] + with patch('create_integration_ticket.fetch_shipped_versions', return_value=None): + self.assertEqual( + resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', 'token'), ['sqcb-26.9'] + ) + + @patch('create_integration_ticket.eprint') + def test_resolve_fix_versions_without_token_warns_and_skips_tag_lookup(self, mock_eprint): + mock_jira = Mock() + mock_jira.project_versions.return_value = [make_version('sqcb-26.9')] + with patch('create_integration_ticket.fetch_shipped_versions') as mock_fetch: + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), ['sqcb-26.9']) + mock_fetch.assert_not_called() + warnings = ' '.join(call.args[0] for call in mock_eprint.call_args_list) + self.assertIn('No GITHUB_TOKEN', warnings) + + def test_resolve_fix_versions_na_makes_no_tag_lookup(self): + with patch('create_integration_ticket.fetch_shipped_versions') as mock_fetch: + self.assertEqual(resolve_fix_versions(Mock(), 'SONAR', 'N/A', 'token'), []) + mock_fetch.assert_not_called() + + @patch('create_integration_ticket.eprint') + def test_resolve_fix_versions_without_token_warns_once_for_all_prefixes(self, mock_eprint): + mock_jira = Mock() + mock_jira.project_versions.return_value = [make_version('sqcb-26.9'), make_version('sqs-2026.5')] + resolve_fix_versions(mock_jira, 'SONAR', 'Community Build & Server', None) + warnings = [c for c in mock_eprint.call_args_list if 'No GITHUB_TOKEN' in c.args[0]] + self.assertEqual(len(warnings), 1) + + def test_parse_shipped_versions_maps_tags_to_jira_versions(self): + """Bugfix and build-number tag segments collapse onto the 'major.minor' Jira version.""" + refs = [ + 'refs/tags/sqs-2026.5.0.132233', + 'refs/tags/sqs-2026.5.2.132813', + 'refs/tags/sqs-2026.4.1.126914', + 'refs/tags/sqs-2026.6', + ] + self.assertEqual(parse_shipped_versions(refs, 'sqs-'), {'sqs-2026.5', 'sqs-2026.4'}) + + def test_parse_shipped_versions_ignores_other_prefixes_and_malformed_refs(self): + refs = ['refs/tags/sqsx-2026.5.0.1', 'refs/tags/sqs-foo', 'refs/tags/sqcb-26.9.0.1'] + self.assertEqual(parse_shipped_versions(refs, 'sqs-'), set()) + + def test_parse_shipped_versions_empty(self): + self.assertEqual(parse_shipped_versions([], 'sqs-'), set()) + + @patch('create_integration_ticket.requests.get') + def test_list_tag_refs_sends_token_and_returns_ref_names(self, mock_get): + response = Mock(links={}) + response.json.return_value = [{'ref': 'refs/tags/sqs-2026.5.0.1'}] + mock_get.return_value = response + self.assertEqual(list_tag_refs('token', 'sqs-'), ['refs/tags/sqs-2026.5.0.1']) + self.assertEqual(mock_get.call_args.kwargs['headers']['Authorization'], 'Bearer token') + self.assertTrue(mock_get.call_args.args[0].endswith('/tags/sqs-')) + + @patch('create_integration_ticket.requests.get') + def test_list_tag_refs_follows_pagination(self, mock_get): + page1 = Mock(links={'next': {'url': 'https://api.github.com/next'}}) + page1.json.return_value = [{'ref': 'refs/tags/sqs-2025.1.0.1'}] + page2 = Mock(links={}) + page2.json.return_value = [{'ref': 'refs/tags/sqs-2026.5.0.2'}] + mock_get.side_effect = [page1, page2] + self.assertEqual( + list_tag_refs('token', 'sqs-'), ['refs/tags/sqs-2025.1.0.1', 'refs/tags/sqs-2026.5.0.2'] + ) + self.assertEqual(mock_get.call_args.args[0], 'https://api.github.com/next') + + @patch('create_integration_ticket.requests.get') + def test_list_tag_refs_raises_on_http_error(self, mock_get): + mock_get.return_value.raise_for_status.side_effect = requests.HTTPError('404 Not Found') + with self.assertRaises(requests.HTTPError): + list_tag_refs('token', 'sqs-') + + def test_fetch_shipped_versions_combines_listing_and_parsing(self): + with patch('create_integration_ticket.list_tag_refs', return_value=['refs/tags/sqs-2026.5.0.1']): + self.assertEqual(fetch_shipped_versions('token', 'sqs-'), {'sqs-2026.5'}) + + @patch('create_integration_ticket.eprint') + def test_fetch_shipped_versions_http_error_returns_none(self, mock_eprint): + with patch('create_integration_ticket.list_tag_refs', side_effect=requests.HTTPError('404 Not Found')): + self.assertIsNone(fetch_shipped_versions('token', 'sqs-')) + self.assertIn('Failed to list sonar-enterprise', mock_eprint.call_args.args[0]) + + def test_fetch_open_versions_filters_released_and_archived(self): + mock_jira = Mock() + open_version = make_version('sqs-2026.6') + mock_jira.project_versions.return_value = [ + make_version('sqs-2026.4', released=True), make_version('sqs-2026.5', archived=True), open_version, + ] + self.assertEqual(fetch_open_versions(mock_jira, 'SONAR'), [open_version]) + + @patch('create_integration_ticket.eprint') + def test_fetch_open_versions_returns_none_on_jira_error(self, mock_eprint): + mock_jira = Mock() + mock_jira.project_versions.side_effect = JIRAError(status_code=500) + self.assertIsNone(fetch_open_versions(mock_jira, 'SONAR')) + + def test_exclude_shipped_versions_removes_tagged(self): + tagged, untagged = make_version('sqs-2026.5'), make_version('sqs-2026.6') + with patch('create_integration_ticket.fetch_shipped_versions', return_value={'sqs-2026.5'}): + self.assertEqual(exclude_shipped_versions([tagged, untagged], 'token', 'sqs-'), [untagged]) + + def test_exclude_shipped_versions_unchanged_when_lookup_fails_or_empty(self): + versions = [make_version('sqs-2026.5')] + for shipped in (None, set()): + with patch('create_integration_ticket.fetch_shipped_versions', return_value=shipped): + self.assertEqual(exclude_shipped_versions(versions, 'token', 'sqs-'), versions) + if __name__ == '__main__': unittest.main() diff --git a/docs/AUTOMATED_RELEASE.md b/docs/AUTOMATED_RELEASE.md index a5582a57..e171e424 100644 --- a/docs/AUTOMATED_RELEASE.md +++ b/docs/AUTOMATED_RELEASE.md @@ -176,6 +176,7 @@ jobs: - `sqs-ticket-edition` and `sqs-sqc-ticket-team` are optional and applied at ticket creation, so a bad value or a field missing from the project's create screen fails the job. `team` takes the Atlassian team **UUID**, not the name — read `customfield_10001.id` off an existing ticket. +- The SQS ticket's `Fix versions` skip versions already tagged in `sonar-enterprise` (vault token from `release-automation-secret-name`, default `sonar-{plugin-name}-release-automation`). - Summaries: - Each job includes a "Summary" step that writes to `$GITHUB_STEP_SUMMARY` only when `verbose: true`. - For non-sandbox releases, a short release announcement containing the project, released version, and GitHub release-notes link is sent to `#team-code-quality-pm-em-lead` after the GitHub release is created. Set `code-quality-leads-slack-notification: false` to opt out. The announcement is skipped when `use-jira-sandbox: true` (the default dry-run mode). diff --git a/test-fixtures/jira/README.md b/test-fixtures/jira/README.md index a7037787..3b323922 100644 --- a/test-fixtures/jira/README.md +++ b/test-fixtures/jira/README.md @@ -47,17 +47,17 @@ python cleanup.py \ ### `assert_ticket_fields.py` -Re-reads a ticket and asserts its Edition/Team values, catching a wrong custom field ID or -value shape. Used by the `create-integration-ticket` sandbox integration test, which has no -setup script — it links against the long-lived sandbox ticket `SONAR-22193` instead, and cleans -up only the tickets it creates via `cleanup.py --issue-keys` above. +Re-reads a ticket and asserts its Edition/Team/Fix versions values, catching a wrong custom +field ID or value shape. Used by the `create-integration-ticket` sandbox integration test, which +has no setup script — it links against the long-lived sandbox ticket `SONAR-22193` instead, and +cleans up only the tickets it creates via `cleanup.py --issue-keys` above. -Both `--team` and `--edition` are required, with `NONE` meaning "must be unset", so a run -cannot pass without asserting anything. +`--team`, `--edition` and `--fix-versions` are required. `NONE` means "must be unset"; +`--fix-versions` also accepts `ANY`, meaning "must be set". ```bash python assert_ticket_fields.py --use-sandbox true --ticket-key SONAR-101 \ - --team "$TEAM_UUID" --edition "Community Build & Server" + --team "$TEAM_UUID" --edition "Community Build & Server" --fix-versions ANY ``` ## Usage in GitHub Actions Workflows diff --git a/test-fixtures/jira/assert_ticket_fields.py b/test-fixtures/jira/assert_ticket_fields.py index 7ad69008..fc61baf2 100644 --- a/test-fixtures/jira/assert_ticket_fields.py +++ b/test-fixtures/jira/assert_ticket_fields.py @@ -1,19 +1,13 @@ #!/usr/bin/env python3 """ -Asserts the Edition and Team fields on a Jira ticket, re-reading it from the server. - -Re-reading rather than trusting the create call is the only way to catch a wrong custom field -ID, a wrong value shape, or a field silently dropped because it is not on the project's create -screen. - -Both expectations are required, and NONE means "must be unset", so a run can never pass by -simply not asserting anything. +Re-reads a Jira ticket and asserts its Edition, Team and Fix versions (NONE = unset, ANY = set). Usage: python assert_ticket_fields.py --use-sandbox true --ticket-key SONAR-101 \ - --team f1da89c9-3712-4d15-b194-a4b24406e3e4 --edition "Community Build & Server" + --team f1da89c9-3712-4d15-b194-a4b24406e3e4 --edition "Community Build & Server" \ + --fix-versions ANY python assert_ticket_fields.py --use-sandbox true --ticket-key GHA-102 \ - --team NONE --edition NONE + --team NONE --edition NONE --fix-versions NONE """ import argparse @@ -25,6 +19,7 @@ from jira_client import get_jira_instance, eprint UNSET = 'NONE' +ANY = 'ANY' def actual(fields, field_id, key): @@ -33,23 +28,43 @@ def actual(fields, field_id, key): return value.get(key) if isinstance(value, dict) else value +def actual_fix_versions(fields): + """fixVersions reads back as a list of objects, each carrying 'name'.""" + return {v['name'] for v in fields.get('fixVersions', [])} + + +def check(name, expected_arg, got, parse_expected, empty): + """Compares one field: NONE = must be empty, ANY = must be set, else exact match.""" + if expected_arg == UNSET: + ok, expected = got == empty, empty + elif expected_arg == ANY: + ok, expected = got != empty, f'<{ANY}>' + else: + expected = parse_expected(expected_arg) + ok, expected = got == expected, expected + return ok, expected + + def main(): - parser = argparse.ArgumentParser(description="Assert Edition/Team on a Jira ticket.") + parser = argparse.ArgumentParser(description="Assert Edition/Team/Fix versions on a Jira ticket.") parser.add_argument("--use-sandbox", default="false") parser.add_argument("--ticket-key", required=True) parser.add_argument("--team", required=True, help=f"Expected team UUID, or {UNSET}.") parser.add_argument("--edition", required=True, help=f"Expected Edition value, or {UNSET}.") + parser.add_argument("--fix-versions", required=True, + help=f"Comma-separated expected Fix versions names, {UNSET}, or {ANY}.") args = parser.parse_args() fields = get_jira_instance(args.use_sandbox).issue(args.ticket_key).raw['fields'] failed = False - for name, expected, got in [ - ('team', args.team, actual(fields, CUSTOM_FIELDS['TEAM'], 'id')), - ('edition', args.edition, actual(fields, CUSTOM_FIELDS['EDITION'], 'value')), + for name, expected_arg, got, parse_expected, empty in [ + ('team', args.team, actual(fields, CUSTOM_FIELDS['TEAM'], 'id'), lambda v: v, None), + ('edition', args.edition, actual(fields, CUSTOM_FIELDS['EDITION'], 'value'), lambda v: v, None), + ('fixVersions', args.fix_versions, actual_fix_versions(fields), lambda v: set(v.split(',')), set()), ]: - expected = None if expected == UNSET else expected - if got == expected: + ok, expected = check(name, expected_arg, got, parse_expected, empty) + if ok: eprint(f"✅ {args.ticket_key} {name}: {got!r}") else: eprint(f"❌ {args.ticket_key} {name}: expected {expected!r}, got {got!r}") From 30455c349cd3d1cebc675bfa8b76f5207091e3d3 Mon Sep 17 00:00:00 2001 From: Jonas Wielage Date: Tue, 6 Oct 2026 13:08:14 +0200 Subject: [PATCH 2/8] Address review nits --- .okf/actions/create-integration-ticket.md | 7 +- create-integration-ticket/action.yml | 4 +- .../create_integration_ticket.py | 4 +- .../test_create_integration_ticket.py | 152 ++++++------------ test-fixtures/jira/assert_ticket_fields.py | 6 +- 5 files changed, 58 insertions(+), 115 deletions(-) diff --git a/.okf/actions/create-integration-ticket.md b/.okf/actions/create-integration-ticket.md index 6602e3b1..8a6c397d 100644 --- a/.okf/actions/create-integration-ticket.md +++ b/.okf/actions/create-integration-ticket.md @@ -43,9 +43,10 @@ set, since which version wins is unit-tested. When `edition` is set, `fixVersions` gets the lowest open `major.minor` version per prefix: `sqcb-` (Community Build), `sqs-` (Server) or both; `N/A` sets nothing. Released, archived and -`sonar-enterprise`-tagged versions are skipped. The tag lookup uses a token resolved like -[create-pull-request](/actions/create-pull-request.md) (vault `SonarSource-` if the `secret-name` input is set, else `-release-automation`, else the `token` -input; no token only warns). Any failure falls back to Jira-only or no fix version, never blocking. +`sonar-enterprise`-tagged versions are skipped. The tag lookup uses a token from vault `SonarSource-` if the `secret-name` input is +set, else `{REPO_OWNER_NAME_DASH}-release-automation`, falling back to the `token` input (the same +vault-then-token fallback as [create-pull-request](/actions/create-pull-request.md)); no token only +warns. Any failure falls back to Jira-only or no fix version, never blocking. # Citations diff --git a/create-integration-ticket/action.yml b/create-integration-ticket/action.yml index aa4a2074..54e422d0 100644 --- a/create-integration-ticket/action.yml +++ b/create-integration-ticket/action.yml @@ -63,7 +63,7 @@ runs: - name: Get GitHub token from Vault id: github_secrets - if: ${{ inputs.edition != '' }} + if: ${{ inputs.edition != '' && inputs.edition != 'N/A' }} continue-on-error: true uses: SonarSource/vault-action-wrapper@320bd31b03e5dacaac6be51bbbb15adf7caccc32 # v3.1 with: @@ -72,7 +72,7 @@ runs: - name: Resolve GitHub token id: resolve_github_token - if: ${{ inputs.edition != '' }} + if: ${{ inputs.edition != '' && inputs.edition != 'N/A' }} shell: bash env: VAULT_OUTCOME: ${{ steps.github_secrets.outcome }} diff --git a/create-integration-ticket/create_integration_ticket.py b/create-integration-ticket/create_integration_ticket.py index a67c50e7..1760fdb6 100644 --- a/create-integration-ticket/create_integration_ticket.py +++ b/create-integration-ticket/create_integration_ticket.py @@ -51,7 +51,7 @@ def find_lowest_version(versions, prefix): def list_tag_refs(github_token, prefix): - """sonar-enterprise tag refs starting with prefix; raises on HTTP/JSON errors.""" + """sonar-enterprise tag refs starting with prefix; raises on HTTP errors and malformed payloads.""" refs = [] url, params = SONAR_ENTERPRISE_TAGS_URL + prefix, {'per_page': 100} while url: @@ -78,7 +78,7 @@ def fetch_shipped_versions(github_token, prefix): """Jira version names already tagged in sonar-enterprise, or None on failure.""" try: return parse_shipped_versions(list_tag_refs(github_token, prefix), prefix) - except (requests.RequestException, ValueError) as e: + except (requests.RequestException, ValueError, KeyError, TypeError) as e: eprint(f"Warning: Failed to list sonar-enterprise '{prefix}*' tags: {e}") return None diff --git a/create-integration-ticket/test_create_integration_ticket.py b/create-integration-ticket/test_create_integration_ticket.py index d64754e2..44d7dc98 100644 --- a/create-integration-ticket/test_create_integration_ticket.py +++ b/create-integration-ticket/test_create_integration_ticket.py @@ -33,6 +33,7 @@ def make_version(name, released=False, archived=False): return version +@patch.dict(os.environ, {'GITHUB_TOKEN': ''}) class TestCreateIntegrationTicket(unittest.TestCase): def setUp(self): @@ -740,30 +741,20 @@ def test_resolve_fix_versions_community_build_and_server_orders_sqcb_first(self) ) mock_jira.project_versions.assert_called_once_with('SONAR') - def test_resolve_fix_versions_na_skips_lookup_entirely(self): - mock_jira = Mock() - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'N/A', None), []) - mock_jira.project_versions.assert_not_called() - - def test_resolve_fix_versions_unknown_edition(self): - mock_jira = Mock() - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Something Else', None), []) - mock_jira.project_versions.assert_not_called() + def test_resolve_fix_versions_editions_without_prefix_skip_all_lookups(self): + for edition in ('N/A', 'Something Else'): + with self.subTest(edition=edition): + mock_jira = Mock() + with patch('create_integration_ticket.fetch_shipped_versions') as mock_fetch: + self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', edition, 'token'), []) + mock_jira.project_versions.assert_not_called() + mock_fetch.assert_not_called() def test_resolve_fix_versions_no_matching_version(self): mock_jira = Mock() mock_jira.project_versions.return_value = [make_version('sqs-2026.5')] self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), []) - def test_resolve_fix_versions_skips_released_and_archived(self): - mock_jira = Mock() - mock_jira.project_versions.return_value = [ - make_version('sqcb-26.9', released=True), - make_version('sqcb-26.10', archived=True), - make_version('sqcb-26.11'), - ] - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), ['sqcb-26.11']) - @patch('create_integration_ticket.eprint') def test_resolve_fix_versions_lookup_error_is_non_fatal(self, mock_eprint): mock_jira = Mock() @@ -789,34 +780,6 @@ def test_find_lowest_version_rejects_prefix_collision(self): self.assertEqual(find_lowest_version(versions, 'sqcb-'), 'sqcb-26.10') self.assertIsNone(find_lowest_version(versions, 'sqs-')) - @patch.dict(os.environ, {'GITHUB_TOKEN': ''}) - def test_create_integration_ticket_sets_fix_versions_for_edition(self): - """Community Build & Server pulls both an sqcb- and an sqs- fix version onto the ticket.""" - mock_jira = Mock() - mock_jira.createmeta.return_value = { - 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] - } - mock_jira.project_versions.return_value = [ - make_version('sqcb-26.9'), make_version('sqs-2026.5'), - ] - mock_ticket = Mock() - mock_ticket.key = 'SQS-50' - mock_jira.create_issue.return_value = mock_ticket - - args = Mock() - args.target_jira_project = 'SONAR' - args.ticket_summary = 'Update sonar-security to 1.0.0' - args.ticket_description = None - args.parent_epic = None - args.edition = 'Community Build & Server' - args.team = None - - create_integration_ticket(mock_jira, args) - - call_args = mock_jira.create_issue.call_args[1]['fields'] - self.assertEqual(call_args['fixVersions'], [{'name': 'sqcb-26.9'}, {'name': 'sqs-2026.5'}]) - - @patch.dict(os.environ, {'GITHUB_TOKEN': ''}) def test_create_integration_ticket_sets_edition_team_and_fix_versions_together(self): """Edition, team and fix versions coexist in a single create_issue payload.""" mock_jira = Mock() @@ -845,51 +808,30 @@ def test_create_integration_ticket_sets_edition_team_and_fix_versions_together(s self.assertEqual(call_args[CUSTOM_FIELDS['TEAM']], 'f1da89c9-3712-4d15-b194-a4b24406e3e4') self.assertEqual(call_args['fixVersions'], [{'name': 'sqcb-26.9'}, {'name': 'sqs-2026.5'}]) - def test_create_integration_ticket_na_edition_sets_no_fix_versions(self): - mock_jira = Mock() - mock_jira.createmeta.return_value = { - 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] - } - mock_ticket = Mock() - mock_ticket.key = 'SQS-51' - mock_jira.create_issue.return_value = mock_ticket - - args = Mock() - args.target_jira_project = 'SONAR' - args.ticket_summary = 'Update sonar-security to 1.0.0' - args.ticket_description = None - args.parent_epic = None - args.edition = 'N/A' - args.team = None - - create_integration_ticket(mock_jira, args) - - call_args = mock_jira.create_issue.call_args[1]['fields'] - self.assertNotIn('fixVersions', call_args) - mock_jira.project_versions.assert_not_called() - - def test_create_integration_ticket_no_edition_sets_no_fix_versions(self): - mock_jira = Mock() - mock_jira.createmeta.return_value = { - 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] - } - mock_ticket = Mock() - mock_ticket.key = 'SQS-52' - mock_jira.create_issue.return_value = mock_ticket + def test_create_integration_ticket_na_or_no_edition_sets_no_fix_versions(self): + for edition in ('N/A', None): + with self.subTest(edition=edition): + mock_jira = Mock() + mock_jira.createmeta.return_value = { + 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] + } + mock_ticket = Mock() + mock_ticket.key = 'SQS-51' + mock_jira.create_issue.return_value = mock_ticket - args = Mock() - args.target_jira_project = 'SONAR' - args.ticket_summary = 'Update sonar-security to 1.0.0' - args.ticket_description = None - args.parent_epic = None - args.edition = None - args.team = None + args = Mock() + args.target_jira_project = 'SONAR' + args.ticket_summary = 'Update sonar-security to 1.0.0' + args.ticket_description = None + args.parent_epic = None + args.edition = edition + args.team = None - create_integration_ticket(mock_jira, args) + create_integration_ticket(mock_jira, args) - call_args = mock_jira.create_issue.call_args[1]['fields'] - self.assertNotIn('fixVersions', call_args) - mock_jira.project_versions.assert_not_called() + call_args = mock_jira.create_issue.call_args[1]['fields'] + self.assertNotIn('fixVersions', call_args) + mock_jira.project_versions.assert_not_called() def test_create_integration_ticket_no_matching_version_sets_no_fix_versions(self): mock_jira = Mock() @@ -943,14 +885,6 @@ def test_resolve_fix_versions_all_candidates_tagged_yields_none(self): with patch('create_integration_ticket.fetch_shipped_versions', return_value={'sqs-2026.5'}): self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Server', 'token'), []) - def test_resolve_fix_versions_tag_lookup_failure_falls_back_to_jira(self): - mock_jira = Mock() - mock_jira.project_versions.return_value = [make_version('sqcb-26.9'), make_version('sqcb-26.10')] - with patch('create_integration_ticket.fetch_shipped_versions', return_value=None): - self.assertEqual( - resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', 'token'), ['sqcb-26.9'] - ) - @patch('create_integration_ticket.eprint') def test_resolve_fix_versions_without_token_warns_and_skips_tag_lookup(self, mock_eprint): mock_jira = Mock() @@ -961,11 +895,6 @@ def test_resolve_fix_versions_without_token_warns_and_skips_tag_lookup(self, moc warnings = ' '.join(call.args[0] for call in mock_eprint.call_args_list) self.assertIn('No GITHUB_TOKEN', warnings) - def test_resolve_fix_versions_na_makes_no_tag_lookup(self): - with patch('create_integration_ticket.fetch_shipped_versions') as mock_fetch: - self.assertEqual(resolve_fix_versions(Mock(), 'SONAR', 'N/A', 'token'), []) - mock_fetch.assert_not_called() - @patch('create_integration_ticket.eprint') def test_resolve_fix_versions_without_token_warns_once_for_all_prefixes(self, mock_eprint): mock_jira = Mock() @@ -1023,10 +952,23 @@ def test_fetch_shipped_versions_combines_listing_and_parsing(self): self.assertEqual(fetch_shipped_versions('token', 'sqs-'), {'sqs-2026.5'}) @patch('create_integration_ticket.eprint') - def test_fetch_shipped_versions_http_error_returns_none(self, mock_eprint): - with patch('create_integration_ticket.list_tag_refs', side_effect=requests.HTTPError('404 Not Found')): - self.assertIsNone(fetch_shipped_versions('token', 'sqs-')) - self.assertIn('Failed to list sonar-enterprise', mock_eprint.call_args.args[0]) + def test_fetch_shipped_versions_lookup_failure_returns_none(self, mock_eprint): + for error in (requests.HTTPError('404 Not Found'), ValueError('bad JSON')): + with self.subTest(error=error): + with patch('create_integration_ticket.list_tag_refs', side_effect=error): + self.assertIsNone(fetch_shipped_versions('token', 'sqs-')) + self.assertIn('Failed to list sonar-enterprise', mock_eprint.call_args.args[0]) + + @patch('create_integration_ticket.eprint') + @patch('create_integration_ticket.requests.get') + def test_fetch_shipped_versions_malformed_payload_returns_none(self, mock_get, mock_eprint): + for label, payload in (('item without ref', [{'name': 'x'}]), ('error object', {'message': 'Not Found'})): + with self.subTest(label): + response = Mock(links={}) + response.json.return_value = payload + mock_get.return_value = response + self.assertIsNone(fetch_shipped_versions('token', 'sqs-')) + self.assertIn('Failed to list sonar-enterprise', mock_eprint.call_args.args[0]) def test_fetch_open_versions_filters_released_and_archived(self): mock_jira = Mock() diff --git a/test-fixtures/jira/assert_ticket_fields.py b/test-fixtures/jira/assert_ticket_fields.py index fc61baf2..a598e5da 100644 --- a/test-fixtures/jira/assert_ticket_fields.py +++ b/test-fixtures/jira/assert_ticket_fields.py @@ -33,7 +33,7 @@ def actual_fix_versions(fields): return {v['name'] for v in fields.get('fixVersions', [])} -def check(name, expected_arg, got, parse_expected, empty): +def check(expected_arg, got, parse_expected, empty): """Compares one field: NONE = must be empty, ANY = must be set, else exact match.""" if expected_arg == UNSET: ok, expected = got == empty, empty @@ -41,7 +41,7 @@ def check(name, expected_arg, got, parse_expected, empty): ok, expected = got != empty, f'<{ANY}>' else: expected = parse_expected(expected_arg) - ok, expected = got == expected, expected + ok = got == expected return ok, expected @@ -63,7 +63,7 @@ def main(): ('edition', args.edition, actual(fields, CUSTOM_FIELDS['EDITION'], 'value'), lambda v: v, None), ('fixVersions', args.fix_versions, actual_fix_versions(fields), lambda v: set(v.split(',')), set()), ]: - ok, expected = check(name, expected_arg, got, parse_expected, empty) + ok, expected = check(expected_arg, got, parse_expected, empty) if ok: eprint(f"✅ {args.ticket_key} {name}: {got!r}") else: From 371c92690ef112b06ac1cfdea3abecbe6c1d50f4 Mon Sep 17 00:00:00 2001 From: Jonas Wielage Date: Tue, 6 Oct 2026 13:42:26 +0200 Subject: [PATCH 3/8] Temporary pin --- .github/workflows/automated-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/automated-release.yml b/.github/workflows/automated-release.yml index bedf7b6d..24fd0a9c 100644 --- a/.github/workflows/automated-release.yml +++ b/.github/workflows/automated-release.yml @@ -877,7 +877,7 @@ jobs: - name: Create SQS Ticket if: ${{ inputs.sqs-integration }} id: create-sqs-ticket - uses: SonarSource/release-github-actions/create-integration-ticket@master + uses: SonarSource/release-github-actions/create-integration-ticket@jw/integration-ticket-fix-versions with: plugin-name: ${{ inputs.plugin-name }} release-version: ${{ needs.prepare-release.outputs.release-version }} From 520f84adf1418a925f687fd1d1a50c75737e3dc2 Mon Sep 17 00:00:00 2001 From: Jonas Wielage Date: Tue, 6 Oct 2026 15:07:07 +0200 Subject: [PATCH 4/8] Improve logging --- .../create_integration_ticket.py | 22 +++++-- .../test_create_integration_ticket.py | 61 ++++++++++++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/create-integration-ticket/create_integration_ticket.py b/create-integration-ticket/create_integration_ticket.py index 1760fdb6..f308aaab 100644 --- a/create-integration-ticket/create_integration_ticket.py +++ b/create-integration-ticket/create_integration_ticket.py @@ -36,6 +36,11 @@ def version_sort_key(name): return int(major), int(minor) +def sort_by_version(names, prefix): + """Sorts 'prefix' + 'major.minor' names numerically, so 'sqcb-26.9' < 'sqcb-26.10'.""" + return sorted(names, key=lambda name: version_sort_key(name[len(prefix):])) + + def find_lowest_version(versions, prefix): """Lowest 'prefix' + 'major.minor' version name, or None.""" candidates = [ @@ -45,7 +50,7 @@ def find_lowest_version(versions, prefix): if not candidates: eprint(f"No open '{prefix}*' version found.") return None - candidates.sort(key=lambda name: version_sort_key(name[len(prefix):])) + candidates = sort_by_version(candidates, prefix) eprint(f"Found '{prefix}*' versions {candidates}, using '{candidates[0]}'.") return candidates[0] @@ -102,7 +107,10 @@ def exclude_shipped_versions(versions, github_token, prefix): shipped = fetch_shipped_versions(github_token, prefix) if not shipped: return versions - eprint(f"Skipping '{prefix}*' versions already tagged in sonar-enterprise: {sorted(shipped)}") + already_tagged = [v.name for v in versions if v.name in shipped] + if already_tagged: + eprint(f"Skipping unreleased '{prefix}*' versions already tagged in sonar-enterprise: " + f"{sort_by_version(already_tagged, prefix)}") return [v for v in versions if v.name not in shipped] @@ -110,8 +118,10 @@ def resolve_fix_versions(jira_client, project_key, edition, github_token): """Fix version names for the edition, skipping tagged ones; [] on failure, never blocks.""" prefixes = EDITION_VERSION_PREFIXES.get(edition) if not prefixes: + eprint(f"No fix versions for edition '{edition}'.") return [] + eprint(f"\nResolving fix versions for edition '{edition}' in project '{project_key}'...") open_versions = fetch_open_versions(jira_client, project_key) if open_versions is None: return [] @@ -122,8 +132,11 @@ def resolve_fix_versions(jira_client, project_key, edition, github_token): fix_versions = [] for prefix in prefixes: candidates = exclude_shipped_versions(open_versions, github_token, prefix) if github_token else open_versions - fix_versions.append(find_lowest_version(candidates, prefix)) - return [name for name in fix_versions if name] + lowest = find_lowest_version(candidates, prefix) + if lowest: + fix_versions.append(lowest) + eprint(f"Adding fix versions: {', '.join(fix_versions)}" if fix_versions else "No fix versions to add.") + return fix_versions def validate_release_ticket(jira_client, release_ticket_key): @@ -190,6 +203,7 @@ def create_integration_ticket(jira_client, args): ticket_details['parent'] = {'key': args.parent_epic} if getattr(args, 'edition', None): + eprint(f"Setting Edition: {args.edition}") ticket_details[CUSTOM_FIELDS['EDITION']] = {'value': args.edition} fix_versions = resolve_fix_versions( jira_client, args.target_jira_project, args.edition, os.environ.get('GITHUB_TOKEN') diff --git a/create-integration-ticket/test_create_integration_ticket.py b/create-integration-ticket/test_create_integration_ticket.py index 44d7dc98..dcaf138c 100644 --- a/create-integration-ticket/test_create_integration_ticket.py +++ b/create-integration-ticket/test_create_integration_ticket.py @@ -17,7 +17,7 @@ from create_integration_ticket import ( validate_release_ticket, create_integration_ticket, - link_tickets, main, version_sort_key, find_lowest_version, resolve_fix_versions, + link_tickets, main, version_sort_key, sort_by_version,find_lowest_version, resolve_fix_versions, fetch_shipped_versions, list_tag_refs, parse_shipped_versions, fetch_open_versions, exclude_shipped_versions ) @@ -692,6 +692,11 @@ def test_version_sort_key_ordering(self): names = ['26.11', '26.9', '26.10'] self.assertEqual(sorted(names, key=version_sort_key), ['26.9', '26.10', '26.11']) + def test_sort_by_version_orders_numerically_ignoring_prefix(self): + self.assertEqual( + sort_by_version(['sqcb-26.10', 'sqcb-26.9'], 'sqcb-'), ['sqcb-26.9', 'sqcb-26.10'] + ) + def test_find_lowest_version_picks_lowest(self): versions = [ make_version('sqcb-26.11'), @@ -995,6 +1000,60 @@ def test_exclude_shipped_versions_unchanged_when_lookup_fails_or_empty(self): with patch('create_integration_ticket.fetch_shipped_versions', return_value=shipped): self.assertEqual(exclude_shipped_versions(versions, 'token', 'sqs-'), versions) + @patch('create_integration_ticket.eprint') + def test_exclude_shipped_versions_logs_only_skipped_open_versions_sorted(self, mock_eprint): + versions = [make_version('sqcb-26.10'), make_version('sqcb-26.9'), make_version('sqcb-26.11')] + shipped = {'sqcb-24.12', 'sqcb-26.9', 'sqcb-26.10'} + with patch('create_integration_ticket.fetch_shipped_versions', return_value=shipped): + exclude_shipped_versions(versions, 'token', 'sqcb-') + mock_eprint.assert_called_once_with( + "Skipping unreleased 'sqcb-*' versions already tagged in sonar-enterprise: ['sqcb-26.9', 'sqcb-26.10']" + ) + + @patch('create_integration_ticket.eprint') + def test_exclude_shipped_versions_logs_nothing_when_no_open_version_is_tagged(self, mock_eprint): + with patch('create_integration_ticket.fetch_shipped_versions', return_value={'sqs-2025.1'}): + exclude_shipped_versions([make_version('sqs-2026.6')], 'token', 'sqs-') + mock_eprint.assert_not_called() + + @patch('create_integration_ticket.eprint') + def test_resolve_fix_versions_logs_header_and_summary(self, mock_eprint): + mock_jira = Mock() + mock_jira.project_versions.return_value = [make_version('sqcb-26.11'), make_version('sqs-2026.6')] + with patch('create_integration_ticket.fetch_shipped_versions', return_value=set()): + resolve_fix_versions(mock_jira, 'SONAR', 'Community Build & Server', 'token') + messages = [call.args[0] for call in mock_eprint.call_args_list] + self.assertEqual( + messages[0], "\nResolving fix versions for edition 'Community Build & Server' in project 'SONAR'..." + ) + self.assertEqual(messages[-1], 'Adding fix versions: sqcb-26.11, sqs-2026.6') + + @patch('create_integration_ticket.eprint') + def test_resolve_fix_versions_logs_when_nothing_to_add(self, mock_eprint): + mock_jira = Mock() + mock_jira.project_versions.return_value = [] + resolve_fix_versions(mock_jira, 'SONAR', 'Server', None) + self.assertEqual(mock_eprint.call_args.args[0], 'No fix versions to add.') + + @patch('create_integration_ticket.eprint') + def test_resolve_fix_versions_logs_editions_without_prefix(self, mock_eprint): + self.assertEqual(resolve_fix_versions(Mock(), 'SONAR', 'N/A', 'token'), []) + mock_eprint.assert_called_once_with("No fix versions for edition 'N/A'.") + + @patch('create_integration_ticket.eprint') + def test_create_integration_ticket_logs_edition(self, mock_eprint): + mock_jira = Mock() + mock_jira.project.return_value = Mock(issueTypes=[Mock(**{'name': 'Task'})]) + mock_jira.createmeta.return_value = {'projects': [{'issuetypes': [{'name': 'Task'}]}]} + args = Mock( + target_jira_project='INT', ticket_summary='s', ticket_description=None, + issue_type='Task', parent_epic=None, edition='N/A', team=None, + ) + with patch('create_integration_ticket.resolve_fix_versions', return_value=[]): + create_integration_ticket(mock_jira, args) + messages = [call.args[0] for call in mock_eprint.call_args_list] + self.assertIn('Setting Edition: N/A', messages) + if __name__ == '__main__': unittest.main() From b130f0777a1a22b0c71de8a0a45a3896fcde409c Mon Sep 17 00:00:00 2001 From: Jonas Wielage Date: Tue, 6 Oct 2026 16:03:14 +0200 Subject: [PATCH 5/8] Update automated-release.yml --- .github/workflows/automated-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/automated-release.yml b/.github/workflows/automated-release.yml index 24fd0a9c..bedf7b6d 100644 --- a/.github/workflows/automated-release.yml +++ b/.github/workflows/automated-release.yml @@ -877,7 +877,7 @@ jobs: - name: Create SQS Ticket if: ${{ inputs.sqs-integration }} id: create-sqs-ticket - uses: SonarSource/release-github-actions/create-integration-ticket@jw/integration-ticket-fix-versions + uses: SonarSource/release-github-actions/create-integration-ticket@master with: plugin-name: ${{ inputs.plugin-name }} release-version: ${{ needs.prepare-release.outputs.release-version }} From 849920ce56bfa4eeecd6ebf18ce0c71300a671ee Mon Sep 17 00:00:00 2001 From: Jonas Wielage Date: Tue, 6 Oct 2026 16:34:35 +0200 Subject: [PATCH 6/8] Iterate --- .okf/actions/create-integration-ticket.md | 8 +++---- .okf/log.md | 13 +++--------- create-integration-ticket/README.md | 5 ++--- create-integration-ticket/action.yml | 26 +---------------------- 4 files changed, 10 insertions(+), 42 deletions(-) diff --git a/.okf/actions/create-integration-ticket.md b/.okf/actions/create-integration-ticket.md index 8a6c397d..3e0691f3 100644 --- a/.okf/actions/create-integration-ticket.md +++ b/.okf/actions/create-integration-ticket.md @@ -43,10 +43,10 @@ set, since which version wins is unit-tested. When `edition` is set, `fixVersions` gets the lowest open `major.minor` version per prefix: `sqcb-` (Community Build), `sqs-` (Server) or both; `N/A` sets nothing. Released, archived and -`sonar-enterprise`-tagged versions are skipped. The tag lookup uses a token from vault `SonarSource-` if the `secret-name` input is -set, else `{REPO_OWNER_NAME_DASH}-release-automation`, falling back to the `token` input (the same -vault-then-token fallback as [create-pull-request](/actions/create-pull-request.md)); no token only -warns. Any failure falls back to Jira-only or no fix version, never blocking. +`sonar-enterprise`-tagged versions are skipped. The tag lookup uses a token from vault +`SonarSource-` if the `secret-name` input is set, else +`{REPO_OWNER_NAME_DASH}-release-automation`. An unavailable token only warns. +Any failure falls back to Jira-only or no fix version, never blocking. # Citations diff --git a/.okf/log.md b/.okf/log.md index 1e1140b2..dbc7fc0b 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -1,16 +1,9 @@ # Update Log ## 2026-10-06 -* **Fix versions skip sonar-enterprise tags**: `create-integration-ticket` skips versions already - tagged in `sonar-enterprise`, via a vault or new `token` input (see - [actions/create-integration-ticket.md](/actions/create-integration-ticket.md)). -* **Configurable vault secret for integration tickets**: `create-integration-ticket` takes a - `secret-name` input, passed from `automated-release.yml` like the update-analyzer steps. - -## 2026-10-05 -* **Automatic Fix versions for integration tickets**: With `edition` set, `create-integration-ticket` - fills `fixVersions` with the lowest open `sqcb-*`/`sqs-*` version (see - [actions/create-integration-ticket.md](/actions/create-integration-ticket.md)). +* **Automatic integration ticket Fix versions**: Selects the lowest open `sqcb-*`/`sqs-*` + versions by edition, skipping `sonar-enterprise` tags; adds a `secret-name` input + and forwards the release automation secret from `automated-release.yml`. ## 2026-09-17 * **Repo-specific repox status preferred**: [get-release-version](/actions/get-release-version.md) diff --git a/create-integration-ticket/README.md b/create-integration-ticket/README.md index b82e8425..b000588f 100644 --- a/create-integration-ticket/README.md +++ b/create-integration-ticket/README.md @@ -35,7 +35,6 @@ This action requires: | `edition` | Jira "Edition" value. One of: `N/A`, `Community Build`, `Server`, `Community Build & Server` | No | - | | `team` | Atlassian team **UUID** for the Jira "Team" field | No | - | | `secret-name` | Release automation vault secret name (read as `SonarSource-`) with access to `sonar-enterprise`; defaults to `{REPO_OWNER_NAME_DASH}-release-automation` | No | - | -| `token` | GitHub token for reading `sonar-enterprise` tags; fallback if the vault token is unavailable | No | `${{ github.token }}` | **Note:** Either `ticket-summary` must be provided, or both `plugin-name` and `release-version` must be provided. If `ticket-summary` is not provided, it will be automatically generated as "Update {plugin-name} to {release-version}". @@ -63,8 +62,8 @@ Released, archived and `sonar-enterprise`-tagged versions (tag `sqs-2026.5.2.1` skipped. Failures never block ticket creation. The tag lookup uses the vault `SonarSource-` token if `secret-name` is set, else -`{REPO_OWNER_NAME_DASH}-release-automation`, falling back to `token`, as in -[`create-pull-request`](../create-pull-request). Without access to `sonar-enterprise`, only Jira is consulted. +`{REPO_OWNER_NAME_DASH}-release-automation`. If the vault token is unavailable or cannot access +`sonar-enterprise`, the action warns and only Jira is consulted. ## Outputs diff --git a/create-integration-ticket/action.yml b/create-integration-ticket/action.yml index 54e422d0..1ab68c27 100644 --- a/create-integration-ticket/action.yml +++ b/create-integration-ticket/action.yml @@ -37,10 +37,6 @@ inputs: secret-name: description: 'Release automation vault secret name with read access to sonar-enterprise (e.g. sonar-foo-release-automation). Defaults to {REPO_OWNER_NAME_DASH}-release-automation.' required: false - token: - description: 'GitHub token for reading sonar-enterprise tags; fallback if the vault token is unavailable.' - required: false - default: ${{ github.token }} outputs: ticket-key: @@ -70,26 +66,6 @@ runs: secrets: | development/github/token/${{ inputs.secret-name && format('SonarSource-{0}', inputs.secret-name) || '{REPO_OWNER_NAME_DASH}-release-automation' }} token | VAULT_GITHUB_TOKEN; - - name: Resolve GitHub token - id: resolve_github_token - if: ${{ inputs.edition != '' && inputs.edition != 'N/A' }} - shell: bash - env: - VAULT_OUTCOME: ${{ steps.github_secrets.outcome }} - VAULT_TOKEN: ${{ steps.github_secrets.outcome == 'success' && fromJSON(steps.github_secrets.outputs.vault).VAULT_GITHUB_TOKEN || '' }} - INPUT_TOKEN: ${{ inputs.token }} - run: | - set -euo pipefail - if [[ "$VAULT_OUTCOME" == "success" && -n "$VAULT_TOKEN" ]]; then - echo "Using vault token" - echo "token=${VAULT_TOKEN}" >> "$GITHUB_OUTPUT" - elif [[ -n "$INPUT_TOKEN" ]]; then - echo "Vault token not available, using input token" - echo "token=${INPUT_TOKEN}" >> "$GITHUB_OUTPUT" - else - echo "::warning::No GitHub token available, Fix versions will not be cross-checked against sonar-enterprise tags." - fi - - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -130,7 +106,7 @@ runs: env: JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} - GITHUB_TOKEN: ${{ steps.resolve_github_token.outputs.token }} + GITHUB_TOKEN: ${{ steps.github_secrets.outcome == 'success' && fromJSON(steps.github_secrets.outputs.vault).VAULT_GITHUB_TOKEN || '' }} USE_SANDBOX: ${{ inputs.use-jira-sandbox || env.USE_JIRA_SANDBOX }} TICKET_DESCRIPTION: | ${{ inputs.ticket-description }}${{ inputs.jira-release-url && inputs.ticket-description && ' From dff2a9c92c50944a1a2192347d778eca293a321b Mon Sep 17 00:00:00 2001 From: Jonas Wielage Date: Tue, 6 Oct 2026 17:05:34 +0200 Subject: [PATCH 7/8] Iterate --- .../test-create-integration-ticket.yml | 32 +- .github/workflows/test-jira-fixtures.yml | 2 +- .okf/actions/create-integration-ticket.md | 14 +- .okf/log.md | 4 + create-integration-ticket/README.md | 4 +- .../create_integration_ticket.py | 4 +- .../test_create_integration_ticket.py | 381 +----------------- .../test_fix_versions.py | 260 ++++++++++++ test-fixtures/jira/README.md | 14 +- test-fixtures/jira/assert_ticket_fields.py | 46 ++- .../jira/test_assert_ticket_fields.py | 92 +++++ 11 files changed, 424 insertions(+), 429 deletions(-) create mode 100644 create-integration-ticket/test_fix_versions.py create mode 100644 test-fixtures/jira/test_assert_ticket_fields.py diff --git a/.github/workflows/test-create-integration-ticket.yml b/.github/workflows/test-create-integration-ticket.yml index 285201da..b024de89 100644 --- a/.github/workflows/test-create-integration-ticket.yml +++ b/.github/workflows/test-create-integration-ticket.yml @@ -41,7 +41,7 @@ jobs: - name: Run unit tests run: | cd create-integration-ticket - python -m pytest test_create_integration_ticket.py -v --cov=create_integration_ticket --cov-report=term-missing + python -m pytest test_*.py -v --cov=create_integration_ticket --cov-report=term-missing integration-tests: name: Integration Tests (Jira Sandbox) @@ -94,7 +94,7 @@ jobs: --edition NONE \ --fix-versions NONE - # Sandbox only proves fixVersions is stored; which version wins is unit-tested. + # Requires an eligible open version per prefix in SONAR; which version wins is unit-tested. - name: Create ticket with edition and team id: with-edition uses: ./create-integration-ticket @@ -118,31 +118,7 @@ jobs: --ticket-key "$TICKET_KEY" \ --team "f1da89c9-3712-4d15-b194-a4b24406e3e4" \ --edition "Community Build & Server" \ - --fix-versions ANY - - - name: Create ticket with N/A edition - id: with-na-edition - uses: ./create-integration-ticket - with: - target-jira-project: SONAR - release-ticket-key: 'SONAR-22193' - ticket-summary: 'Integration ticket test ${{ github.run_id }} — N/A edition' - edition: 'N/A' - use-jira-sandbox: 'true' - - - name: Verify N/A edition sets no fix versions - env: - JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} - JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} - TICKET_KEY: ${{ steps.with-na-edition.outputs.ticket-key }} - run: | - set -euo pipefail - python test-fixtures/jira/assert_ticket_fields.py \ - --use-sandbox "true" \ - --ticket-key "$TICKET_KEY" \ - --team NONE \ - --edition "N/A" \ - --fix-versions NONE + --fix-version-prefixes sqcb-,sqs- - name: Create ticket without edition or team id: without-fields @@ -176,7 +152,7 @@ jobs: JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }} JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }} CREATED_KEYS: >- - ${{ steps.with-team.outputs.ticket-key }},${{ steps.with-edition.outputs.ticket-key }},${{ steps.with-na-edition.outputs.ticket-key }},${{ steps.without-fields.outputs.ticket-key }} + ${{ steps.with-team.outputs.ticket-key }},${{ steps.with-edition.outputs.ticket-key }},${{ steps.without-fields.outputs.ticket-key }} run: | python test-fixtures/jira/cleanup.py \ --use-sandbox "true" \ diff --git a/.github/workflows/test-jira-fixtures.yml b/.github/workflows/test-jira-fixtures.yml index 987d1299..1fd3bbd8 100644 --- a/.github/workflows/test-jira-fixtures.yml +++ b/.github/workflows/test-jira-fixtures.yml @@ -38,7 +38,7 @@ jobs: - 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 --cov-report=xml:coverage.xml + python -m pytest test_*.py -v --cov=jira_client --cov=setup --cov=cleanup --cov=assert_ticket_fields --cov-report=term-missing --cov-report=xml:coverage.xml - name: Upload coverage report uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.okf/actions/create-integration-ticket.md b/.okf/actions/create-integration-ticket.md index 3e0691f3..94cba79d 100644 --- a/.okf/actions/create-integration-ticket.md +++ b/.okf/actions/create-integration-ticket.md @@ -4,7 +4,7 @@ title: Create Integration Ticket description: Creates a Jira integration ticket with a custom summary and links it to an existing release ticket. resource: https://github.com/SonarSource/release-github-actions/tree/master/create-integration-ticket tags: [action, jira, integration-ticket] -timestamp: 2026-08-05T00:00:00Z +timestamp: 2026-10-06T00:00:00Z --- # Overview @@ -36,8 +36,9 @@ dropped. Availability differs per project (`SONAR`: both; `SC`: team only), whic Covered by a real Jira sandbox job that re-reads the created tickets and asserts the stored values — the only check that catches a wrong custom field ID or value shape. It runs against fixed sandbox state (`SONAR-22193`, a fixed team UUID) instead of a setup script, like -[get-jira-release-notes](/actions/get-jira-release-notes.md). `fixVersions` is only asserted as -set, since which version wins is unit-tested. +[get-jira-release-notes](/actions/get-jira-release-notes.md). `fixVersions` must contain exactly +one version per edition prefix and no unrelated versions; which version wins is unit-tested. +The sandbox needs an eligible open version for each prefix. The `N/A` case is tested locally. # Automatic Fix versions @@ -46,7 +47,12 @@ When `edition` is set, `fixVersions` gets the lowest open `major.minor` version `sonar-enterprise`-tagged versions are skipped. The tag lookup uses a token from vault `SonarSource-` if the `secret-name` input is set, else `{REPO_OWNER_NAME_DASH}-release-automation`. An unavailable token only warns. -Any failure falls back to Jira-only or no fix version, never blocking. +Jira API errors, connection errors, timeouts and invalid JSON omit automatic fix versions; +GitHub lookup failures fall back to Jira-only selection. Neither prevents ticket creation. + +Tests exercise edition mappings and version filtering through the resolver, and lookup +failures and environment token propagation through ticket creation. HTTP authentication, +pagination and tag parsing are covered separately; logging wording is not a test contract. # Citations diff --git a/.okf/log.md b/.okf/log.md index dbc7fc0b..7b1ed21b 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -4,6 +4,10 @@ * **Automatic integration ticket Fix versions**: Selects the lowest open `sqcb-*`/`sqs-*` versions by edition, skipping `sonar-enterprise` tags; adds a `secret-name` input and forwards the release automation secret from `automated-release.yml`. +* **Integration ticket test consolidation**: Replaces overlapping helper and logging tests + with behavior cases, covers lookup failures and token propagation through ticket creation, + and requires one stored fix version per edition prefix in the sandbox. Jira connection + errors, timeouts and invalid JSON no longer block ticket creation. ## 2026-09-17 * **Repo-specific repox status preferred**: [get-release-version](/actions/get-release-version.md) diff --git a/create-integration-ticket/README.md b/create-integration-ticket/README.md index b000588f..fbad70f4 100644 --- a/create-integration-ticket/README.md +++ b/create-integration-ticket/README.md @@ -59,7 +59,8 @@ When `edition` is set, `Fix versions` is filled with the lowest open version per | `Community Build & Server` | next unreleased `sqcb-*` and `sqs-*`| Released, archived and `sonar-enterprise`-tagged versions (tag `sqs-2026.5.2.1` ⇒ `sqs-2026.5`) are -skipped. Failures never block ticket creation. +skipped. If Jira version lookup fails (including connection errors, timeouts or invalid JSON), +the action warns and creates the ticket without automatic Fix versions. The tag lookup uses the vault `SonarSource-` token if `secret-name` is set, else `{REPO_OWNER_NAME_DASH}-release-automation`. If the vault token is unavailable or cannot access @@ -155,3 +156,4 @@ The action will fail if: The action will continue but warn if: - The description field cannot be set (due to project configuration or permissions) - Ticket linking fails (the ticket is still created successfully) +- Automatic Fix versions lookup fails (Jira failure omits Fix versions; GitHub failure uses Jira-only selection) diff --git a/create-integration-ticket/create_integration_ticket.py b/create-integration-ticket/create_integration_ticket.py index f308aaab..fd78f41e 100644 --- a/create-integration-ticket/create_integration_ticket.py +++ b/create-integration-ticket/create_integration_ticket.py @@ -92,8 +92,8 @@ def fetch_open_versions(jira_client, project_key): """Unreleased, non-archived project versions, or None on failure.""" try: versions = jira_client.project_versions(project_key) - except JIRAError as e: - eprint(f"Warning: Failed to fetch versions for project '{project_key}'. Status: {e.status_code}") + except (JIRAError, requests.RequestException, ValueError) as e: + eprint(f"Warning: Failed to fetch versions for project '{project_key}': {e}") eprint("Warning: Skipping automatic 'Fix versions' assignment.") return None return [ diff --git a/create-integration-ticket/test_create_integration_ticket.py b/create-integration-ticket/test_create_integration_ticket.py index dcaf138c..c5812b15 100644 --- a/create-integration-ticket/test_create_integration_ticket.py +++ b/create-integration-ticket/test_create_integration_ticket.py @@ -6,7 +6,6 @@ """ import unittest -import requests from unittest.mock import Mock, patch import sys import os @@ -17,22 +16,12 @@ from create_integration_ticket import ( validate_release_ticket, create_integration_ticket, - link_tickets, main, version_sort_key, sort_by_version,find_lowest_version, resolve_fix_versions, - fetch_shipped_versions, list_tag_refs, parse_shipped_versions, fetch_open_versions, - exclude_shipped_versions + link_tickets, main ) from jira_common import CUSTOM_FIELDS from jira.exceptions import JIRAError -def make_version(name, released=False, archived=False): - version = Mock() - version.name = name - version.released = released - version.archived = archived - return version - - @patch.dict(os.environ, {'GITHUB_TOKEN': ''}) class TestCreateIntegrationTicket(unittest.TestCase): @@ -687,373 +676,5 @@ def test_create_integration_ticket_edition_and_team_combinations(self): else: self.assertNotIn(CUSTOM_FIELDS['TEAM'], call_args) - def test_version_sort_key_ordering(self): - """'major.minor' versions sort numerically, not lexicographically (so '26.10' > '26.9').""" - names = ['26.11', '26.9', '26.10'] - self.assertEqual(sorted(names, key=version_sort_key), ['26.9', '26.10', '26.11']) - - def test_sort_by_version_orders_numerically_ignoring_prefix(self): - self.assertEqual( - sort_by_version(['sqcb-26.10', 'sqcb-26.9'], 'sqcb-'), ['sqcb-26.9', 'sqcb-26.10'] - ) - - def test_find_lowest_version_picks_lowest(self): - versions = [ - make_version('sqcb-26.11'), - make_version('sqcb-26.9'), - make_version('sqcb-26.10'), - ] - self.assertEqual(find_lowest_version(versions, 'sqcb-'), 'sqcb-26.9') - - def test_find_lowest_version_filters_by_prefix(self): - versions = [make_version('sqs-2026.5'), make_version('2026.4')] - self.assertIsNone(find_lowest_version(versions, 'sqcb-')) - - def test_find_lowest_version_no_candidates(self): - self.assertIsNone(find_lowest_version([], 'sqcb-')) - - def test_find_lowest_version_skips_bugfix_versions(self): - """Bugfix versions ('major.minor.patch') are ignored.""" - versions = [make_version('sqs-2025.4.9'), make_version('sqs-2025.5')] - self.assertEqual(find_lowest_version(versions, 'sqs-'), 'sqs-2025.5') - - def test_find_lowest_version_skips_non_matching_format(self): - versions = [make_version('sqcb-26.10-RC1'), make_version('sqcb-26.11')] - self.assertEqual(find_lowest_version(versions, 'sqcb-'), 'sqcb-26.11') - - def test_resolve_fix_versions_community_build(self): - mock_jira = Mock() - mock_jira.project_versions.return_value = [ - make_version('sqcb-26.10'), make_version('sqcb-26.9'), make_version('sqs-2026.5'), - ] - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), ['sqcb-26.9']) - - def test_resolve_fix_versions_server(self): - mock_jira = Mock() - mock_jira.project_versions.return_value = [ - make_version('sqcb-26.9'), make_version('sqs-2026.6'), make_version('sqs-2026.5'), - ] - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Server', None), ['sqs-2026.5']) - - def test_resolve_fix_versions_community_build_and_server_orders_sqcb_first(self): - mock_jira = Mock() - mock_jira.project_versions.return_value = [ - make_version('sqs-2026.5'), make_version('sqcb-26.9'), - ] - self.assertEqual( - resolve_fix_versions(mock_jira, 'SONAR', 'Community Build & Server', None), - ['sqcb-26.9', 'sqs-2026.5'] - ) - mock_jira.project_versions.assert_called_once_with('SONAR') - - def test_resolve_fix_versions_editions_without_prefix_skip_all_lookups(self): - for edition in ('N/A', 'Something Else'): - with self.subTest(edition=edition): - mock_jira = Mock() - with patch('create_integration_ticket.fetch_shipped_versions') as mock_fetch: - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', edition, 'token'), []) - mock_jira.project_versions.assert_not_called() - mock_fetch.assert_not_called() - - def test_resolve_fix_versions_no_matching_version(self): - mock_jira = Mock() - mock_jira.project_versions.return_value = [make_version('sqs-2026.5')] - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), []) - - @patch('create_integration_ticket.eprint') - def test_resolve_fix_versions_lookup_error_is_non_fatal(self, mock_eprint): - mock_jira = Mock() - mock_jira.project_versions.side_effect = JIRAError(status_code=500, text="Server Error") - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), []) - warnings = ' '.join(call.args[0] for call in mock_eprint.call_args_list) - self.assertIn('Failed to fetch versions', warnings) - self.assertIn("Skipping automatic 'Fix versions' assignment", warnings) - - def test_find_lowest_version_accepts_single_digit_major(self): - """A single-digit major (e.g. '9.1') must not be rejected by the name pattern.""" - versions = [make_version('sqcb-9.1'), make_version('sqcb-26.9')] - self.assertEqual(find_lowest_version(versions, 'sqcb-'), 'sqcb-9.1') - - def test_find_lowest_version_accepts_three_digit_minor(self): - """A three-digit minor like '2026.100' is accepted.""" - versions = [make_version('sqs-2026.100'), make_version('sqs-2026.99')] - self.assertEqual(find_lowest_version(versions, 'sqs-'), 'sqs-2026.99') - - def test_find_lowest_version_rejects_prefix_collision(self): - """Names with extra text after the prefix (e.g. 'sqcb-lts-26.9') are ignored.""" - versions = [make_version('sqcb-lts-26.9'), make_version('sqs-next'), make_version('sqcb-26.10')] - self.assertEqual(find_lowest_version(versions, 'sqcb-'), 'sqcb-26.10') - self.assertIsNone(find_lowest_version(versions, 'sqs-')) - - def test_create_integration_ticket_sets_edition_team_and_fix_versions_together(self): - """Edition, team and fix versions coexist in a single create_issue payload.""" - mock_jira = Mock() - mock_jira.createmeta.return_value = { - 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] - } - mock_jira.project_versions.return_value = [ - make_version('sqcb-26.9'), make_version('sqs-2026.5'), - ] - mock_ticket = Mock() - mock_ticket.key = 'SQS-54' - mock_jira.create_issue.return_value = mock_ticket - - args = Mock() - args.target_jira_project = 'SONAR' - args.ticket_summary = 'Update sonar-security to 1.0.0' - args.ticket_description = None - args.parent_epic = None - args.edition = 'Community Build & Server' - args.team = 'f1da89c9-3712-4d15-b194-a4b24406e3e4' - - create_integration_ticket(mock_jira, args) - - call_args = mock_jira.create_issue.call_args[1]['fields'] - self.assertEqual(call_args[CUSTOM_FIELDS['EDITION']], {'value': 'Community Build & Server'}) - self.assertEqual(call_args[CUSTOM_FIELDS['TEAM']], 'f1da89c9-3712-4d15-b194-a4b24406e3e4') - self.assertEqual(call_args['fixVersions'], [{'name': 'sqcb-26.9'}, {'name': 'sqs-2026.5'}]) - - def test_create_integration_ticket_na_or_no_edition_sets_no_fix_versions(self): - for edition in ('N/A', None): - with self.subTest(edition=edition): - mock_jira = Mock() - mock_jira.createmeta.return_value = { - 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] - } - mock_ticket = Mock() - mock_ticket.key = 'SQS-51' - mock_jira.create_issue.return_value = mock_ticket - - args = Mock() - args.target_jira_project = 'SONAR' - args.ticket_summary = 'Update sonar-security to 1.0.0' - args.ticket_description = None - args.parent_epic = None - args.edition = edition - args.team = None - - create_integration_ticket(mock_jira, args) - - call_args = mock_jira.create_issue.call_args[1]['fields'] - self.assertNotIn('fixVersions', call_args) - mock_jira.project_versions.assert_not_called() - - def test_create_integration_ticket_no_matching_version_sets_no_fix_versions(self): - mock_jira = Mock() - mock_jira.createmeta.return_value = { - 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] - } - mock_jira.project_versions.return_value = [] - mock_ticket = Mock() - mock_ticket.key = 'SQS-53' - mock_jira.create_issue.return_value = mock_ticket - - args = Mock() - args.target_jira_project = 'SONAR' - args.ticket_summary = 'Update sonar-security to 1.0.0' - args.ticket_description = None - args.parent_epic = None - args.edition = 'Community Build' - args.team = None - - create_integration_ticket(mock_jira, args) - - call_args = mock_jira.create_issue.call_args[1]['fields'] - self.assertNotIn('fixVersions', call_args) - - def test_resolve_fix_versions_skips_versions_already_tagged(self): - """An open Jira version with a sonar-enterprise tag is already shipped, so the next one wins.""" - mock_jira = Mock() - mock_jira.project_versions.return_value = [make_version('sqcb-26.9'), make_version('sqcb-26.10')] - with patch('create_integration_ticket.fetch_shipped_versions', return_value={'sqcb-26.9'}): - self.assertEqual( - resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', 'token'), ['sqcb-26.10'] - ) - - def test_resolve_fix_versions_filters_each_prefix_by_its_own_tags(self): - mock_jira = Mock() - mock_jira.project_versions.return_value = [ - make_version('sqcb-26.9'), make_version('sqcb-26.10'), - make_version('sqs-2026.5'), make_version('sqs-2026.6'), - ] - shipped = {'sqcb-': {'sqcb-26.9'}, 'sqs-': set()} - with patch('create_integration_ticket.fetch_shipped_versions', - side_effect=lambda token, prefix: shipped[prefix]): - self.assertEqual( - resolve_fix_versions(mock_jira, 'SONAR', 'Community Build & Server', 'token'), - ['sqcb-26.10', 'sqs-2026.5'] - ) - - def test_resolve_fix_versions_all_candidates_tagged_yields_none(self): - mock_jira = Mock() - mock_jira.project_versions.return_value = [make_version('sqs-2026.5')] - with patch('create_integration_ticket.fetch_shipped_versions', return_value={'sqs-2026.5'}): - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Server', 'token'), []) - - @patch('create_integration_ticket.eprint') - def test_resolve_fix_versions_without_token_warns_and_skips_tag_lookup(self, mock_eprint): - mock_jira = Mock() - mock_jira.project_versions.return_value = [make_version('sqcb-26.9')] - with patch('create_integration_ticket.fetch_shipped_versions') as mock_fetch: - self.assertEqual(resolve_fix_versions(mock_jira, 'SONAR', 'Community Build', None), ['sqcb-26.9']) - mock_fetch.assert_not_called() - warnings = ' '.join(call.args[0] for call in mock_eprint.call_args_list) - self.assertIn('No GITHUB_TOKEN', warnings) - - @patch('create_integration_ticket.eprint') - def test_resolve_fix_versions_without_token_warns_once_for_all_prefixes(self, mock_eprint): - mock_jira = Mock() - mock_jira.project_versions.return_value = [make_version('sqcb-26.9'), make_version('sqs-2026.5')] - resolve_fix_versions(mock_jira, 'SONAR', 'Community Build & Server', None) - warnings = [c for c in mock_eprint.call_args_list if 'No GITHUB_TOKEN' in c.args[0]] - self.assertEqual(len(warnings), 1) - - def test_parse_shipped_versions_maps_tags_to_jira_versions(self): - """Bugfix and build-number tag segments collapse onto the 'major.minor' Jira version.""" - refs = [ - 'refs/tags/sqs-2026.5.0.132233', - 'refs/tags/sqs-2026.5.2.132813', - 'refs/tags/sqs-2026.4.1.126914', - 'refs/tags/sqs-2026.6', - ] - self.assertEqual(parse_shipped_versions(refs, 'sqs-'), {'sqs-2026.5', 'sqs-2026.4'}) - - def test_parse_shipped_versions_ignores_other_prefixes_and_malformed_refs(self): - refs = ['refs/tags/sqsx-2026.5.0.1', 'refs/tags/sqs-foo', 'refs/tags/sqcb-26.9.0.1'] - self.assertEqual(parse_shipped_versions(refs, 'sqs-'), set()) - - def test_parse_shipped_versions_empty(self): - self.assertEqual(parse_shipped_versions([], 'sqs-'), set()) - - @patch('create_integration_ticket.requests.get') - def test_list_tag_refs_sends_token_and_returns_ref_names(self, mock_get): - response = Mock(links={}) - response.json.return_value = [{'ref': 'refs/tags/sqs-2026.5.0.1'}] - mock_get.return_value = response - self.assertEqual(list_tag_refs('token', 'sqs-'), ['refs/tags/sqs-2026.5.0.1']) - self.assertEqual(mock_get.call_args.kwargs['headers']['Authorization'], 'Bearer token') - self.assertTrue(mock_get.call_args.args[0].endswith('/tags/sqs-')) - - @patch('create_integration_ticket.requests.get') - def test_list_tag_refs_follows_pagination(self, mock_get): - page1 = Mock(links={'next': {'url': 'https://api.github.com/next'}}) - page1.json.return_value = [{'ref': 'refs/tags/sqs-2025.1.0.1'}] - page2 = Mock(links={}) - page2.json.return_value = [{'ref': 'refs/tags/sqs-2026.5.0.2'}] - mock_get.side_effect = [page1, page2] - self.assertEqual( - list_tag_refs('token', 'sqs-'), ['refs/tags/sqs-2025.1.0.1', 'refs/tags/sqs-2026.5.0.2'] - ) - self.assertEqual(mock_get.call_args.args[0], 'https://api.github.com/next') - - @patch('create_integration_ticket.requests.get') - def test_list_tag_refs_raises_on_http_error(self, mock_get): - mock_get.return_value.raise_for_status.side_effect = requests.HTTPError('404 Not Found') - with self.assertRaises(requests.HTTPError): - list_tag_refs('token', 'sqs-') - - def test_fetch_shipped_versions_combines_listing_and_parsing(self): - with patch('create_integration_ticket.list_tag_refs', return_value=['refs/tags/sqs-2026.5.0.1']): - self.assertEqual(fetch_shipped_versions('token', 'sqs-'), {'sqs-2026.5'}) - - @patch('create_integration_ticket.eprint') - def test_fetch_shipped_versions_lookup_failure_returns_none(self, mock_eprint): - for error in (requests.HTTPError('404 Not Found'), ValueError('bad JSON')): - with self.subTest(error=error): - with patch('create_integration_ticket.list_tag_refs', side_effect=error): - self.assertIsNone(fetch_shipped_versions('token', 'sqs-')) - self.assertIn('Failed to list sonar-enterprise', mock_eprint.call_args.args[0]) - - @patch('create_integration_ticket.eprint') - @patch('create_integration_ticket.requests.get') - def test_fetch_shipped_versions_malformed_payload_returns_none(self, mock_get, mock_eprint): - for label, payload in (('item without ref', [{'name': 'x'}]), ('error object', {'message': 'Not Found'})): - with self.subTest(label): - response = Mock(links={}) - response.json.return_value = payload - mock_get.return_value = response - self.assertIsNone(fetch_shipped_versions('token', 'sqs-')) - self.assertIn('Failed to list sonar-enterprise', mock_eprint.call_args.args[0]) - - def test_fetch_open_versions_filters_released_and_archived(self): - mock_jira = Mock() - open_version = make_version('sqs-2026.6') - mock_jira.project_versions.return_value = [ - make_version('sqs-2026.4', released=True), make_version('sqs-2026.5', archived=True), open_version, - ] - self.assertEqual(fetch_open_versions(mock_jira, 'SONAR'), [open_version]) - - @patch('create_integration_ticket.eprint') - def test_fetch_open_versions_returns_none_on_jira_error(self, mock_eprint): - mock_jira = Mock() - mock_jira.project_versions.side_effect = JIRAError(status_code=500) - self.assertIsNone(fetch_open_versions(mock_jira, 'SONAR')) - - def test_exclude_shipped_versions_removes_tagged(self): - tagged, untagged = make_version('sqs-2026.5'), make_version('sqs-2026.6') - with patch('create_integration_ticket.fetch_shipped_versions', return_value={'sqs-2026.5'}): - self.assertEqual(exclude_shipped_versions([tagged, untagged], 'token', 'sqs-'), [untagged]) - - def test_exclude_shipped_versions_unchanged_when_lookup_fails_or_empty(self): - versions = [make_version('sqs-2026.5')] - for shipped in (None, set()): - with patch('create_integration_ticket.fetch_shipped_versions', return_value=shipped): - self.assertEqual(exclude_shipped_versions(versions, 'token', 'sqs-'), versions) - - @patch('create_integration_ticket.eprint') - def test_exclude_shipped_versions_logs_only_skipped_open_versions_sorted(self, mock_eprint): - versions = [make_version('sqcb-26.10'), make_version('sqcb-26.9'), make_version('sqcb-26.11')] - shipped = {'sqcb-24.12', 'sqcb-26.9', 'sqcb-26.10'} - with patch('create_integration_ticket.fetch_shipped_versions', return_value=shipped): - exclude_shipped_versions(versions, 'token', 'sqcb-') - mock_eprint.assert_called_once_with( - "Skipping unreleased 'sqcb-*' versions already tagged in sonar-enterprise: ['sqcb-26.9', 'sqcb-26.10']" - ) - - @patch('create_integration_ticket.eprint') - def test_exclude_shipped_versions_logs_nothing_when_no_open_version_is_tagged(self, mock_eprint): - with patch('create_integration_ticket.fetch_shipped_versions', return_value={'sqs-2025.1'}): - exclude_shipped_versions([make_version('sqs-2026.6')], 'token', 'sqs-') - mock_eprint.assert_not_called() - - @patch('create_integration_ticket.eprint') - def test_resolve_fix_versions_logs_header_and_summary(self, mock_eprint): - mock_jira = Mock() - mock_jira.project_versions.return_value = [make_version('sqcb-26.11'), make_version('sqs-2026.6')] - with patch('create_integration_ticket.fetch_shipped_versions', return_value=set()): - resolve_fix_versions(mock_jira, 'SONAR', 'Community Build & Server', 'token') - messages = [call.args[0] for call in mock_eprint.call_args_list] - self.assertEqual( - messages[0], "\nResolving fix versions for edition 'Community Build & Server' in project 'SONAR'..." - ) - self.assertEqual(messages[-1], 'Adding fix versions: sqcb-26.11, sqs-2026.6') - - @patch('create_integration_ticket.eprint') - def test_resolve_fix_versions_logs_when_nothing_to_add(self, mock_eprint): - mock_jira = Mock() - mock_jira.project_versions.return_value = [] - resolve_fix_versions(mock_jira, 'SONAR', 'Server', None) - self.assertEqual(mock_eprint.call_args.args[0], 'No fix versions to add.') - - @patch('create_integration_ticket.eprint') - def test_resolve_fix_versions_logs_editions_without_prefix(self, mock_eprint): - self.assertEqual(resolve_fix_versions(Mock(), 'SONAR', 'N/A', 'token'), []) - mock_eprint.assert_called_once_with("No fix versions for edition 'N/A'.") - - @patch('create_integration_ticket.eprint') - def test_create_integration_ticket_logs_edition(self, mock_eprint): - mock_jira = Mock() - mock_jira.project.return_value = Mock(issueTypes=[Mock(**{'name': 'Task'})]) - mock_jira.createmeta.return_value = {'projects': [{'issuetypes': [{'name': 'Task'}]}]} - args = Mock( - target_jira_project='INT', ticket_summary='s', ticket_description=None, - issue_type='Task', parent_epic=None, edition='N/A', team=None, - ) - with patch('create_integration_ticket.resolve_fix_versions', return_value=[]): - create_integration_ticket(mock_jira, args) - messages = [call.args[0] for call in mock_eprint.call_args_list] - self.assertIn('Setting Edition: N/A', messages) - - if __name__ == '__main__': unittest.main() diff --git a/create-integration-ticket/test_fix_versions.py b/create-integration-ticket/test_fix_versions.py new file mode 100644 index 00000000..70a3618e --- /dev/null +++ b/create-integration-ticket/test_fix_versions.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Behavior and HTTP contract tests for automatic integration ticket fix versions.""" + +import os +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, call, patch + +import requests + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from create_integration_ticket import ( + SONAR_ENTERPRISE_TAGS_URL, + create_integration_ticket, + list_tag_refs, + parse_shipped_versions, + resolve_fix_versions, +) +from jira_common import CUSTOM_FIELDS +from jira.exceptions import JIRAError + + +def make_version(name, released=False, archived=False): + return SimpleNamespace(name=name, released=released, archived=archived) + + +@patch.dict(os.environ, {'GITHUB_TOKEN': ''}) +class TestFixVersions(unittest.TestCase): + def setUp(self): + self.jira = Mock() + self.jira.createmeta.return_value = { + 'projects': [{'issuetypes': [{'name': 'Maintenance'}]}] + } + self.jira.create_issue.return_value.key = 'SONAR-123' + self.args = SimpleNamespace( + target_jira_project='SONAR', ticket_summary='Update sonar-security to 1.0.0', + ticket_description=None, parent_epic=None, edition='Community Build & Server', + team='f1da89c9-3712-4d15-b194-a4b24406e3e4', + ) + + def test_resolve_fix_versions_selects_open_versions_by_edition(self): + self.jira.project_versions.return_value = [ + make_version('sqcb-26.11'), make_version('sqcb-26.10'), make_version('sqcb-26.9'), + make_version('sqs-2026.6'), make_version('sqs-2026.5'), + make_version('sqcb-26.8', released=True), make_version('sqcb-26.7', archived=True), + make_version('sqs-2026.3', released=True), make_version('sqs-2026.4', archived=True), + ] + cases = [ + ('Community Build', ['sqcb-26.9']), + ('Server', ['sqs-2026.5']), + ('Community Build & Server', ['sqcb-26.9', 'sqs-2026.5']), + ('N/A', []), + ('Something Else', []), + ] + for edition, expected in cases: + with self.subTest(edition=edition), patch('create_integration_ticket.requests.get') as get: + self.jira.reset_mock() + self.assertEqual(resolve_fix_versions(self.jira, 'SONAR', edition, None), expected) + if expected: + self.jira.project_versions.assert_called_once_with('SONAR') + else: + self.jira.project_versions.assert_not_called() + get.assert_not_called() + + def test_resolve_fix_versions_accepts_only_matching_major_minor_names(self): + cases = [ + ('Community Build', ['sqcb-9.1', 'sqcb-26.9'], ['sqcb-9.1']), + ('Server', ['sqs-2026.100', 'sqs-2026.99'], ['sqs-2026.99']), + ('Server', ['sqs-2025.4.9', 'sqs-2025.5'], ['sqs-2025.5']), + ('Community Build', ['sqcb-26.10-RC1', 'sqcb-26.11-M1', 'sqcb-26.12'], ['sqcb-26.12']), + ('Community Build', ['sqcb-lts-26.9', 'sqs-next', 'sqcb-26.10'], ['sqcb-26.10']), + ('Community Build', ['sqs-2026.5', '2026.4'], []), + ('Server', ['sqs-next'], []), + ('Server', [], []), + ] + for edition, names, expected in cases: + with self.subTest(edition=edition, names=names): + self.jira.project_versions.return_value = [make_version(name) for name in names] + self.assertEqual(resolve_fix_versions(self.jira, 'SONAR', edition, None), expected) + + def test_resolve_fix_versions_filters_each_prefix_by_its_own_tags(self): + self.jira.project_versions.return_value = [ + make_version('sqcb-26.10'), make_version('sqcb-26.9'), + make_version('sqs-2026.6'), make_version('sqs-2026.5'), + ] + cases = [ + ({'sqcb-': {'sqcb-26.9'}, 'sqs-': {'sqs-2026.5'}}, ['sqcb-26.10', 'sqs-2026.6']), + ({'sqcb-': {'sqcb-26.9'}, 'sqs-': set()}, ['sqcb-26.10', 'sqs-2026.5']), + ({'sqcb-': set(), 'sqs-': set()}, ['sqcb-26.9', 'sqs-2026.5']), + ({'sqcb-': {'sqcb-24.12'}, 'sqs-': {'sqs-2025.1'}}, ['sqcb-26.9', 'sqs-2026.5']), + ({'sqcb-': {'sqcb-26.9', 'sqcb-26.10'}, 'sqs-': {'sqs-2026.5', 'sqs-2026.6'}}, []), + ({'sqcb-': {'sqcb-26.9', 'sqcb-26.10'}, 'sqs-': set()}, ['sqs-2026.5']), + ({'sqcb-': None, 'sqs-': {'sqs-2026.5'}}, ['sqcb-26.9', 'sqs-2026.6']), + ] + for shipped, expected in cases: + with self.subTest(shipped=shipped), patch( + 'create_integration_ticket.fetch_shipped_versions', + side_effect=lambda token, prefix: shipped[prefix], + ): + self.assertEqual( + resolve_fix_versions(self.jira, 'SONAR', self.args.edition, 'token'), expected + ) + + def test_create_ticket_without_token_sets_edition_team_and_jira_fix_versions(self): + self.jira.project_versions.return_value = [make_version('sqcb-26.9'), make_version('sqs-2026.5')] + with patch('create_integration_ticket.requests.get') as get, patch('create_integration_ticket.eprint') as log: + ticket = create_integration_ticket(self.jira, self.args) + self.assertEqual(ticket, self.jira.create_issue.return_value) + self.jira.create_issue.assert_called_once_with(fields={ + 'project': 'SONAR', 'issuetype': {'name': 'Maintenance'}, 'summary': self.args.ticket_summary, + CUSTOM_FIELDS['EDITION']: {'value': self.args.edition}, CUSTOM_FIELDS['TEAM']: self.args.team, + 'fixVersions': [{'name': 'sqcb-26.9'}, {'name': 'sqs-2026.5'}], + }) + get.assert_not_called() + self.assertTrue(any('No GITHUB_TOKEN' in message.args[0] for message in log.call_args_list)) + + def test_create_ticket_uses_environment_token_to_skip_tagged_versions(self): + self.jira.project_versions.return_value = [ + make_version('sqcb-26.9'), make_version('sqcb-26.10'), + make_version('sqs-2026.5'), make_version('sqs-2026.6'), + ] + sqcb = Mock(links={}) + sqcb.json.return_value = [{'ref': 'refs/tags/sqcb-26.9.0.123'}] + sqs = Mock(links={}) + sqs.json.return_value = [{'ref': 'refs/tags/sqs-2026.5.2.456'}] + with patch.dict(os.environ, {'GITHUB_TOKEN': 'test-token'}), patch( + 'create_integration_ticket.requests.get', side_effect=[sqcb, sqs] + ) as get: + ticket = create_integration_ticket(self.jira, self.args) + self.assertEqual(ticket, self.jira.create_issue.return_value) + self.jira.create_issue.assert_called_once_with(fields={ + 'project': 'SONAR', 'issuetype': {'name': 'Maintenance'}, 'summary': self.args.ticket_summary, + CUSTOM_FIELDS['EDITION']: {'value': self.args.edition}, CUSTOM_FIELDS['TEAM']: self.args.team, + 'fixVersions': [{'name': 'sqcb-26.10'}, {'name': 'sqs-2026.6'}], + }) + self.assertEqual(get.call_count, 2) + for prefix, request in zip(('sqcb-', 'sqs-'), get.call_args_list): + self.assertEqual(request.args, (SONAR_ENTERPRISE_TAGS_URL + prefix,)) + self.assertEqual(request.kwargs['headers']['Authorization'], 'Bearer test-token') + sqcb.raise_for_status.assert_called_once() + sqs.raise_for_status.assert_called_once() + + def test_create_ticket_omits_fix_versions_when_edition_or_candidates_are_absent(self): + cases = [ + ('N/A', ['sqcb-26.9', 'sqs-2026.5']), + (None, ['sqcb-26.9']), + ('', ['sqs-2026.5']), + ('Community Build', []), + ('Community Build', ['sqs-2026.5']), + ] + for edition, names in cases: + with self.subTest(edition=edition, names=names), patch('create_integration_ticket.requests.get') as get: + self.jira.reset_mock() + self.jira.project_versions.return_value = [make_version(name) for name in names] + self.args.edition = edition + self.assertEqual(create_integration_ticket(self.jira, self.args), self.jira.create_issue.return_value) + self.jira.create_issue.assert_called_once() + fields = self.jira.create_issue.call_args.kwargs['fields'] + self.assertNotIn('fixVersions', fields) + if edition: + self.assertEqual(fields[CUSTOM_FIELDS['EDITION']], {'value': edition}) + else: + self.assertNotIn(CUSTOM_FIELDS['EDITION'], fields) + if edition in ('N/A', None, ''): + self.jira.project_versions.assert_not_called() + get.assert_not_called() + + def test_create_ticket_survives_jira_version_lookup_failures(self): + errors = [ + JIRAError(status_code=500, text='Server Error'), + requests.ConnectionError('Connection failed'), requests.Timeout('Read timed out'), + ValueError('Invalid Jira JSON'), + ] + for error in errors: + with self.subTest(error=error), patch.dict(os.environ, {'GITHUB_TOKEN': 'test-token'}), patch( + 'create_integration_ticket.requests.get' + ) as get, patch('create_integration_ticket.eprint') as log: + self.jira.reset_mock() + self.jira.project_versions.side_effect = error + self.assertEqual(create_integration_ticket(self.jira, self.args), self.jira.create_issue.return_value) + self.jira.create_issue.assert_called_once() + fields = self.jira.create_issue.call_args.kwargs['fields'] + self.assertNotIn('fixVersions', fields) + self.assertEqual(fields[CUSTOM_FIELDS['EDITION']], {'value': self.args.edition}) + self.assertEqual(fields[CUSTOM_FIELDS['TEAM']], self.args.team) + get.assert_not_called() + self.assertTrue(any('Failed to fetch versions' in message.args[0] for message in log.call_args_list)) + + def test_create_ticket_falls_back_to_jira_versions_on_github_lookup_failure(self): + self.args.edition = 'Server' + self.jira.project_versions.return_value = [make_version('sqs-2026.6'), make_version('sqs-2026.5')] + first_page = Mock(links={'next': {'url': 'https://api.github.com/next'}}) + first_page.json.return_value = [{'ref': 'refs/tags/sqs-2026.5.0.123'}] + last_page = Mock(links={}) + last_page.raise_for_status.side_effect = requests.HTTPError('503 Unavailable') + cases = [ + ('HTTP error', {'raise_for_status.side_effect': requests.HTTPError('404 Not Found')}, None), + ('connection error', {}, requests.ConnectionError('Connection failed')), + ('timeout', {}, requests.Timeout('Read timed out')), + ('invalid JSON', {'json.side_effect': ValueError('Invalid JSON')}, None), + ('missing ref', {'json.return_value': [{'name': 'x'}]}, None), + ('error object', {'json.return_value': {'message': 'Not Found'}}, None), + ('invalid ref type', {'json.return_value': [{'ref': None}]}, None), + ('later page failure', {}, [first_page, last_page]), + ] + for label, response_config, request_error in cases: + with self.subTest(label=label), patch.dict(os.environ, {'GITHUB_TOKEN': 'test-token'}), patch( + 'create_integration_ticket.requests.get', + return_value=Mock(links={}, **response_config), side_effect=request_error, + ), patch('create_integration_ticket.eprint') as log: + self.jira.reset_mock() + self.assertEqual(create_integration_ticket(self.jira, self.args), self.jira.create_issue.return_value) + self.jira.create_issue.assert_called_once() + self.assertEqual( + self.jira.create_issue.call_args.kwargs['fields']['fixVersions'], [{'name': 'sqs-2026.5'}] + ) + self.assertTrue( + any('Failed to list sonar-enterprise' in message.args[0] for message in log.call_args_list) + ) + + def test_list_tag_refs_follows_pagination_with_authentication_and_timeout(self): + next_url = SONAR_ENTERPRISE_TAGS_URL + 'sqs-?page=2' + first_page = Mock(links={'next': {'url': next_url}}) + first_page.json.return_value = [{'ref': 'refs/tags/sqs-2025.1.0.123'}] + last_page = Mock(links={}) + last_page.json.return_value = [{'ref': 'refs/tags/sqs-2026.5.0.456'}] + with patch('create_integration_ticket.requests.get', side_effect=[first_page, last_page]) as get: + self.assertEqual(list_tag_refs('test-token', 'sqs-'), [ + 'refs/tags/sqs-2025.1.0.123', 'refs/tags/sqs-2026.5.0.456', + ]) + headers = {'Authorization': 'Bearer test-token', 'Accept': 'application/vnd.github+json'} + self.assertEqual(get.call_args_list, [ + call(SONAR_ENTERPRISE_TAGS_URL + 'sqs-', headers=headers, params={'per_page': 100}, timeout=30), + call(next_url, headers=headers, params=None, timeout=30), + ]) + first_page.raise_for_status.assert_called_once() + last_page.raise_for_status.assert_called_once() + + def test_parse_shipped_versions_maps_only_matching_tags_to_major_minor_names(self): + refs = [ + 'refs/tags/sqs-2026.5.0.132233', 'refs/tags/sqs-2026.5.2.132813', + 'refs/tags/sqs-2026.4.1.126914', 'refs/tags/sqs-2026.6', + 'refs/tags/sqcb-26.9.0.123', 'refs/tags/sqcb-26.10.1.456', + 'refs/tags/sqsx-2026.5.0.1', 'refs/tags/sqs-foo', + ] + cases = [ + ('sqs-', refs, {'sqs-2026.5', 'sqs-2026.4'}), + ('sqcb-', refs, {'sqcb-26.9', 'sqcb-26.10'}), + ('sqs-', [], set()), + ('sqs-', ['refs/tags/sqsx-2026.5.0.1', 'refs/tags/sqs-foo', 'refs/tags/sqcb-26.9.0.1'], set()), + ] + for prefix, tags, expected in cases: + with self.subTest(prefix=prefix, tags=tags): + self.assertEqual(parse_shipped_versions(tags, prefix), expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/test-fixtures/jira/README.md b/test-fixtures/jira/README.md index 3b323922..b5e65401 100644 --- a/test-fixtures/jira/README.md +++ b/test-fixtures/jira/README.md @@ -52,12 +52,20 @@ field ID or value shape. Used by the `create-integration-ticket` sandbox integra has no setup script — it links against the long-lived sandbox ticket `SONAR-22193` instead, and cleans up only the tickets it creates via `cleanup.py --issue-keys` above. -`--team`, `--edition` and `--fix-versions` are required. `NONE` means "must be unset"; -`--fix-versions` also accepts `ANY`, meaning "must be set". +`--team` and `--edition` are required, with `NONE` meaning "must be unset". Supply exactly one of: + +- `--fix-versions`: comma-separated exact names, `NONE` (unset), or `ANY` (nonempty). +- `--fix-version-prefixes`: comma-separated prefixes, requiring exactly one version per prefix + and no unrelated versions. The integration ticket sandbox test uses this to require both + `sqcb-*` and `sqs-*` without hardcoding release numbers. + +The `SONAR` sandbox must contain an eligible unreleased, non-archived `major.minor` version +for each prefix, excluding versions already tagged in `sonar-enterprise` when tag lookup is +available. Version selection and the `N/A` edition are covered by local unit tests. ```bash python assert_ticket_fields.py --use-sandbox true --ticket-key SONAR-101 \ - --team "$TEAM_UUID" --edition "Community Build & Server" --fix-versions ANY + --team "$TEAM_UUID" --edition "Community Build & Server" --fix-version-prefixes sqcb-,sqs- ``` ## Usage in GitHub Actions Workflows diff --git a/test-fixtures/jira/assert_ticket_fields.py b/test-fixtures/jira/assert_ticket_fields.py index a598e5da..465e9d39 100644 --- a/test-fixtures/jira/assert_ticket_fields.py +++ b/test-fixtures/jira/assert_ticket_fields.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ -Re-reads a Jira ticket and asserts its Edition, Team and Fix versions (NONE = unset, ANY = set). +Re-reads a Jira ticket and asserts its Edition, Team and Fix versions. Usage: python assert_ticket_fields.py --use-sandbox true --ticket-key SONAR-101 \ --team f1da89c9-3712-4d15-b194-a4b24406e3e4 --edition "Community Build & Server" \ - --fix-versions ANY + --fix-version-prefixes sqcb-,sqs- python assert_ticket_fields.py --use-sandbox true --ticket-key GHA-102 \ --team NONE --edition NONE --fix-versions NONE """ @@ -45,25 +45,51 @@ def check(expected_arg, got, parse_expected, empty): return ok, expected +def check_fix_version_prefixes(expected_arg, got): + """Requires exactly one version per prefix and no unrelated versions.""" + prefixes = expected_arg.split(',') + expected = {f'{prefix}*' for prefix in prefixes} + ok = ( + all(prefixes) + and len(got) == len(prefixes) + and all(sum(name.startswith(prefix) for name in got) == 1 for prefix in prefixes) + and all(any(name.startswith(prefix) for prefix in prefixes) for name in got) + ) + return ok, expected + + def main(): parser = argparse.ArgumentParser(description="Assert Edition/Team/Fix versions on a Jira ticket.") parser.add_argument("--use-sandbox", default="false") parser.add_argument("--ticket-key", required=True) parser.add_argument("--team", required=True, help=f"Expected team UUID, or {UNSET}.") parser.add_argument("--edition", required=True, help=f"Expected Edition value, or {UNSET}.") - parser.add_argument("--fix-versions", required=True, - help=f"Comma-separated expected Fix versions names, {UNSET}, or {ANY}.") + fix_versions = parser.add_mutually_exclusive_group(required=True) + fix_versions.add_argument("--fix-versions", + help=f"Comma-separated expected Fix versions names, {UNSET}, or {ANY}.") + fix_versions.add_argument("--fix-version-prefixes", + help="Comma-separated prefixes; require exactly one Fix version per prefix.") args = parser.parse_args() fields = get_jira_instance(args.use_sandbox).issue(args.ticket_key).raw['fields'] + values = { + 'team': actual(fields, CUSTOM_FIELDS['TEAM'], 'id'), + 'edition': actual(fields, CUSTOM_FIELDS['EDITION'], 'value'), + 'fixVersions': actual_fix_versions(fields), + } + checks = { + 'team': check(args.team, values['team'], str, None), + 'edition': check(args.edition, values['edition'], str, None), + 'fixVersions': ( + check_fix_version_prefixes(args.fix_version_prefixes, values['fixVersions']) + if args.fix_version_prefixes is not None + else check(args.fix_versions, values['fixVersions'], lambda v: set(v.split(',')), set()) + ), + } failed = False - for name, expected_arg, got, parse_expected, empty in [ - ('team', args.team, actual(fields, CUSTOM_FIELDS['TEAM'], 'id'), lambda v: v, None), - ('edition', args.edition, actual(fields, CUSTOM_FIELDS['EDITION'], 'value'), lambda v: v, None), - ('fixVersions', args.fix_versions, actual_fix_versions(fields), lambda v: set(v.split(',')), set()), - ]: - ok, expected = check(expected_arg, got, parse_expected, empty) + for name, (ok, expected) in checks.items(): + got = values[name] if ok: eprint(f"✅ {args.ticket_key} {name}: {got!r}") else: diff --git a/test-fixtures/jira/test_assert_ticket_fields.py b/test-fixtures/jira/test_assert_ticket_fields.py new file mode 100644 index 00000000..4389ce28 --- /dev/null +++ b/test-fixtures/jira/test_assert_ticket_fields.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Tests for the Jira field assertions used by the sandbox workflow.""" + +import os +import sys +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from assert_ticket_fields import main +from jira_common import CUSTOM_FIELDS + + +class TestAssertTicketFields(unittest.TestCase): + def setUp(self): + self.fields = { + CUSTOM_FIELDS['TEAM']: {'id': 'team-id'}, + CUSTOM_FIELDS['EDITION']: {'value': 'Community Build & Server'}, + 'fixVersions': [], + } + self.jira = Mock() + self.jira.issue.return_value.raw = {'fields': self.fields} + self.argv = [ + 'assert_ticket_fields.py', '--use-sandbox', 'true', '--ticket-key', 'SONAR-123', + '--team', 'team-id', '--edition', 'Community Build & Server', + ] + + def run_assertion(self, *fix_version_args): + with patch('sys.argv', self.argv + list(fix_version_args)), patch( + 'assert_ticket_fields.get_jira_instance', return_value=self.jira + ), patch('assert_ticket_fields.eprint'), patch('sys.stderr'), self.assertRaises(SystemExit) as result: + main() + return result.exception.code + + def test_requires_one_fix_version_per_prefix_and_no_other_versions(self): + cases = [ + (['sqs-2026.5', 'sqcb-26.9'], 0), + (['sqs-2026.5'], 1), + (['unrelated-version'], 1), + (['sqs-2026.5', 'sqs-2026.6'], 1), + (['sqs-2026.5', 'sqcb-26.9', 'sqcb-26.10'], 1), + (['sqs-2026.5', 'sqcb-26.9', 'unrelated-version'], 1), + ([], 1), + ] + for names, expected_exit in cases: + with self.subTest(names=names): + self.fields['fixVersions'] = [{'name': name} for name in names] + self.assertEqual(self.run_assertion('--fix-version-prefixes', 'sqcb-,sqs-'), expected_exit) + + def test_preserves_unset_any_and_exact_fix_version_expectations(self): + cases = [ + ('NONE', [], 0), + ('NONE', ['sqs-2026.5'], 1), + ('ANY', ['sqs-2026.5'], 0), + ('ANY', [], 1), + ('sqcb-26.9,sqs-2026.5', ['sqs-2026.5', 'sqcb-26.9'], 0), + ('sqcb-26.9,sqs-2026.5', ['sqs-2026.5'], 1), + ] + for expectation, names, expected_exit in cases: + with self.subTest(expectation=expectation, names=names): + self.fields['fixVersions'] = [{'name': name} for name in names] + self.assertEqual(self.run_assertion('--fix-versions', expectation), expected_exit) + + def test_checks_team_and_edition_alongside_fix_versions(self): + cases = [ + (CUSTOM_FIELDS['TEAM'], {'id': 'other-team'}), + (CUSTOM_FIELDS['EDITION'], {'value': 'Server'}), + (CUSTOM_FIELDS['TEAM'], None), + (CUSTOM_FIELDS['EDITION'], None), + ] + for field, value in cases: + with self.subTest(field=field, value=value): + fields = dict(self.fields) + fields[field] = value + self.jira.issue.return_value.raw = {'fields': fields} + self.assertEqual(self.run_assertion('--fix-versions', 'NONE'), 1) + self.jira.issue.return_value.raw = {'fields': {}} + self.argv = [ + 'assert_ticket_fields.py', '--ticket-key', 'SONAR-123', '--team', 'NONE', '--edition', 'NONE', + ] + self.assertEqual(self.run_assertion('--fix-versions', 'NONE'), 0) + + def test_requires_exactly_one_fix_version_expectation(self): + for args in ((), ('--fix-versions', 'ANY', '--fix-version-prefixes', 'sqcb-,sqs-')): + with self.subTest(args=args): + self.assertEqual(self.run_assertion(*args), 2) + self.jira.issue.assert_not_called() + + +if __name__ == '__main__': + unittest.main() From 833b513b1f64e7f7bf29cef5ad6f67d89272d11b Mon Sep 17 00:00:00 2001 From: Jonas Wielage Date: Wed, 7 Oct 2026 08:25:10 +0200 Subject: [PATCH 8/8] Documentation fix --- .okf/actions/create-integration-ticket.md | 20 +++++---------- .okf/log.md | 10 +++----- create-integration-ticket/README.md | 30 ++++++++++------------- docs/AUTOMATED_RELEASE.md | 4 +-- test-fixtures/jira/README.md | 8 +++--- 5 files changed, 27 insertions(+), 45 deletions(-) diff --git a/.okf/actions/create-integration-ticket.md b/.okf/actions/create-integration-ticket.md index 94cba79d..5ecb4e0c 100644 --- a/.okf/actions/create-integration-ticket.md +++ b/.okf/actions/create-integration-ticket.md @@ -36,23 +36,15 @@ dropped. Availability differs per project (`SONAR`: both; `SC`: team only), whic Covered by a real Jira sandbox job that re-reads the created tickets and asserts the stored values — the only check that catches a wrong custom field ID or value shape. It runs against fixed sandbox state (`SONAR-22193`, a fixed team UUID) instead of a setup script, like -[get-jira-release-notes](/actions/get-jira-release-notes.md). `fixVersions` must contain exactly -one version per edition prefix and no unrelated versions; which version wins is unit-tested. -The sandbox needs an eligible open version for each prefix. The `N/A` case is tested locally. +[get-jira-release-notes](/actions/get-jira-release-notes.md). The sandbox also asserts exactly +one Fix version per edition prefix and no unrelated versions, requiring eligible open versions. # Automatic Fix versions -When `edition` is set, `fixVersions` gets the lowest open `major.minor` version per prefix: -`sqcb-` (Community Build), `sqs-` (Server) or both; `N/A` sets nothing. Released, archived and -`sonar-enterprise`-tagged versions are skipped. The tag lookup uses a token from vault -`SonarSource-` if the `secret-name` input is set, else -`{REPO_OWNER_NAME_DASH}-release-automation`. An unavailable token only warns. -Jira API errors, connection errors, timeouts and invalid JSON omit automatic fix versions; -GitHub lookup failures fall back to Jira-only selection. Neither prevents ticket creation. - -Tests exercise edition mappings and version filtering through the resolver, and lookup -failures and environment token propagation through ticket creation. HTTP authentication, -pagination and tag parsing are covered separately; logging wording is not a test contract. +When `edition` is set, `fixVersions` gets the lowest unreleased, non-archived `major.minor` +version per prefix: `sqcb-` (Community Build), `sqs-` (Server) or both; `N/A` sets nothing. +The `secret-name` token excludes `sonar-enterprise`-tagged versions. Jira lookup failure omits +Fix versions; unavailable GitHub lookup uses Jira-only selection. # Citations diff --git a/.okf/log.md b/.okf/log.md index 7b1ed21b..ca17561e 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -1,13 +1,9 @@ # Update Log ## 2026-10-06 -* **Automatic integration ticket Fix versions**: Selects the lowest open `sqcb-*`/`sqs-*` - versions by edition, skipping `sonar-enterprise` tags; adds a `secret-name` input - and forwards the release automation secret from `automated-release.yml`. -* **Integration ticket test consolidation**: Replaces overlapping helper and logging tests - with behavior cases, covers lookup failures and token propagation through ticket creation, - and requires one stored fix version per edition prefix in the sandbox. Jira connection - errors, timeouts and invalid JSON no longer block ticket creation. +* **Automatic integration ticket Fix versions**: Selects versions by edition, excluding + `sonar-enterprise` tags; adds `secret-name`, forwarded by `automated-release.yml`. +* **Sandbox assertions**: Require one Fix version per edition prefix and no unrelated versions. ## 2026-09-17 * **Repo-specific repox status preferred**: [get-release-version](/actions/get-release-version.md) diff --git a/create-integration-ticket/README.md b/create-integration-ticket/README.md index fbad70f4..ab506f7d 100644 --- a/create-integration-ticket/README.md +++ b/create-integration-ticket/README.md @@ -34,7 +34,7 @@ This action requires: | `parent-epic` | Jira issue key to set as parent of the created ticket (e.g. CPP-7858) | No | - | | `edition` | Jira "Edition" value. One of: `N/A`, `Community Build`, `Server`, `Community Build & Server` | No | - | | `team` | Atlassian team **UUID** for the Jira "Team" field | No | - | -| `secret-name` | Release automation vault secret name (read as `SonarSource-`) with access to `sonar-enterprise`; defaults to `{REPO_OWNER_NAME_DASH}-release-automation` | No | - | +| `secret-name` | Vault token secret with read access to `sonar-enterprise`; read as `SonarSource-` when supplied | No | `{REPO_OWNER_NAME_DASH}-release-automation` | **Note:** Either `ticket-summary` must be provided, or both `plugin-name` and `release-version` must be provided. If `ticket-summary` is not provided, it will be automatically generated as "Update {plugin-name} to {release-version}". @@ -47,24 +47,20 @@ request and the action fails. `edition` is available on `SONAR`, not on `SC`; `t `team` takes the team UUID, not the name (find it via `customfield_10001.id` on an existing ticket's `/rest/api/2/issue/`). UUIDs differ between production and sandbox. -### Automatic 'Fix versions' +### Automatic Fix versions -When `edition` is set, `Fix versions` is filled with the lowest open version per prefix: +When `edition` is set, Fix versions uses the lowest unreleased, non-archived `major.minor` +version per prefix, excluding versions already tagged in `sonar-enterprise`: -| `edition` | Fix versions set | -|-------------------------------|-------------------------------------| -| `N/A` | none | -| `Community Build` | next unreleased `sqcb-*` | -| `Server` | next unreleased `sqs-*` | -| `Community Build & Server` | next unreleased `sqcb-*` and `sqs-*`| +| `edition` | Version prefixes | +|----------------------------|------------------| +| `N/A` | none | +| `Community Build` | `sqcb-` | +| `Server` | `sqs-` | +| `Community Build & Server` | `sqcb-`, `sqs-` | -Released, archived and `sonar-enterprise`-tagged versions (tag `sqs-2026.5.2.1` ⇒ `sqs-2026.5`) are -skipped. If Jira version lookup fails (including connection errors, timeouts or invalid JSON), -the action warns and creates the ticket without automatic Fix versions. - -The tag lookup uses the vault `SonarSource-` token if `secret-name` is set, else -`{REPO_OWNER_NAME_DASH}-release-automation`. If the vault token is unavailable or cannot access -`sonar-enterprise`, the action warns and only Jira is consulted. +For example, tag `sqs-2026.5.2.1` excludes Jira version `sqs-2026.5`. +Tag lookup uses the `secret-name` token. Missing candidates are omitted. ## Outputs @@ -156,4 +152,4 @@ The action will fail if: The action will continue but warn if: - The description field cannot be set (due to project configuration or permissions) - Ticket linking fails (the ticket is still created successfully) -- Automatic Fix versions lookup fails (Jira failure omits Fix versions; GitHub failure uses Jira-only selection) +- Automatic Fix versions lookup fails: Jira lookup failure omits Fix versions; an unavailable token or GitHub lookup failure uses Jira-only selection. diff --git a/docs/AUTOMATED_RELEASE.md b/docs/AUTOMATED_RELEASE.md index e171e424..97069b97 100644 --- a/docs/AUTOMATED_RELEASE.md +++ b/docs/AUTOMATED_RELEASE.md @@ -52,7 +52,7 @@ This workflow composes several actions from this repository: | `use-jira-sandbox` | Use Jira sandbox. The workflow treats this as a dry run and suppresses the Code Quality leads release announcement. | No | `true` | | `is-draft-release` | Create the GitHub release as a draft | No | `true` | | `pm-email` | Product manager email to assign the release ticket after technical release | Yes | - | -| `release-automation-secret-name` | Secret name used to create analyzer update PRs. If omitted, defaults to `sonar-{plugin-name}-release-automation`. | No | - | +| `release-automation-secret-name` | Token secret for analyzer update PRs and `sonar-enterprise` tag lookup. Defaults to `sonar-{plugin-name}-release-automation`. | No | - | | `short-description` | Brief summary for release and integration tickets | Yes | - | | `rule-props-changed` | Whether rule properties changed (`true`/`false`); mapped to Yes/No in the release ticket | Yes | - | | `branch` | Branch to release from | Yes | `master` | @@ -176,7 +176,7 @@ jobs: - `sqs-ticket-edition` and `sqs-sqc-ticket-team` are optional and applied at ticket creation, so a bad value or a field missing from the project's create screen fails the job. `team` takes the Atlassian team **UUID**, not the name — read `customfield_10001.id` off an existing ticket. -- The SQS ticket's `Fix versions` skip versions already tagged in `sonar-enterprise` (vault token from `release-automation-secret-name`, default `sonar-{plugin-name}-release-automation`). +- When `sqs-ticket-edition` is set, [automatic Fix versions](../create-integration-ticket/README.md#automatic-fix-versions) uses `release-automation-secret-name` for tag lookup. - Summaries: - Each job includes a "Summary" step that writes to `$GITHUB_STEP_SUMMARY` only when `verbose: true`. - For non-sandbox releases, a short release announcement containing the project, released version, and GitHub release-notes link is sent to `#team-code-quality-pm-em-lead` after the GitHub release is created. Set `code-quality-leads-slack-notification: false` to opt out. The announcement is skipped when `use-jira-sandbox: true` (the default dry-run mode). diff --git a/test-fixtures/jira/README.md b/test-fixtures/jira/README.md index b5e65401..13ae7600 100644 --- a/test-fixtures/jira/README.md +++ b/test-fixtures/jira/README.md @@ -56,12 +56,10 @@ cleans up only the tickets it creates via `cleanup.py --issue-keys` above. - `--fix-versions`: comma-separated exact names, `NONE` (unset), or `ANY` (nonempty). - `--fix-version-prefixes`: comma-separated prefixes, requiring exactly one version per prefix - and no unrelated versions. The integration ticket sandbox test uses this to require both - `sqcb-*` and `sqs-*` without hardcoding release numbers. + and no unrelated versions. -The `SONAR` sandbox must contain an eligible unreleased, non-archived `major.minor` version -for each prefix, excluding versions already tagged in `sonar-enterprise` when tag lookup is -available. Version selection and the `N/A` edition are covered by local unit tests. +The sandbox test requires an [eligible version](../../create-integration-ticket/README.md#automatic-fix-versions) +for each of `sqcb-` and `sqs-` in `SONAR`. ```bash python assert_ticket_fields.py --use-sandbox true --ticket-key SONAR-101 \