diff --git a/.github/workflows/sync-release-to-jira.yml b/.github/workflows/sync-release-to-jira.yml new file mode 100644 index 000000000..3275b36f6 --- /dev/null +++ b/.github/workflows/sync-release-to-jira.yml @@ -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" diff --git a/etc/sync_jira_release.py b/etc/sync_jira_release.py new file mode 100644 index 000000000..040d78deb --- /dev/null +++ b/etc/sync_jira_release.py @@ -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 ") + 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])