Looking for Confluence Data Center to Cloud Migration Tips?


Preparing

There is few thing to do before the first test migration:

  • Disable mail
  • Limit access
  • Install Apps - Notice this is not like Data Center - You cant disable and enable them as You please....

Clean up

I do recommend to clean up first:

Duplicates of objects

Ive seen that this does not function: https://confluence.atlassian.com/cloudkb/jira-cloud-migration-assistant-duplicates-and-tracking-entities-1063170066.html#:~:text=Possible%20causes%20for%20duplicates,3)%2C%20and%20so%20on.&text=One%20example%20use%20case%20is,a%20test%20instance%20in%20Server

A test - Migrate a project - Delete it - Migrate it again... Gave a lot of (migrated) fields, screens etc etc

Current recommendation seems to be a sire Reset (for Jira and Jira SErvice Management, and then a Big Bang migrate.


Dark Features

Disable Workflow migration errors

This Dark Feature will allow You to migrate Workflows:

com.atlassian.jira.migration.skip.invalid.workflow.rule

Migrate all boards and filters

https://jira.atlassian.com/browse/MIG-1668

Testing

You can test migrate project to Cloud as many times as needed.


Copy App/Speciel Fields to Text/Text Multiline field

As it is often nessesary to copy values to shadow fields, if the App has no migration or the App will not be used in cloud.

This script does not send mail to users!

Variables:

VariableExplanation
BASEURLThe URI for the Jira instance
RESTThe Scriptrunner path to the REST service
USERNAMEA username for a user with access to all issues. I use the "automation" user, and that user is a member of "automaticservices" - required for the REST
PASSWORDPassword or token for the above user.
FIELDMAP

23321|29033|M - 23321 is the source field in DC, 29033 is the destination field in DC, M is for the source and destination fields are multivalue fields, the opposite of S for singlevalue.

Like this S - Singlevalue:

Or this M - Multivalue:

DRYRUNIf DRYRUN="1" - no updates will be made in Jira
EMPTYONLYIf EMPTYONLY="1" - the REST service will only select issues where the destination field is empty. This will be faster, but values that are updated will not be copied again.
SKIPINDEXIf SKIPINDEX="1" - the Issue will not be reindexed, so f used - make sure to reindex Jira after the scriptrun.
DAYSBACK

Will add to the JQL:

and updated > -<DAYSBACK>d

So, after a full run, a more partial run can be done.


The script will loop all issuetypes for all projects and all field mappings and call a REST

password.txt
PASSWORD=*******************


UpdateShadowFieldsForIssue.sh
#!/bin/bash

source password.txt

IFS=$(echo -en ",")

BASEURL="https://jira.server.dk"
REST="/rest/scriptrunner/latest/custom/UpdateShadowFieldsForIssue"
USERNAME=automation
DRYRUN=""
EMPTYONLY="1"
SKIPINDEX=""
DAYSBACK=

#Loop projects
curl -s -u "$USERNAME:$PASSWORD" -X GET -H 'Content-Type: application/json'  $BASEURL/rest/api/2/project?maxResults=1000 > projects.json
cat projects.json | jq '.[].key' | while read -r PROJECTKEY; 
do

  PROJECTKEY=$(echo $PROJECTKEY | sed 's/\"//g')
  #echo "$PROJECTKEY"

  #Loop Issuetypes for the Project
  curl -s -u "$USERNAME:$PASSWORD" -X GET -H 'Content-Type: application/json'  $BASEURL/rest/api/2/issue/createmeta/$PROJECTKEY/issuetypes?maxResults=1000 > issuetypes.json
  cat issuetypes.json | jq '.values[].name' | while read -r ISSUETYPE;
  do

    ISSUETYPE=$(echo $ISSUETYPE | sed 's/\"//g')
    #echo $ISSUETYPE

    #Loop Fields
    declare -a FIELDMAP=("23321|29033|M" "23523|29020|S" "24523|29021|S" "23522|29022|S" "24623|29026|S" "24422|29023|S" "23726|29024|S" "23728|29034|M" "23729|29035|M" "23727|29025|S" "24920|29036|S" "24921|29037|S" "24624|29027|S" "24423|29028|S" "23320|29032|S" "24821|29031|S" "24625|29030|S" "24424|29029|S" "24922|29039|S" "24923|29038|S" "24924|29040|S" "24925|29041|S" "24926|29042|S" "25028|29043|S" "25027|29044|S")

    for FIELD in "${FIELDMAP[@]}"
    do
	
      #echo "Field $FIELD"
	    SOURCEFIELDID=$(echo $FIELD | cut -d '|' -f 1)
	    SHADOWFIELDID=$(echo $FIELD | cut -d '|' -f 2)
	    TYPE=$(echo $FIELD | cut -d '|' -f 3)

      ISSUETYPE=$(echo $ISSUETYPE | sed 's/ /%20/g')
	
	    QUERYSTRING="projectkey=$PROJECTKEY&issuetype=$ISSUETYPE&sourcefieldid=$SOURCEFIELDID&shadowfieldid=$SHADOWFIELDID&type=$TYPE&emptyonly=$EMPTYONLY&dryrun=$DRYRUN&skipindex=$SKIPINDEX&daysback=$DAYSBACK"
	    echo "curling.... $QUERYSTRING"
	    curl -u "$USERNAME:$PASSWORD" -X GET -H 'Content-Type: application/json' "$BASEURL$REST?$QUERYSTRING"

    done
  done
done


The scriptrunner REST code:

UpdateShadowFieldsForIssue.groovy
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.Issue
import com.atlassian.jira.issue.IssueManager
import com.atlassian.jira.user.util.UserUtil
import com.atlassian.jira.issue.CustomFieldManager
import com.atlassian.jira.issue.fields.CustomField
import com.atlassian.jira.bc.issue.search.SearchService
import com.atlassian.jira.issue.search.SearchProvider
import com.atlassian.jira.issue.search.SearchResults
import com.atlassian.jira.web.bean.PagerFilter
import com.atlassian.jira.user.ApplicationUser
import com.atlassian.jira.event.type.EventDispatchOption
import groovy.json.JsonSlurper
import com.atlassian.jira.issue.index.IssueIndexingService
import com.atlassian.jira.issue.MutableIssue
import com.atlassian.jira.issue.IssueManager
import com.atlassian.jira.util.ImportUtils
import groovy.transform.BaseScript
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
import javax.ws.rs.core.MultivaluedMap
import javax.servlet.http.HttpServletRequest
import javax.ws.rs.core.Response

@BaseScript CustomEndpointDelegate delegate

UpdateShadowFieldsForIssue(httpMethod: "GET", groups: ["automaticservices","jira-administrators"]) { MultivaluedMap queryParams ->

    Random random = new Random()
    String scriptRunIdent = Math.abs(random.nextInt() % 99999) + 1
    String scriptName = "UpdateShadowFieldsForIssue.groovy"

    String projectKey = queryParams.getFirst("projectkey") as String
    String issueType = queryParams.getFirst("issuetype") as String
    String sourceFieldId = queryParams.getFirst("sourcefieldid") as String
    String shadowFieldId = queryParams.getFirst("shadowfieldid") as String  
    String type = queryParams.getFirst("type") as String
    String emptyOnly = queryParams.getFirst("emptyonly") as String
    String dryRun = queryParams.getFirst("dryrun") as String
    String skipIndex = queryParams.getFirst("skipindex") as String
    String daysBack = queryParams.getFirst("daysback") as String
    Integer currentNumber = 0
    def builder = new groovy.json.JsonBuilder()

    String jql="Project=" + projectKey + " and issuetype='" + issueType + "' and cf[" + sourceFieldId + "] is not empty"
    if (emptyOnly == "1")
    {
        jql=jql + " and cf[" + shadowFieldId + "] is empty"
    }
    if (daysBack != "")
    {
        jql=jql + " and updated > -" + daysBack + "d"
    }
    log.info "Script=" + scriptName + " ScriptRunIdent=" + scriptRunIdent + " Message='JQL: " + jql + "'"
    
    ApplicationUser currentUser = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
    CustomFieldManager customFieldManager = ComponentAccessor.getCustomFieldManager()
    IssueManager issueManager = ComponentAccessor.getIssueManager()
    def issueIndexingService = ComponentAccessor.getComponent(IssueIndexingService)

    CustomField sourceField = customFieldManager.getCustomFieldObject("customfield_" + sourceFieldId)
    String jiraSourceFieldType =sourceField.getCustomFieldType().getName()
    CustomField shadowField = customFieldManager.getCustomFieldObject("customfield_" + shadowFieldId)
    String jiraShadowFieldType = shadowField.getCustomFieldType().getName()

    log.info "Script=" + scriptName + " ScriptRunIdent=" + scriptRunIdent + " Message='Source field Jira type: " + jiraSourceFieldType + "'"
    log.info "Script=" + scriptName + " ScriptRunIdent=" + scriptRunIdent + " Message='Shadow field Jira type: " + jiraShadowFieldType + "'"

    if (jiraShadowFieldType == "Text Field (single line)" || jiraShadowFieldType == "Text Field (multi-line)")
    {
        SearchService searchService = ComponentAccessor.getComponent(SearchService.class)
        SearchService.ParseResult parseResult =  searchService.parseQuery(currentUser, jql)

        if (parseResult.isValid())
        {
            SearchResults results = searchService.search(currentUser, parseResult.getQuery(), PagerFilter.getUnlimitedFilter())
            final List issues = results?.results
            totalIssues = issues.size()

            issues.each { theIssue ->

                log.info "Script=" + scriptName + " ScriptRunIdent=" + scriptRunIdent + " Message='Value " + theIssue.getCustomFieldValue(sourceField) + "'"

                //Extract value(s) here from Source field. Its possible to expand for another 'jiraSourceFieldType'
                if (jiraSourceFieldType.contains("Elements Connect"))
                {
                    sourceFieldValue = extractElementsValues(theIssue.getCustomFieldValue(sourceField),type)
                }
                else
                {
                    sourceFieldValue = theIssue.getCustomFieldValue(sourceField)
                }
                shadowFieldsValue = theIssue.getCustomFieldValue(shadowField)

                if (sourceFieldValue != shadowFieldsValue)
                {
                    currentNumber = currentNumber + 1
                    
                    if (dryRun != "1")
                    {
                        log.info "Script=" + scriptName + " IssueKey=" + theIssue.getKey() + " ScriptRunIdent=" + scriptRunIdent + " Message='Copying " + sourceFieldId + " to " + shadowFieldId + ", value: " + sourceFieldValue + "'"
                        MutableIssue mIssue = issueManager.getIssueByCurrentKey(theIssue.getKey())   
                        try {
                            mIssue.setCustomFieldValue(shadowField,sourceFieldValue)
                            ComponentAccessor.getIssueManager().updateIssue(currentUser, mIssue, EventDispatchOption.DO_NOT_DISPATCH, false)
                        }
                        catch  (Exception ex)
                        {
                            log.info "Script=" + scriptName + " IssueKey=" + theIssue.getKey() + " ScriptRunIdent=" + scriptRunIdent + " Error='Error Updating: " + ex.getMessage() + "'" 
                        }
                        //Reindex Issue
                        if (skipIndex != "1")
                        {
                            boolean wasIndexing = ImportUtils.isIndexIssues()
                            ImportUtils.setIndexIssues(true)
                            issueIndexingService.reIndex(mIssue)
                            ImportUtils.setIndexIssues(wasIndexing)
                        }
                    }
                    else
                    {
                        log.info "Script=" + scriptName + " IssueKey=" + theIssue.getKey() + " ScriptRunIdent=" + scriptRunIdent + " Message='DryRun - Copying " + sourceFieldId + " to " + shadowFieldId + ", value: " + sourceFieldValue + "'"
                    }
                }
                else
                {
                    log.info "Script=" + scriptName + " IssueKey=" + theIssue.getKey() + " ScriptRunIdent=" + scriptRunIdent + " Message='Skipped copy due to identical values'"
                }    
            }
            if (dryRun != "1")
            {
                if (currentNumber > 0)
                {
                    builder  {success "Fields copied successfully. Copied " + currentNumber + " field(s)."}
                }
                else
                {
                    builder  {success "No Fields copied."}
                }            
            }
            else
            {
                builder  {success "Dry Run. Woulf have copied " + currentNumber + " field(s)."}
            }
            Response.status(200).entity(builder.toString()).build()
        }
        else
        {
          builder  {error "Not valid JQL"}
          Response.status(500).entity(builder.toString()).build()
        }
    }
    else
    {
      builder  {error "Shadow field is not a Text Field."}
      Response.status(500).entity(builder.toString()).build()
    }
}

def extractElementsValues(theValue,fieldType)
{
    if (theValue == null)
    {
        return null
    }

    def slurper = new JsonSlurper().parseText(theValue)
    if (fieldType == "M")
    {
        slurper.keys.join("\n").toString()
    }
    else
    {
        slurper.keys[0].toString()
    }   
}