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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/automated-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/test-create-integration-ticket.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-jira-fixtures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions .okf/actions/create-integration-ticket.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions .okf/log.md
Original file line number Diff line number Diff line change
@@ -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-<repo-name>-<branch>` context first, falling back to the generic
Expand Down
17 changes: 17 additions & 0 deletions create-integration-ticket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<secret-name>` 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}".

Expand All @@ -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/<KEY>`). 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 |
Expand Down Expand Up @@ -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.
13 changes: 13 additions & 0 deletions create-integration-ticket/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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 && '
Expand Down
128 changes: 128 additions & 0 deletions create-integration-ticket/create_integration_ticket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jonas-wielage-sonarsource marked this conversation as resolved.
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):
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion create-integration-ticket/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
jira==3.10.5

requests==2.34.2
3 changes: 2 additions & 1 deletion create-integration-ticket/test_create_integration_ticket.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from jira.exceptions import JIRAError


@patch.dict(os.environ, {'GITHUB_TOKEN': ''})
class TestCreateIntegrationTicket(unittest.TestCase):

def setUp(self):
Expand Down Expand Up @@ -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
Expand All @@ -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()
Loading
Loading