Issues in Jira are made up of built-in fields along with fields which you can create and customise to meet the needs of your project team. Custom fields contain values which you can copy from one to another, allowing you to widen or narrow the custom field.

To copy custom field values to another field:

  1. Navigate to the Copy custom field values to another field page from the Jira Administration menu by selecting Apps→ScriptRunner→Built-in Scripts.
  2. Enter a Subquery (JQL) to identify issues containing this query.
  3. Select the the Source field and Target field.
  4. Click the Run button, or click the Choose another script button to repeat the process and identify more issues.

Each issue returned by the query will copy values from one custom field to another. This is useful if you want to convert the type of a custom field.

Note that if the two custom fields contain different types, you may not be able to use this feature. The following conversions are handled:

  • Single to multi, for example, single select to multi select, single user picker to multi user picker.

  • Multi to single, however, only the first value will be retained.

  • Multi to text, the values are concatenated with a comma.

  • Short text to unlimited text

For reference, here is how you can script this functionality yourself to run in the script console:

// Define a JQL query for the issues on which you want to copy custom field values
def query = 'project = FOO'

// Here you can specify the names of the fields you want to copy from and into
def sourceFieldName = 'Assignee'
def targetFieldName = 'My User Field'

// We retrieve a list of all fields in this JIRA instance
def fields = get("/rest/api/2/field")
        .asObject(List)
assert fields.status == 200

List<Map> allFields = fields.body
// Now we lookup the field IDs
Map sourceField = allFields.find { it.name == sourceFieldName }
Map targetField = allFields.find { it.name == targetFieldName }

assert sourceField : "No field found with name '${sourceFieldName}'"
assert targetField : "No field found with name '${targetFieldName}'"

// Search for the issues we want to update
def searchReq = get("/rest/api/2/search")
        .queryString("jql", query)
        .queryString("startAt", 0)
        .queryString("maxResults", 100) // If we search for too many issues we'll reach the 30s script timeout
        .queryString("fields", "${sourceField.key},${targetField.key}")
        .queryString("expand", "names,schema")
        .asObject(Map)
assert searchReq.status == 200

Map searchResult = searchReq.body

// A useful helper method
def sanitiseValue(fieldValue) {
    // If we strip ids and self links out, we can set options by their values
    if (fieldValue instanceof Map) {
        fieldValue.remove('id')
        fieldValue.remove('self')
    }
    if (fieldValue instanceof List || fieldValue.class?.isArray()) {
        fieldValue.each {
            sanitiseValue(it)
        }
    }
}
// Each field type stores its value in a different way, we allow some conversion between types here
def getValue(sourceValue, String sourceType, String targetType) {
    if (sourceType == targetType) {
        return sourceValue
    }
    if (sourceType == 'option' && targetType == 'array') {
        return [sourceValue]
    }

    if (sourceType == 'option' && targetType == 'string') {
        return (sourceValue as Map).value
    }

    if (sourceType == 'array' && (targetType == 'option' || targetType == 'user')) {
        return (sourceValue as List)[0]
    }
    if (sourceType == 'array' && targetType == 'string') {
        return (sourceValue as List<Map>).collect { it.value }.join(',')
    }

    if (sourceType == 'string' && targetType == 'option') {
        return [value: sourceValue]
    }

    if (sourceType == 'string' && targetType == 'array') {
        return [[value:sourceValue]]
    }
    if (sourceType == 'user' && targetType == 'array') {
        return [sourceValue]
    }
    sourceValue
}

String sourceType = (sourceField.schema as Map).type
String targetType = (targetField.schema as Map).type
def count = 0
def errors = ''

// Now we iterate through the search results
searchResult.issues.each { Map issue ->
    def issueFields = issue.fields as Map
    def sourceFieldValue = issueFields[sourceField.key]

    if (sourceFieldValue) {
        // If there is a field value in the source field we try and convert it into a format that
        // the target field will understand
        sanitiseValue(sourceFieldValue)
        def updateDoc = [fields: [
                (targetField.key): getValue(sourceFieldValue, sourceType, targetType)
        ]]

        // Now we make the change, ignoring whether the field exists on the edit screen
        def resp = put("/rest/api/2/issue/${issue.key}")
                .queryString("overrideScreenSecurity", true)
                .header("Content-Type", "application/json")
                .body(updateDoc)
                .asObject(Map)
        if (resp.status > 299) {
            logger.error("Failed to update ${issue.key}: ${resp.statusText} - ${resp.body}")
            def errorMessages = (resp.body as Map).errorMessages as List<String>
            errors += "\nERROR: ${issue.key}: ${errorMessages?.join(',')}"
        } else {
            count++
        }
    }
}
logger.info("Updated '${targetFieldName}' on ${count} issues")
errors
GROOVY