How to migrate Zephyr Squad between two Jira Cloud instances

This article explains how to migrate third-party app called Zephyr Squad – Test Management for Jira from one Jira Cloud instance to another. This is for instances that use old view of Zepyhr Squad – it means that tests are Jira issues with issue type called Test.

Why use Zephyr Squad – Test Management for Jira

  • Test Case Management: Organize and manage your test cases with ease, allowing for better handling of testing assets and the ability to reuse test cases across different sprints.
  • Detailed Test Reporting: Gain deeper insights into your test results through a variety of reports, dashboards, and widgets designed to track test execution progress.
  • Automation & Integration: Enhance collaboration and streamline your testing processes with native Jira integration, along with support for BDD and CI tool integrations for automated testing.

Using Zephyr Squad REST APIs

Note: To view the official Zephyr Squad REST API documentation, please refer to the official link.

Before getting started, navigate to the app configuration, then select Generate API Keys to create a new key. Make sure to save both the Access Key and Secret Key values. Since each Zephyr REST API call involves different parameters, a new token must be generated for each call. Therefore, the token generation code needs to be executed before every REST API request.

Below is an example of how to generate the token needed to call the REST API for retrieving test steps of a specific issue:

ACCESS_KEY = "your_access_key"
SECRET_KEY = "your_secret_key"
ACCOUNT_ID = "your_account_id"
JWT_EXPIRE = 3600

RELATIVE_PATH = f'/public/rest/api/2.0/teststep/{issue_id}'
QUERY_STRING = f'projectId={source_project_id}'

PATH_SOURCE = 'GET&' + RELATIVE_PATH + '&' + QUERY_STRING
payload_token = {
                    'sub': ACCOUNT_ID,
                    'qsh': hashlib.sha256(PATH_SOURCE.encode('utf-8')).hexdigest(),
                    'iss': ACCESS_KEY,
                    'exp': int(time.time()) + JWT_EXPIRE,
                    'iat': int(time.time())
                }
token = jwt.encode(payload_token, SECRET_KEY, algorithm='HS256').strip()
print(token)

Once you have retrieved the token, you are ready to call the API endpoint to fetch the test steps for the specific issue:

url = f"https://prod-api.zephyr4jiracloud.com/connect/public/rest/api/2.0/teststep/{issue_id}?projectId={project_id}"

headers = {
           'Authorization': 'JWT ' + token,
           'Content-Type': 'application/json',
           'zapiAccessKey': ACCESS_KEY
          }
response = requests.get(url, headers=headers)

Real life example

The following script demonstrates how to migrate test steps from a source Jira Cloud instance to a destination instance for specified projects using Zephyr Squad REST APIs:

import json
import jwt
import time
import hashlib
import requests
from requests.auth import HTTPBasicAuth

EMAIL = "your_email"
API_TOKEN = "your_api_token"
ACCOUNT_ID = "your_atlassian_cloud_account_id"

auth = HTTPBasicAuth(email, API_TOKEN)
SOURCE_INSTANCE = ""    # source cloud instance
DESTINATION_INSTANCE = ""  # destination cloud instance

# BASE URL for Zephyr for Jira Cloud
BASE_URL = 'https://prod-api.zephyr4jiracloud.com/connect'

SOURCE_ACCESS_KEY = ''  # Specify Zephyr Squad access key for your user on source instance
SOURCE_SECRET_KEY = ''  # Specify Zephyr Squad secret key for your user on source instance

DESTINATION_ACCESS_KEY = ""  # Specify Zephyr Squad access key for your user on destination instance
DESTINATION_SECRET_KEY = ""  # Specify Zephyr Squad secret key for your user on destination instance


projects = [""]  # Specify projects that contain Zephyr Squad data and need to be updated


def is_json(data):
    try:
        json.loads(data)
    except ValueError:
        return False
    return True


def get_project_id(instance: str, project_key: str):
    project_url = f"https://{instance}.atlassian.net/rest/api/3/project/{project_key}"

    headers = {
        "Accept": "application/json"
    }

    project_response = requests.request(
        "GET",
        project_url,
        headers=headers,
        auth=auth
    )

    project_data = json.loads(project_response.text)

    return project_data["id"]


for project in projects:

    # Token is active for 3600s
    JWT_EXPIRE = 3600

    issue_url = f"https://{SOURCE_INSTANCE}.atlassian.net/rest/api/3/search"

    issue_headers = {
        "Accept": "application/json"
    }

    query = {
        'jql': f'project = "{project}" AND type = Test'
    }

    response = requests.request("GET", issue_url, headers=issue_headers, params=query, auth=auth)

    data = json.loads(response.text)

    total = data["total"]
    start_at = 0
    max_results = data["maxResults"]

    while total + 50 >= start_at:

        issue_url = f"https://{SOURCE_INSTANCE}.atlassian.net/rest/api/3/search?startAt={start_at}"

        issue_headers = {
            "Accept": "application/json"
        }

        query = {
            'jql': f'project = "{project}" AND type = Test'
        }

        response = requests.request("GET", issue_url, headers=issue_headers, params=query, auth=auth)

        data = json.loads(response.text)

        for issue in data["issues"]:

            source_issue_id = issue["id"]
            issue_key = issue["key"]

            issue_url = f"https://{DESTINATION_INSTANCE}.atlassian.net/rest/api/3/search"

            issue_headers = {
                "Accept": "application/json"
            }

            query = {
                'jql': f'issuekey = {issue_key}'
            }

            destination_issue = requests.request("GET", issue_url, headers=issue_headers, params=query, auth=auth)

            data = json.loads(destination_issue.text)

            if "issues" in data:
                destination_issue_id = data["issues"][0]["id"]

                # RELATIVE PATH for token generation and make request to api
                SOURCE_RELATIVE_PATH = f'/public/rest/api/2.0/teststep/{source_issue_id}'
                DESTINATION_RELATIVE_PATH = f'/public/rest/api/1.0/teststep/{destination_issue_id}'
                # QUERY STRING for API

                source_project_id = get_project_id(SOURCE_INSTANCE, project)
                destination_project_id = get_project_id(DESTINATION_INSTANCE, project)

                SOURCE_QUERY_STRING = f'projectId={source_project_id}'
                DESTINATION_QUERY_STRING = f'projectId={destination_project_id}'
                # CANONICAL PATH (Http Method & Relative Path & Query String)

                CANONICAL_PATH_SOURCE = 'GET&' + SOURCE_RELATIVE_PATH + '&' + SOURCE_QUERY_STRING
                CANONICAL_PATH_DESTINATION = 'POST&' + DESTINATION_RELATIVE_PATH + '&' + DESTINATION_QUERY_STRING

                # TOKEN HEADER: to generate jwt token
                source_payload_token = {
                    'sub': ACCOUNT_ID,
                    'qsh': hashlib.sha256(CANONICAL_PATH_SOURCE.encode('utf-8')).hexdigest(),
                    'iss': SOURCE_ACCESS_KEY,
                    'exp': int(time.time()) + JWT_EXPIRE,
                    'iat': int(time.time())
                }

                destination_payload_token = {
                    'sub': ACCOUNT_ID,
                    'qsh': hashlib.sha256(CANONICAL_PATH_DESTINATION.encode('utf-8')).hexdigest(),
                    'iss': DESTINATION_ACCESS_KEY,
                    'exp': int(time.time()) + JWT_EXPIRE,
                    'iat': int(time.time())
                }

                # GENERATE TOKEN
                source_token = jwt.encode(source_payload_token, SOURCE_SECRET_KEY, algorithm='HS256').strip()
                destination_token = jwt.encode(destination_payload_token, DESTINATION_SECRET_KEY, algorithm='HS256').strip()

                source_headers = {
                    'Authorization': 'JWT ' + source_token,
                    'Content-Type': 'application/json',
                    'zapiAccessKey': SOURCE_ACCESS_KEY
                }

                destination_headers = {
                    'Authorization': 'JWT ' + destination_token,
                    'Content-Type': 'application/json',
                    'zapiAccessKey': DESTINATION_ACCESS_KEY
                }

                source_url = f"https://prod-api.zephyr4jiracloud.com/connect/public/rest/api/2.0/teststep/{source_issue_id}?projectId={source_project_id}"

                response = requests.get(source_url, headers=source_headers)

                data = json.loads(response.text)

                if len(data["testSteps"]) > 0:

                    for step in data["testSteps"]:
                        #print(step)

                        payload = json.dumps({
                            "step": step["step"],
                            "data": step["data"],
                            "result": step["result"],
                        })

                        destination_url = f"https://prod-play.zephyr4jiracloud.com/connect/public/rest/api/1.0/teststep/{destination_issue_id}?projectId={destination_project_id}"
                        #print(f"Destination url is {destination_url}")

                        try:
                            response = requests.post(destination_url, headers=destination_headers, data=payload)
                            if response.status_code == 200:
                                print(f"Test step was created for issue {issue_key}.")
                            else:
                                print(
                                    f"Issue {issue_key} wasn't updated, status code {response.status_code}. Response: {response.text}")
                        except requests.exceptions.RequestException as e:
                            print(f"An error occurred while updating issue {issue_key}: {e}")
                else:
                    print(f"Issue {issue_key} doesn't have test steps.")

        start_at += 50

Keep in mind

  • Old vs. New View: As noted earlier, this guide is for Jira instances using the old view of Zephyr Squad, where tests are Jira issues with the “Test” issue type. If one of your instances uses the new view, contact Zephyr Squad support to see if they can revert it to the old view.
  • Permissions: Ensure you have the necessary global and project-level permissions before running scripts like this.
  • Testing: Always test the script in a non-production environment before applying it to your production instance.

Reach out!

I hope this article was helpful to you! If you have any additional questions, want to dive deeper into the topic, or have ideas for improvement, I’d love to hear from you.

You can find links to my LinkedIn profile and email at the bottom of the page, or feel free to reach out via the Contact page.