Skip to content
Open
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
41 changes: 41 additions & 0 deletions .github/workflows/sync-release-to-jira.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Sync Release to Jira

# Manual trigger - run from GitHub Actions UI after publishing a release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g., 0.42.0 or v0.42.0)"
required: true
type: string

# Read-only permissions by default
permissions:
contents: read

jobs:
sync-jira:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dependencies
run: pip install requests

- name: Run Jira sync
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
# Pass version via env var to prevent shell injection
RELEASE_VERSION: ${{ inputs.version }}
run: |
python etc/sync_jira_release.py "$RELEASE_VERSION"
260 changes: 260 additions & 0 deletions etc/sync_jira_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
GitHub to Jira Release Sync
Syncs a specific release from viamrobotics/viam-cpp-sdk to Jira RSDK project.
Creates a "C++ SDK {version}" fix version in Jira and tags/closes referenced tickets.

Triggered by the sync-release-to-jira GitHub Actions workflow.

Environment Variables Required:
GITHUB_TOKEN - GitHub personal access token
JIRA_BASE_URL - Your Jira instance URL (e.g., https://your-domain.atlassian.net)
JIRA_EMAIL - Your Jira email address
JIRA_API_TOKEN - Jira API token
"""

import os
import re
import sys

import requests

# Configuration
GITHUB_REPO = "viamrobotics/viam-cpp-sdk"
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
JIRA_BASE_URL = os.getenv("JIRA_BASE_URL")
JIRA_EMAIL = os.getenv("JIRA_EMAIL")
JIRA_API_TOKEN = os.getenv("JIRA_API_TOKEN")
JIRA_PROJECT_KEY = "RSDK"

# Validate environment variables
assert GITHUB_TOKEN and JIRA_BASE_URL and JIRA_EMAIL and JIRA_API_TOKEN, (
"Missing required environment variables!\n\tRequired: GITHUB_TOKEN, JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN"
)

# Jira auth
jira_auth = (JIRA_EMAIL, JIRA_API_TOKEN)

# GitHub headers
github_headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {GITHUB_TOKEN}",
"X-GitHub-Api-Version": "2022-11-28",
}


def parse_release_version(version_input):
# Normalize version input - handle both "0.42.0" and "v0.42.0"
version = version_input.strip()

if version.startswith("v"):
version = version[1:]

if not re.match(r"^\d+\.\d+(\.\d+)?$", version):
raise ValueError(f"Invalid version format: {version_input}. Expected format: 0.42.0 or v0.42.0")

return version


def verify_release_exists(version):
# C++ SDK uses "releases/v{version}" tag format
tag = f"releases/v{version}"
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/{tag}"

response = requests.get(url, headers=github_headers)

if response.status_code == 200:
return response.json(), tag
else:
raise Exception(f"Release {tag} not found on {GITHUB_REPO}")


def get_tickets_from_release_notes(tag):
# Extract Jira ticket IDs from the GitHub release description
url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/{tag}"
response = requests.get(url, headers=github_headers)

if response.status_code != 200:
raise Exception(f"Could not fetch release notes for {tag}")

release_data = response.json()
body = release_data.get("body", "")

if not body:
print(f"Warning: Release {tag} has no description/notes")
return []

# Find all RSDK-XXX ticket references
pattern = f"{JIRA_PROJECT_KEY}-\\d+"
tickets = list(set(re.findall(pattern, body, re.IGNORECASE)))

return tickets


def create_jira_version(github_version):
# Create version in Jira RSDK project with format "C++ SDK {version}"
jira_version_name = f"C++ SDK {github_version}"

url = f"{JIRA_BASE_URL}/rest/api/3/version"

payload = {
"name": jira_version_name,
"project": JIRA_PROJECT_KEY,
"released": False,
"description": f"Release {github_version} from {GITHUB_REPO}",
}

response = requests.post(url, auth=jira_auth, headers={"Content-Type": "application/json"}, json=payload)

if response.status_code == 201:
return response.json()
elif response.status_code == 400:
# Version already exists, fetch it
search_url = f"{JIRA_BASE_URL}/rest/api/3/project/{JIRA_PROJECT_KEY}/versions"
versions = requests.get(search_url, auth=jira_auth).json()
return next((v for v in versions if v["name"] == jira_version_name), None)
else:
raise Exception(f"Failed to create version: {response.text}")


def get_ticket_status(ticket_key):
url = f"{JIRA_BASE_URL}/rest/api/3/issue/{ticket_key}"
params = {"fields": "status"}

response = requests.get(url, auth=jira_auth, params=params)
if response.status_code == 200:
return response.json()["fields"]["status"]["name"]
return None


def set_fix_version(ticket_key, version_id):
# Add fix version to a ticket (additive - preserves existing fix versions)
update_url = f"{JIRA_BASE_URL}/rest/api/3/issue/{ticket_key}"
update_payload = {"update": {"fixVersions": [{"add": {"id": version_id}}]}}

response = requests.put(
update_url, auth=jira_auth, headers={"Content-Type": "application/json"}, json=update_payload
)

if response.status_code != 204:
print(f"Failed to set fix version for {ticket_key}")
return False
return True


def set_fix_version_and_close(ticket_key, version_id):
# Set fix version, then transition ticket to Closed
if not set_fix_version(ticket_key, version_id):
return False

# Get available transitions
transitions_url = f"{JIRA_BASE_URL}/rest/api/3/issue/{ticket_key}/transitions"
response = requests.get(transitions_url, auth=jira_auth)

if response.status_code != 200:
print(f"Failed to fetch transitions for {ticket_key} (HTTP {response.status_code})")
return False

transitions = response.json().get("transitions", [])
close_transition = next(
(t for t in transitions if t.get("to", {}).get("name", "").lower() == "closed"),
None,
)

if not close_transition:
available = [f"{t['name']} -> {t.get('to', {}).get('name', '?')}" for t in transitions]
print(f"No transition to 'Closed' status for {ticket_key}. Available: {available}")
return False

# Transition to Closed with Resolution "Done"
payload = {"transition": {"id": close_transition["id"]}, "fields": {"resolution": {"name": "Done"}}}

response = requests.post(
transitions_url, auth=jira_auth, headers={"Content-Type": "application/json"}, json=payload
)

return response.status_code == 204


def main(version_input):
print(f"Processing: {version_input}\n")

# 1. Parse and validate version
version = parse_release_version(version_input)
print(f"Extracted version: {version}")

# 2. Verify release exists on GitHub
print("Checking GitHub for release...")
release_data, tag = verify_release_exists(version)
print(f"Found release {tag} on GitHub")
print(f" Published: {release_data['published_at'][:10]}")
print(f" Author: {release_data['author']['login']}\n")

# 3. Create Jira version
print(f"Creating Jira version: C++ SDK {version}")
jira_version = create_jira_version(version)
if not jira_version:
print("Failed to create Jira version")
return

version_id = jira_version["id"]
print(f"Jira version: {jira_version['name']} (ID: {version_id})\n")

# 4. Extract Jira tickets from release notes
print("Extracting Jira tickets from release notes...")
ticket_keys = get_tickets_from_release_notes(tag)
print(f"Found {len(ticket_keys)} unique tickets:")
print(f" {', '.join(ticket_keys) if ticket_keys else 'None'}\n")

if not ticket_keys:
print("No Jira tickets found in this release")
return

# 5. Process tickets based on their current status
print("Processing tickets...\n")
closed_count = 0
tagged_count = 0
skipped_count = 0

for ticket_key in ticket_keys:
status = get_ticket_status(ticket_key)

if status == "Awaiting Release":
print(f" {ticket_key}: {status} -> Setting fix version & closing...")
if set_fix_version_and_close(ticket_key, version_id):
print(f" {ticket_key} closed with fix version C++ SDK {version}")
closed_count += 1
else:
print(f" Failed to close {ticket_key}")
elif status == "Closed":
print(f" {ticket_key}: {status} -> Tagging fix version...")
if set_fix_version(ticket_key, version_id):
print(f" {ticket_key} tagged with fix version C++ SDK {version}")
tagged_count += 1
else:
print(f" Failed to tag fix version for {ticket_key}")
else:
print(f" {ticket_key}: {status} (skipped)")
skipped_count += 1

# 6. Summary
print(f"\n{'=' * 60}")
print("Release sync complete!")
print(f" Version: C++ SDK {version}")
print(f" Tickets closed: {closed_count}")
print(f" Tickets tagged (already closed): {tagged_count}")
print(f" Tickets skipped: {skipped_count}")
print(f" Total tickets: {len(ticket_keys)}")
print(f" Release page: {JIRA_BASE_URL}/projects/{JIRA_PROJECT_KEY}/versions/{version_id}/tab/release-report-all-issues")
print(f"{'=' * 60}")


if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: sync_jira_release.py <version>")
print("Examples:")
print(" sync_jira_release.py 0.42.0")
print(" sync_jira_release.py v0.42.0")
sys.exit(1)

main(sys.argv[1])
Loading