Bulk Change JIRA Descriptions with Python


02/10/2017

Bulk JIRA Changes

Roderick McMullen, Accusoft Software Engineer III

Earlier this year, I was tasked to prepare a JIRA epic and issues to improve continuous integration for ImageGear under our existing build infrastructure. Only after creating the epic’s story and several dozen issues did I notice a mistake in the boilerplate description used to create each issue. The next hour was spent using the JIRA web interface to correct each story to add the missing text.

Per JIRA documentation, bulk change of JIRA story descriptions is not available. However, JIRA’s REST API can be leveraged to script description updates to one or more issues, provided that authentication with the JIRA server succeeds. For each JIRA issue specified, retrieve its current description, apply a Python regular expression search and replace, and update the issue's description on the server.

Following the JIRA REST API tutorials, I prepared a Python 2.7 script that accepts as command line options:

  • login name and password.
  • a comma-separated list of JIRA issue keys.
  • a regular expression pattern to match.
  • replacement text.

After parsing the command line arguments with the Python argparse module, the script uses Python urllib2 to obtain the JIRA session cookie with an HTTP POST request, containing a username and password, packed into a JSON message, to endpoint rest/auth/1/session. The request should contain a header to identify “content-type” as “application/json”. When successful, the HTTP POST response contains the JIRA session cookie, packed in a JSON message, that must accompany subsequent server requests. Unpack the JSON response into a Python dict using the Python json.loads() function and retrieve the dict path “session/value” to identify the JIRA session cookie.

def reauthenticate(username, password, server='https://localhost:8090/jira'):
   url = u'{0}/rest/auth/1/session'.format(server)
   data = {'username':username, 'password': password}
   r = urllib2.Request(url,json.dumps(data))
   r.add_header('Content-Type', 'application/json')
   f = urllib2.urlopen(r)
   try:
       return json.loads((f.read()))['session']['value']
   finally:
       f.close()

To get an issue’s current description, send an HTTP GET request to endpoint rest/api/2/issue/{issuekey}, where {issuekey} identifies the JIRA issue to retrieve. When successful, the HTTP GET response contains the JIRA issue’s property values. Unpack the JSON response into a Python dict using the json.loads() function and retrieve the dict path “fields/description” to identify the issue description.

def get_jira_issue(jsessionid, issuekey, server='https://localhost:8090/jira'):
   url = u'{0}/rest/api/2/issue/{1}'.format(server, urllib2.quote(issuekey))
   opener = urllib2.build_opener()
   opener.addheaders.append((u'Cookie', u'JSESSIONID={0}'.format(jsessionid)))
   f = opener.open(url)
   try:
       return json.loads((f.read()))
   finally:
       f.close()

To set an issue’s details, send an HTTP PUT request to endpoint rest/api/2/issue/{issuekey}, where {issuekey} identifies the JIRA issue to edit. The request data is a JSON message that indicates new property values. Create a Python dict with dict path “fields/description” value equal to the new description. Pack the dict into a JSON message using the json.dumps() function.

def set_jira_issue_description(jsessionid, issuekey, description,
                              server='https://localhost:8090/jira'):
   url = u'{0}/rest/api/2/issue/{1}'.format(server, urllib2.quote(issuekey))
   data = {u'fields':{u'description':description}}
   r = urllib2.Request(url,json.dumps(data))
   r.add_header('Content-Type', 'application/json')
   r.add_header('Cookie', 'JSESSIONID={0}'.format(jsessionid))
   r.get_method = lambda:'PUT'
   f = urllib2.urlopen(r)
   try:
       return
   finally:
       f.close()

I favor the Python re module re.sub() function for text substitutions. For simplicity, the script accepts a single find-replace pair. Prior to each issue update, a difference report is generated with the Python difflib module and printed to stdout. An interactive version of this script could block at this stage, prompting the operator to accept or skip the update after review.

def preview_update(issuekey, before, after):
   d = difflib.unified_diff(before.splitlines(), after.splitlines(),
       u'{0} Description (original)'.format(issuekey),
       u'{0} Description (modified)'.format(issuekey), n=2, lineterm=u'\n')
   sys.stdout.write(u'\n'.join(list(d))+'\n')
 
def preview_and_accept_update(issuekey, before, after):
   preview_update(issuekey, before, after)
   return True
 
def preview_and_reject_update(issuekey, before, after):
   preview_update(issuekey, before, after)
   return False
 
def bulk_update_description(jsessionid, issuekeys, regex, repl,
                           server='https://localhost:8090/jira',
                           confirmUpdateProc=preview_and_accept_update):
   for issuekey in issuekeys:
       issuekey = issuekey.strip()
       before = get_jira_issue(jsessionid,issuekey,server)['fields']['description']
       after = regex.sub(repl, before)
       if confirmUpdateProc(issuekey, before, after):
           set_jira_issue_description(jsessionid,issuekey,after,server)

The final Python 2.7 script is included below.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
COPYRIGHT   = 'Copyright Accusoft Corporation'
PROG        = 'jira-find-replace.py'
VERSION     = '1.0.0'
DESCRIPTION = """For each JIRA issue specified, retrieve its description,
apply a regular expression substitution, then update its description on the 
server.

Example: Replace occurrences of "a blog post" to "an article" in JIRA issue ABC-001:
    python jira-find-replace.py -n https://jira.organization.com -i "ABC-001" -f "a blog post" -r "an article" -u jdoe
    python jira-find-replace.py -n https://jira.organization.com -i "ABC-001" -f "a blog post" -r "an article" -u jdoe -p password
    python jira-find-replace.py -n https://jira.organization.com -i "ABC-001" -f "a blog post" -r "an article" -j DEADBEEFDEADBEEFDEADBEEFDEADBEEF --preview-only

"""

import argparse
import codecs
import sys
import urllib2
import json
import getpass
import re
import difflib

DEFAULT_JIRA_SERVER = u'https://localhost:8090/jira'

def reauthenticate(username, password, server=DEFAULT_JIRA_SERVER):
    """ Create a new session and return the session cookie.
    
    Args:
        username (str): The JIRA username for authentication.
        password (str): The JIRA password for authentication.
        server (str, optional): The URL to the JIRA server. Default value
            is DEFAULT_JIRA_SERVER.
    
    Returns:
        Str: Returns the JSESSIONID cookie retrieved from the server response.
    
    Raises:
        HTTPError. Raises HTTPError for authentication errors.
    
    """
    url = u'{0}/rest/auth/1/session'.format(server)
    data = {'username':username, 'password': password}
    r = urllib2.Request(url,json.dumps(data))
    r.add_header('Content-Type', 'application/json')
    f = urllib2.urlopen(r)
    try:
        return json.loads((f.read()))['session']['value']
    finally:
        f.close()

def get_jira_issue(jsessionid, issuekey, server=DEFAULT_JIRA_SERVER):
    """Get details for a JIRA issue.
        
    Args:
        jsessionid (str): The JIRA session ID cookie assigned after
            authenticating with the JIRA server.
        issuekey (str): The JIRA issue to return. e.g. XYZ-200.
        server (str, optional): The URL to the JIRA server. Default value
            is DEFAULT_JIRA_SERVER.
    
    Returns:
        Dict: JIRA issuekey details.
    
    Raises:
        HTTPError. Raises HTTPError for communication errors.
    
    """
    url = u'{0}/rest/api/2/issue/{1}'.format(server, urllib2.quote(issuekey))
    opener = urllib2.build_opener()
    opener.addheaders.append((u'Cookie', u'JSESSIONID={0}'.format(jsessionid)))
    f = opener.open(url)
    try:
        return json.loads((f.read()))
    finally:
        f.close()

def set_jira_issue_description(jsessionid, issuekey, description, 
                               server=DEFAULT_JIRA_SERVER):
    """Set description for a JIRA issue.
        
    Args:
        jsessionid (str): The JIRA session ID cookie assigned after logging 
            into the JIRA server.
        issuekey (str): The JIRA issue to return. e.g. XYZ-200.
        description (str): The new description.
        server (str, optional): The URL to the JIRA server. Default value
            is DEFAULT_JIRA_SERVER.
    
    Returns:
        Dict: JIRA issue details.
    
    Raises:
        HTTPError. Raises HTTPError for communication errors.
    
    """
    url = u'{0}/rest/api/2/issue/{1}'.format(server, urllib2.quote(issuekey))
    data = {u'fields':{u'description':description}}
    r = urllib2.Request(url,json.dumps(data))
    r.add_header('Content-Type', 'application/json')
    r.add_header('Cookie', 'JSESSIONID={0}'.format(jsessionid))
    r.get_method = lambda:'PUT' 
    f = urllib2.urlopen(r)
    try:
        return
    finally:
        f.close()

def preview_update(issuekey, before, after):
    """ Prints a diff report for the description change.
    
    Args:
        issuekey (str): The JIRA issue to return. e.g. XYZ-200.
        before (str): The issue description before susbstitutions are applied.
        after (str): The issue description after susbstitutions are applied.
    
    Returns:
        None
    
    """
    d = difflib.unified_diff(before.splitlines(), after.splitlines(), 
        u'{0} Description (original)'.format(issuekey), 
        u'{0} Description (modified)'.format(issuekey), n=2, lineterm=u'\n')
    sys.stdout.write(u'\n'.join(list(d))+'\n')

def preview_and_accept_update(issuekey, before, after):
    """ Prints a diff report and accept update.
    
    Args:
        issuekey (str): The JIRA issue to return. e.g. XYZ-200.
        before (str): The issue description before susbstitutions are applied.
        after (str): The issue description after susbstitutions are applied.
    
    Returns:
        None
    
    """
    preview_update(issuekey, before, after)
    return True

def preview_and_reject_update(issuekey, before, after):
    """ Prints a diff report and reject update.
    
    Args:
        issuekey (str): The JIRA issue to return. e.g. XYZ-200.
        before (str): The issue description before susbstitutions are applied.
        after (str): The issue description after susbstitutions are applied.
    
    Returns:
        None
    
    """
    preview_update(issuekey, before, after)
    return False

def bulk_update_description(jsessionid, issuekeys, regex, repl, 
                            server=DEFAULT_JIRA_SERVER, 
                            confirmUpdateProc=preview_and_accept_update):
    """ Update list of JIRA issue specified, retrieve the its description, apply a
    regular expression substitution, then update its description on the server.
    
    Args:
        jsessionid (str): The JIRA session ID cookie assigned after logging 
            into the JIRA server.
        issuekeys (list): List of JIRA issues to modify. e.g. [XYZ-200, XYZ-201].
        regex (re.regex): A precompiled regular expression object to perform
            substitutions.
        repl (str): The replacement text.
        server (str, optional): The URL to the JIRA server. Default value
            is DEFAULT_JIRA_SERVER.
        confirmUpdateProc (function, optional): Callback function that
            returns True to accept update; returns False to skip update.
            Default value is print_diff_and_accept_update.
    
    Returns:
        None
    
    Raises:
        HTTPError. Raises HTTPError for communication errors.
    
    """
    for issuekey in issuekeys:
        issuekey = issuekey.strip()
        before = get_jira_issue(jsessionid,issuekey,server)['fields']['description']
        after = regex.sub(repl, before)
        if confirmUpdateProc(issuekey, before, after):
            set_jira_issue_description(jsessionid,issuekey,after,server)

if __name__ == u'__main__':

    # Parse arguments.
    parser = argparse.ArgumentParser(description=DESCRIPTION, prog=PROG, conflict_handler=u'resolve', formatter_class=argparse.RawTextHelpFormatter)
    parser.add_argument(u'--version', action='version', version=u'%(PROG)s %(VERSION)s, %(COPYRIGHT)s'%{u'PROG':PROG, u'VERSION':VERSION, u'COPYRIGHT':COPYRIGHT})
    parser.add_argument(u'-n', u'--server', dest='jiraBaseUrl', default=DEFAULT_JIRA_SERVER, help=u'The JIRA server base URL.\nDefault value is {0}.'.format(DEFAULT_JIRA_SERVER))
    parser.add_argument(u'-j', u'--jsessionid', dest='jiraSessionId', default=None, help=u'The JIRA session ID cookie assigned after a successful login.')
    parser.add_argument(u'-u', u'--user', dest='jiraUser', default=None, help=u'The JIRA login username.\nDefault is None.')
    parser.add_argument(u'-p', u'--password', dest='jiraPassword', default=None, help=u'The JIRA login password.\nDefault is None.')
    parser.add_argument(u'-i', u'--issuekeys', dest='jiraIssueKeys', default=None, help=u'A comma-separated list of JIRA issues to modify. e.g. "ABC-001, ABC-002"', required=True)
    parser.add_argument(u'-f', u'--find', dest='find', default='', help='A regular expression pattern to match.', required=True)
    parser.add_argument(u'-r', u'--replace', dest='replace', default='', help='The substitution text to replace regular expression matches.', required=True)
    parser.add_argument(u'-d', u'--preview-only', action='store_true', dest='previewOnly', default=False, help='Preview modifications, but do not commit.\nDefault is False.')
    args = parser.parse_args()
    
    if args.jiraSessionId is None:
        if args.jiraUser is not None:
            if args.jiraPassword is None:
                args.jiraPassword = getpass.getpass('password: ')
            args.jiraSessionId = reauthenticate(username=args.jiraUser,
                                                password=args.jiraPassword,
                                                server=args.jiraBaseUrl)
    
    if True == args.previewOnly:
        bulk_update_description(jsessionid=args.jiraSessionId,
                                issuekeys=args.jiraIssueKeys.split(','),
                                regex=re.compile(args.find),
                                repl=args.replace,
                                server=args.jiraBaseUrl,
                                confirmUpdateProc=preview_and_reject_update)
    else:
        bulk_update_description(jsessionid=args.jiraSessionId,
                                issuekeys=args.jiraIssueKeys.split(','),
                                regex=re.compile(args.find),
                                repl=args.replace,
                                server=args.jiraBaseUrl,
                                confirmUpdateProc=preview_and_accept_update)
    
    exit(0)

Roderick McMullen is a Software Engineer III in the SDK division. He joined the company in 2004 as a Software Engineer in Support. Roderick graduated with a Bachelor of Science in Computer Engineering from the University of Florida.

Join the discussion.