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..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) @@ -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 + # 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 @@ -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,8 @@ jobs: --use-sandbox "true" \ --ticket-key "$TICKET_KEY" \ --team "f1da89c9-3712-4d15-b194-a4b24406e3e4" \ - --edition "Community Build & Server" + --edition "Community Build & Server" \ + --fix-version-prefixes sqcb-,sqs- - name: Create ticket without edition or team id: without-fields @@ -139,7 +142,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 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 cc0705fa..5ecb4e0c 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,7 +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). +[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 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 50273fff..ca17561e 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -1,5 +1,10 @@ # Update Log +## 2026-10-06 +* **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) 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..ab506f7d 100644 --- a/create-integration-ticket/README.md +++ b/create-integration-ticket/README.md @@ -34,6 +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` | 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}". @@ -46,6 +47,21 @@ 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 uses the lowest unreleased, non-archived `major.minor` +version per prefix, excluding versions already tagged in `sonar-enterprise`: + +| `edition` | Version prefixes | +|----------------------------|------------------| +| `N/A` | none | +| `Community Build` | `sqcb-` | +| `Server` | `sqs-` | +| `Community Build & Server` | `sqcb-`, `sqs-` | + +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 | Output | Description | @@ -136,3 +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 lookup failure omits Fix versions; an unavailable token or GitHub lookup failure uses Jira-only selection. diff --git a/create-integration-ticket/action.yml b/create-integration-ticket/action.yml index 90440342..1ab68c27 100644 --- a/create-integration-ticket/action.yml +++ b/create-integration-ticket/action.yml @@ -34,6 +34,9 @@ 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 outputs: ticket-key: @@ -54,6 +57,15 @@ 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 != '' && inputs.edition != 'N/A' }} + 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: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: @@ -94,6 +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.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 && ' diff --git a/create-integration-ticket/create_integration_ticket.py b/create-integration-ticket/create_integration_ticket.py index ac0de43c..fd78f41e 100644 --- a/create-integration-ticket/create_integration_ticket.py +++ b/create-integration-ticket/create_integration_ticket.py @@ -8,13 +8,135 @@ 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 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 = [ + 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_by_version(candidates, 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 errors and malformed payloads.""" + 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, KeyError, TypeError) 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, 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 [ + 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 + 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] + + +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 [] + + 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 + 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): @@ -81,7 +203,13 @@ 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') + ) + 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..c5812b15 100644 --- a/create-integration-ticket/test_create_integration_ticket.py +++ b/create-integration-ticket/test_create_integration_ticket.py @@ -22,6 +22,7 @@ from jira.exceptions import JIRAError +@patch.dict(os.environ, {'GITHUB_TOKEN': ''}) class TestCreateIntegrationTicket(unittest.TestCase): def setUp(self): @@ -650,6 +651,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 +676,5 @@ def test_create_integration_ticket_edition_and_team_combinations(self): else: self.assertNotIn(CUSTOM_FIELDS['TEAM'], call_args) - 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/docs/AUTOMATED_RELEASE.md b/docs/AUTOMATED_RELEASE.md index a5582a57..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,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. +- 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 a7037787..13ae7600 100644 --- a/test-fixtures/jira/README.md +++ b/test-fixtures/jira/README.md @@ -47,17 +47,23 @@ 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` 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 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 \ - --team "$TEAM_UUID" --edition "Community Build & Server" + --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 7ad69008..465e9d39 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. 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-version-prefixes sqcb-,sqs- 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,69 @@ 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(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 = got == expected + 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 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}.") + 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, got in [ - ('team', args.team, actual(fields, CUSTOM_FIELDS['TEAM'], 'id')), - ('edition', args.edition, actual(fields, CUSTOM_FIELDS['EDITION'], 'value')), - ]: - expected = None if expected == UNSET else expected - if got == expected: + for name, (ok, expected) in checks.items(): + got = values[name] + if ok: eprint(f"✅ {args.ticket_key} {name}: {got!r}") else: eprint(f"❌ {args.ticket_key} {name}: expected {expected!r}, got {got!r}") 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()