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
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:
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!
Delfino Rosales
Senior Cloud Support Engineer
Amsterdam, NL
0 comments