Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 additions & 2 deletions .github/workflows/test-jira-fixtures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
python test-fixtures/jira/setup.py \
--project-key GHA \
--run-id "${{ github.run_id }}" \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/"
--use-sandbox "true"
echo "Fixture state:"
cat /tmp/jira-fixtures.json | jq .

Expand Down Expand Up @@ -128,7 +128,7 @@ jobs:
run: |
if [ -f /tmp/jira-fixtures.json ]; then
python test-fixtures/jira/cleanup.py \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/" \
--use-sandbox "true" \
--state-file /tmp/jira-fixtures.json
else
echo "State file not found — setup failed before writing fixtures, nothing to clean up."
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test-resolve-ktlo-epic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ jobs:
python test-fixtures/jira/setup_ktlo.py \
--project-key GHA \
--run-id "${{ github.run_id }}" \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/" \
--use-sandbox "true" \
--state-file "$STATE_FILE"
STATE=$(cat "$STATE_FILE")
echo "one_match_key=$(echo "$STATE" | jq -r .one_match_key)" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -147,7 +147,7 @@ jobs:
STDERR_OUTPUT=$(python resolve-ktlo-epic/resolve_ktlo_epic.py \
--jira-project GHA \
--epic-name-pattern "$PATTERN" \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/" 2>&1 >/dev/null)
--use-sandbox "true" 2>&1 >/dev/null)
if ! echo "$STDERR_OUTPUT" | grep -q "::warning::"; then
echo "❌ Expected ::warning:: annotation in stderr for multi-match case"
echo "Got: $STDERR_OUTPUT"
Expand Down Expand Up @@ -183,7 +183,7 @@ jobs:
run: |
if [ -f "$STATE_FILE" ]; then
python test-fixtures/jira/cleanup_ktlo.py \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/" \
--use-sandbox "true" \
--state-file "$STATE_FILE"
else
echo "State file not found — setup failed before writing fixtures, nothing to clean up."
Expand Down
47 changes: 47 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ action-name/
- Input precedence: explicit input > environment variable > default

### Jira Custom Field IDs

Defined in `shared/jira_common.py` as the `CUSTOM_FIELDS` dict — single source of truth:
```python
customfield_10146 # SHORT_DESCRIPTION
customfield_10145 # LINK_TO_RELEASE_NOTES
Expand Down Expand Up @@ -121,3 +123,48 @@ run: echo "$INPUT_BRANCH"
```

This prevents tag mutation attacks where a malicious actor could change what code a tag points to.

## Golden Architecture

Every action is a self-contained composite invoked as `SonarSource/release-github-actions/<action>@v1`. Consumers pin `@v1`; `release.yml` fast-forwards it on release. **Internal refactors are transparent; changing an action's `inputs:`/`outputs:` is a breaking change — treat the input/output surface as a public API.**

### Shared Python code

Common helpers live in `shared/` (`eprint`, `get_jira_instance`, `CUSTOM_FIELDS`). Import via:
```python
import os, sys
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
```

**Do NOT package `shared/` with `pyproject.toml` / `pip install -e .`** — that forces an install step into every consumer workflow. `@v1` checks out the whole repo, so `../shared` is already on disk at runtime. (See reverted commit `ad8a663`.)

Rule: if a helper exists in ≥2 actions and you need a third copy, put it in `shared/` with a test in `shared/test_*.py` instead.

### Jira URL resolution

Jira URL selection lives entirely in `shared/jira_common.py` (`JIRA_URL_PROD`, `JIRA_URL_SANDBOX`, `get_jira_url()`). **No `action.yml` constructs a URL.**

All `action.yml` files pass the sandbox flag through as an env var and forward it to the script:
```yaml
env:
USE_SANDBOX: ${{ inputs.use-jira-sandbox || env.USE_JIRA_SANDBOX }}
run: |
python script.py --use-sandbox="$USE_SANDBOX"
```

Scripts call `get_jira_instance(args.use_sandbox)` — URL resolution happens inside. Only scripts that need the URL string directly (e.g. for constructing issue links) also import `get_jira_url`. A future domain change is one edit in `shared/jira_common.py`.

### Things that LOOK like duplication but MUST stay per-action

- **Vault credential blocks.** A nested composite can only export secrets via `$GITHUB_ENV`, exposing them to every later step in the job. Keep the vault block inline per action.
- **`inputs.x || env.X` + `if [[ -z ]]` guard.** Mandated by the Security section above (no user input interpolated into `run:`) and the input-precedence rule. Removing it reintroduces injection risk.
- **Orchestrator fan-outs** (integration-ticket steps, analyzer steps in `automated-release.yml`). They surface *named* job outputs consumed by downstream jobs, and reference same-job step outputs; a `matrix` job cannot expose per-cell named outputs. Keep explicit steps.
- **setup-python + `pip install` preamble.** Byte-identical across actions and kept pinned by `update-action-versions.yml`. Don't wrap it in a composite for line count.

### Conventions for every new action

- `using: "composite"`; Python 3.10; use the canonical JIRA URL expression above.
- Outputs via stdout `key=value` → `action.yml` redirects `>> $GITHUB_OUTPUT`; diagnostics via `eprint()` to stderr.
- External (non-SonarSource) actions pinned to a full commit SHA with a version comment.
- New action → `README.md` in its directory + link from root `README.md`.
5 changes: 2 additions & 3 deletions create-integration-ticket/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,7 @@ runs:
env:
JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }}
JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }}
JIRA_PROD_URL: "https://sonarsource.atlassian.net/"
JIRA_SANDBOX_URL: "https://sonarsource-sandbox-608.atlassian.net/"
USE_SANDBOX: ${{ inputs.use-jira-sandbox || env.USE_JIRA_SANDBOX }}
TICKET_DESCRIPTION: |
${{ inputs.ticket-description }}${{ inputs.jira-release-url && inputs.ticket-description && '

Expand All @@ -104,7 +103,7 @@ runs:
--ticket-summary "$TICKET_SUMMARY" \
--target-jira-project "$INPUT_TARGET_JIRA_PROJECT" \
--release-ticket-key "$INPUT_RELEASE_TICKET_KEY" \
--jira-url="${{ ((inputs.use-jira-sandbox || env.USE_JIRA_SANDBOX) == 'true') && env.JIRA_SANDBOX_URL || env.JIRA_PROD_URL }}" \
--use-sandbox="$USE_SANDBOX" \
--link-type "$INPUT_LINK_TYPE" \
${TICKET_DESCRIPTION:+--ticket-description "$TICKET_DESCRIPTION"} \
${INPUT_PARENT_EPIC:+--parent-epic "$INPUT_PARENT_EPIC"} >> $GITHUB_OUTPUT
51 changes: 6 additions & 45 deletions create-integration-ticket/create_integration_ticket.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,50 +10,11 @@
import os
import sys
import time
from jira import JIRA
from jira.exceptions import JIRAError


# noinspection DuplicatedCode
def eprint(*args, **kwargs):
"""
Prints messages to the standard error stream (stderr).

This function serves as a dedicated logger for informational messages,
warnings, or errors. In a CI/CD pipeline, this ensures that diagnostic
output is kept separate from the primary result of the script, which is
written to standard output (stdout). This separation allows for cleaner
data parsing and redirection.
"""
print(*args, file=sys.stderr, **kwargs)


def get_jira_instance(jira_url):
"""
Initializes and returns a JIRA client instance and the server URL.
"""
jira_user = os.environ.get('JIRA_USER')
jira_token = os.environ.get('JIRA_TOKEN')

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

eprint(f"Connecting to JIRA server at: {jira_url}")
eprint(f"Authenticating with user: {jira_user}")
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'shared'))
from jira_common import eprint, get_jira_instance
from jira.exceptions import JIRAError

try:
jira_client = JIRA(jira_url, basic_auth=(jira_user, jira_token), get_server_info=True)
eprint("JIRA authentication successful.")
return jira_client
except JIRAError as e:
eprint(f"Error: JIRA authentication failed. Status: {e.status_code}")
eprint("Please check your JIRA URL, user, and token.")
eprint(f"Response text: {e.text}")
sys.exit(1)
except Exception as e:
eprint(f"An unexpected error occurred during JIRA connection: {e}")
sys.exit(1)


def validate_release_ticket(jira_client, release_ticket_key):
Expand Down Expand Up @@ -184,8 +145,8 @@ def main():
help="The key of the ticket to link to (e.g., REL-123).")
parser.add_argument("--target-jira-project", required=True,
help="The key of the project where the ticket will be created (e.g., SQS).")
parser.add_argument("--jira-url", required=True,
help="The Jira server URL to connect to.")
parser.add_argument("--use-sandbox", default="false",
help="Use Jira sandbox (true/false).")
parser.add_argument("--link-type", default="relates to",
help="The type of link to create (e.g., 'relates to', 'depends on').")
parser.add_argument("--parent-epic",
Expand All @@ -194,7 +155,7 @@ def main():
args = parser.parse_args()

# Initialize Jira client
jira = get_jira_instance(args.jira_url)
jira = get_jira_instance(args.use_sandbox)

# Validate the release ticket exists
release_ticket = validate_release_ticket(jira, args.release_ticket_key)
Expand Down
85 changes: 13 additions & 72 deletions create-integration-ticket/test_create_integration_ticket.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from create_integration_ticket import (
get_jira_instance, validate_release_ticket, create_integration_ticket,
validate_release_ticket, create_integration_ticket,
link_tickets, main
)
from jira.exceptions import JIRAError
Expand All @@ -30,65 +30,6 @@ def setUp(self):
'JIRA_TOKEN': 'test_token'
}

@patch.dict(os.environ, {})
def test_get_jira_instance_missing_credentials(self):
"""Test that get_jira_instance exits when credentials are missing."""
with self.assertRaises(SystemExit) as cm:
get_jira_instance('https://sonarsource.atlassian.net/')
self.assertEqual(cm.exception.code, 1)

@patch.dict(os.environ, {'JIRA_USER': 'test', 'JIRA_TOKEN': 'token'})
@patch('create_integration_ticket.JIRA')
def test_get_jira_instance_success_prod(self, mock_jira_class):
"""Test successful JIRA instance creation for production."""
mock_jira = Mock()
mock_jira_class.return_value = mock_jira

jira_client = get_jira_instance('https://sonarsource.atlassian.net/')

self.assertEqual(jira_client, mock_jira)
mock_jira_class.assert_called_once_with(
'https://sonarsource.atlassian.net/',
basic_auth=('test', 'token'),
get_server_info=True
)

@patch.dict(os.environ, {'JIRA_USER': 'test', 'JIRA_TOKEN': 'token'})
@patch('create_integration_ticket.JIRA')
def test_get_jira_instance_success_sandbox(self, mock_jira_class):
"""Test successful JIRA instance creation for sandbox."""
mock_jira = Mock()
mock_jira_class.return_value = mock_jira

jira_client = get_jira_instance('https://sonarsource-sandbox-608.atlassian.net/')

self.assertEqual(jira_client, mock_jira)
mock_jira_class.assert_called_once_with(
'https://sonarsource-sandbox-608.atlassian.net/',
basic_auth=('test', 'token'),
get_server_info=True
)

@patch.dict(os.environ, {'JIRA_USER': 'test', 'JIRA_TOKEN': 'token'})
@patch('create_integration_ticket.JIRA')
def test_get_jira_instance_auth_failure(self, mock_jira_class):
"""Test JIRA instance creation with authentication failure."""
mock_jira_class.side_effect = JIRAError(status_code=401, text="Unauthorized")

with self.assertRaises(SystemExit) as cm:
get_jira_instance('https://sonarsource.atlassian.net/')
self.assertEqual(cm.exception.code, 1)

@patch.dict(os.environ, {'JIRA_USER': 'test', 'JIRA_TOKEN': 'token'})
@patch('create_integration_ticket.JIRA')
def test_get_jira_instance_unexpected_error(self, mock_jira_class):
"""Test JIRA instance creation with unexpected error."""
mock_jira_class.side_effect = Exception("Unexpected error")

with self.assertRaises(SystemExit) as cm:
get_jira_instance('https://sonarsource.atlassian.net/')
self.assertEqual(cm.exception.code, 1)

def test_validate_release_ticket_success(self):
"""Test successful release ticket validation."""
mock_jira = Mock()
Expand Down Expand Up @@ -481,7 +422,7 @@ def test_link_tickets_unexpected_error(self):
'--ticket-summary', 'Integration for TestProject 1.0.0',
'--release-ticket-key', 'REL-123',
'--target-jira-project', 'INT',
'--jira-url', 'https://sonarsource.atlassian.net/',
'--use-sandbox', 'false',
'--link-type', 'relates to'
])
@patch('create_integration_ticket.get_jira_instance')
Expand Down Expand Up @@ -509,8 +450,8 @@ def test_main_successful_execution(self, mock_stderr, mock_link_tickets,

main()

# Verify get_jira_instance was called with correct URL
mock_get_jira.assert_called_once_with('https://sonarsource.atlassian.net/')
# Verify get_jira_instance was called with correct flag
mock_get_jira.assert_called_once_with('false')

# Verify validate_release_ticket was called
mock_validate_release_ticket.assert_called_once_with(mock_jira, 'REL-123')
Expand All @@ -523,7 +464,7 @@ def test_main_successful_execution(self, mock_stderr, mock_link_tickets,
self.assertEqual(args.ticket_summary, 'Integration for TestProject 1.0.0')
self.assertEqual(args.release_ticket_key, 'REL-123')
self.assertEqual(args.target_jira_project, 'INT')
self.assertEqual(args.jira_url, 'https://sonarsource.atlassian.net/')
self.assertEqual(args.use_sandbox, 'false')
self.assertEqual(args.link_type, 'relates to')

# Verify link_tickets was called
Expand All @@ -543,7 +484,7 @@ def test_main_successful_execution(self, mock_stderr, mock_link_tickets,
'--ticket-summary', 'Minimal integration ticket',
'--release-ticket-key', 'REL-456',
'--target-jira-project', 'TEST',
'--jira-url', 'https://sonarsource-sandbox-608.atlassian.net/'
'--use-sandbox', 'true'
])
@patch('create_integration_ticket.get_jira_instance')
@patch('create_integration_ticket.validate_release_ticket')
Expand All @@ -570,16 +511,16 @@ def test_main_with_minimal_parameters(self, mock_stderr, mock_link_tickets,

main()

# Verify get_jira_instance was called with sandbox URL
mock_get_jira.assert_called_once_with('https://sonarsource-sandbox-608.atlassian.net/')
# Verify get_jira_instance was called with sandbox flag
mock_get_jira.assert_called_once_with('true')

# Verify parameters were parsed correctly with defaults
call_args = mock_create_ticket.call_args[0]
args = call_args[1] # args object
self.assertEqual(args.ticket_summary, 'Minimal integration ticket')
self.assertEqual(args.release_ticket_key, 'REL-456')
self.assertEqual(args.target_jira_project, 'TEST')
self.assertEqual(args.jira_url, 'https://sonarsource-sandbox-608.atlassian.net/')
self.assertEqual(args.use_sandbox, 'true')
self.assertEqual(args.link_type, 'relates to') # default

# Verify link_tickets was called with default link type
Expand All @@ -593,7 +534,7 @@ def test_main_with_minimal_parameters(self, mock_stderr, mock_link_tickets,
'--ticket-description', 'This ticket has a detailed description',
'--release-ticket-key', 'REL-789',
'--target-jira-project', 'DESC',
'--jira-url', 'https://sonarsource.atlassian.net/',
'--use-sandbox', 'false',
'--link-type', 'depends on'
])
@patch('create_integration_ticket.get_jira_instance')
Expand Down Expand Up @@ -621,8 +562,8 @@ def test_main_with_description(self, mock_stderr, mock_link_tickets,

main()

# Verify get_jira_instance was called with correct URL
mock_get_jira.assert_called_once_with('https://sonarsource.atlassian.net/')
# Verify get_jira_instance was called with correct flag
mock_get_jira.assert_called_once_with('false')

# Verify validate_release_ticket was called
mock_validate_release_ticket.assert_called_once_with(mock_jira, 'REL-789')
Expand All @@ -636,7 +577,7 @@ def test_main_with_description(self, mock_stderr, mock_link_tickets,
self.assertEqual(args.ticket_description, 'This ticket has a detailed description')
self.assertEqual(args.release_ticket_key, 'REL-789')
self.assertEqual(args.target_jira_project, 'DESC')
self.assertEqual(args.jira_url, 'https://sonarsource.atlassian.net/')
self.assertEqual(args.use_sandbox, 'false')
self.assertEqual(args.link_type, 'depends on')

# Verify link_tickets was called
Expand Down
4 changes: 2 additions & 2 deletions create-jira-release-ticket/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ runs:
RELEASE_VERSION: ${{ inputs.version || env.RELEASE_VERSION }}
SHORT_DESCRIPTION: ${{ inputs.short-description }}
DUE_DATE: ${{ inputs.due-date }}
JIRA_URL: ${{ ((inputs.use-jira-sandbox || env.USE_JIRA_SANDBOX) == 'true') && 'https://sonarsource-sandbox-608.atlassian.net/' || 'https://sonarsource.atlassian.net/' }}
USE_SANDBOX: ${{ inputs.use-jira-sandbox || env.USE_JIRA_SANDBOX }}
DOCUMENTATION_STATUS: ${{ inputs.documentation-status }}
RULE_PROPS_CHANGED: ${{ inputs.rule-props-changed }}
JIRA_RELEASE_URL: ${{ inputs.jira-release-url || env.JIRA_RELEASE_URL }}
Expand All @@ -98,7 +98,7 @@ runs:
--version="$RELEASE_VERSION" \
--due-date="$DUE_DATE" \
--short-description="$SHORT_DESCRIPTION" \
--jira-url="$JIRA_URL" \
--use-sandbox="$USE_SANDBOX" \
--documentation-status="$DOCUMENTATION_STATUS" \
--rule-props-changed="$RULE_PROPS_CHANGED" \
--jira-release-url="$JIRA_RELEASE_URL" \
Expand Down
Loading
Loading