Efficiently clone Epics and their children in Jira Cloud with Python

Hello Atlassian Community!

I want to share a Python script that automates the cloning of epics, including all their child issues, in Jira Software (Cloud).
This tool could be particularly beneficial for those who have struggled with Jira Cloud's rate-limiting constraints when working with epics having a substantial number of child issues.

Doing this manually can be tedious and time-consuming. On the other hand, using Jira Cloud automation for this task can inadvertently take us to a rate-limiting scenario due to the high number of automated requests in a short span of time.

The Solution

This Python script provides a more efficient alternative. By integrating with Jira's REST API, the script clones one or multiple epics and their children, implementing a delay mechanism between each API call. This delay aims to avoid rate-limiting and also respects the server's handling capacity, ensuring a smoother operation without impacting the instance's performance.

Preparation

  • Make sure you create a virtual environment, activate it, and install the requests library before running the script, run these 3 commands:
    • python3 -m venv venv
    • source venv/bin/activate (on mac) or .\venv\Scripts\activate (on windows)
    • python3 -m pip install requests
  • When the script identifies any error, it will colorize it for better reference, the colorama library is used for this purpose and it is a requirement for the script to run properly

    • pip install colorama

  • Go to the section labeled as # Configuration to input your cloud information, epic keys to clone, target project key, and authentication data

Script

import requests
import logging
import time
from datetime import datetime
from colorama import Fore, Style, init

init(autoreset=True)

class CustomFormatter(logging.Formatter):
    grey = Style.DIM + Fore.WHITE
    yellow = Fore.YELLOW
    red = Fore.RED
    bold_red = Style.BRIGHT + Fore.RED
    green = Fore.GREEN
    cyan = Fore.CYAN  # Add this line for cyan color
    format = '%(asctime)s - %(levelname)s - %(message)s'

    FORMATS = {
        logging.DEBUG: grey + format,
        logging.INFO: green + format,
        logging.WARNING: yellow + format,
        logging.ERROR: red + format,
        logging.CRITICAL: bold_red + format,
        'CUSTOM_CYAN': cyan + format
    }

    def format(self, record):
        log_fmt = self.FORMATS.get(record.levelno, self.FORMATS.get('CUSTOM_CYAN'))
        formatter = logging.Formatter(log_fmt, "%Y-%m-%d %H:%M:%S")
        return formatter.format(record)
    
    def log_custom_cyan(logger, message):
        extra = {'custom_color': 'cyan'}
        logger.info(message, extra=extra)

logger = logging.getLogger()
logger.setLevel(logging.INFO)
for handler in logger.handlers:
    logger.removeHandler(handler)
ch = logging.StreamHandler()
ch.setFormatter(CustomFormatter())
logger.addHandler(ch)

s = requests.Session()
s.headers.update({'User-Agent': 'OnlyScripts-CloningEpic/1.0'})

# Configuration
JIRA_URL = 'https://YOURCLOUDURL.atlassian.net'
API_TOKEN = 'YOURAPITOKEN'  # Generate from https://id.atlassian.com/manage-profile/security/api-tokens
EMAIL = 'USERNAME@MAIL.com'
AUTH = (EMAIL, API_TOKEN)
HEADERS = {'Content-Type': 'application/json'}
EPIC_KEYS = ['PROJ1-001', 'PROJ2-002']  # List of epics to clone
TARGET_PROJECT_KEY = 'PROJ3'  # Target project key

def clone_issue(issue_key, project_key, new_epic_link=None):
    try:
        url = f'{JIRA_URL}/rest/api/3/issue/{issue_key}'
        response = s.get(url, auth=AUTH)
        
        if response.status_code != 200:
            response.raise_for_status()

        issue_data = response.json()

        new_issue_data = {
            "fields": {
                "project": {
                    "key": project_key
                },
                "summary": issue_data['fields']['summary'],
                "description": issue_data['fields'].get('description', ''),
                "issuetype": {
                    "id": issue_data['fields']['issuetype']['id']
                }
            }
        }

        if issue_data['fields']['issuetype']['name'].lower() == 'epic':
            epic_name_field = 'customfield_10011'  # Epic Name field ID
            new_issue_data['fields'][epic_name_field] = issue_data['fields'][epic_name_field]
        elif new_epic_link:
            new_issue_data['fields']['customfield_10014'] = new_epic_link  # Epic Link field ID

        create_response = requests.post(f'{JIRA_URL}/rest/api/3/issue/', json=new_issue_data, headers=HEADERS, auth=AUTH)
        created_issue_key = create_response.json().get('key')

        logger.info(Fore.CYAN + f"Issue {issue_key} cloned as {created_issue_key} in {project_key}" + Style.RESET_ALL)
        return created_issue_key

    except requests.exceptions.HTTPError as http_err:
        logger.error(f'HTTP error occurred: {http_err}')  
    except Exception as err:
        logger.error(f'An error occurred: {err}')
        return None

def fetch_all_child_issues(epic_key):
    start_at = 0
    max_results = 50
    all_issues = []

    while True:
        jql = f'"Epic Link" = {epic_key}'
        search_url = f'{JIRA_URL}/rest/api/3/search'
        params = {
            'jql': jql,
            'startAt': start_at,
            'maxResults': max_results,
        }
        response = requests.get(search_url, headers=HEADERS, params=params, auth=AUTH)
        issues = response.json()

        all_issues.extend(issues.get('issues', []))
        if start_at + len(issues.get('issues', [])) >= issues.get('total', 0):
            break
        
        start_at += max_results
    
    return all_issues

def clone_epic_with_issues(epic_key, target_project):
    start_time = datetime.now()
    logging.info(f"Starting the cloning process for epic {epic_key}...")

    new_epic_key = clone_issue(epic_key, target_project)
    if not new_epic_key:
        logging.error("⚠ Failed to clone, aborting operation for this epic!")
logging.error("⚠ Check your authentication details and spelling of the epic keys.") return child_issues = fetch_all_child_issues(epic_key) total_issues = len(child_issues) child_issues_cloned = 0 logging.info(f"Cloning {total_issues} child issues...") for issue in child_issues: if clone_issue(issue['key'], target_project, new_epic_key): child_issues_cloned += 1 time.sleep(2) # Pause to prevent hitting API rate limit end_time = datetime.now() total_time = end_time - start_time hours, remainder = divmod(total_time.total_seconds(), 3600) minutes, seconds = divmod(remainder, 60) logging.info(f"The cloning operation for epic {epic_key} completed successfully.") logging.info(f"{child_issues_cloned}/{total_issues} child issues were cloned.") logging.info(f"Total time: {int(hours)}h {int(minutes)}m {int(seconds)}s") logging.info(f"Access the new epic here: {JIRA_URL}/browse/{new_epic_key}") if __name__ == "__main__": for epic_key in EPIC_KEYS: clone_epic_with_issues(epic_key, TARGET_PROJECT_KEY) logger.info("=====================================")

Sneak peek:


image(1).png

Note: I'm still working on better handling error logs, I'll be updating the script as I progress with that.

I'd love to read your thoughts, feedback, or any enhancements you believe can improve the script further.

Disclaimer:

While this script is designed to facilitate certain interactions with JIRA Software Cloud as a convenience, it is essential to understand that its functionality is subject to change due to updates to JIRA Software Cloud’s API or other conditions that could affect its operation.

Please note that this script is provided on an "as is" and "as available" basis without any warranties of any kind. This script is not officially supported or endorsed by Atlassian, and its use is at your own discretion and risk.

 

Happy Cloning!

 

0 comments

Comment

Log in or Sign up to comment
TAGS
AUG Leaders

Atlassian Community Events