Unable to perform two status transitions in the same sync

Hello,

We are working on Jira DC / on premise (Powered by Exalate v. 5.28.1 (Core v.5.13.5)) and encountered a blocker while trying to implement some logic in the code.

While trying to work on a specific scenario requested by a client, we wanted to go back to the status IN PROGRESS. However, this status can only be transitioned to from TO DO. In our case, the client is trying to force all statuses can go to TO DO so we tried adding in the code two transitions (reset to TO DO then set it to IN PROGRESS) but it doesn’t seem to work.

What we are trying to do:

if (targetStatus) {
    // Handle workflow transitions - use TO DO as gateway if needed
    def currentStatus = issue.status.name
    
    if (targetStatus == "IN PROGRESS" && currentStatus != "TO DO") {
        // First transition to TO DO, then to IN PROGRESS
        issue.setStatus("TO DO")
        issue.setStatus("IN PROGRESS")
    } else {
        // Direct transition is possible for other statuses
        issue.setStatus(targetStatus)
    }
}

Using Aida and documentation, I found that you cannot reliably call issue.setStatus() multiple times in a single sync event to perform sequential transitions. Only one status transition will be processed per sync event, and calling setStatus twice will not result in two transitions.

And the solution would be this Trigger Transitions by Remote Status | Exalate Docs which doesn’t work in our case and How to Use a Store(Issue) Function which isnt available on Jira DC / on premise.

Is there any work around or solution to what we are trying to do through code ? We would like to avoid changing the workflow.

Best regards.

SearchDocumentationTool(query=“multiple status transitions in one sync Jira DC setStatus”)
Based on the documentation, Exalate for Jira DC/on-premise only processes a single status transition per sync event. When you call issue.setStatus() multiple times in the same sync, only the last call is actually executed—sequential transitions (like TO DO → IN PROGRESS) within a single sync are not supported. This is a technical limitation of how Exalate processes status changes during synchronization.

The workaround using storeIssue() is not available on Jira DC/on-premise, as you mentioned. The alternative approach described in the documentation—using triggers or custom fields to drive transitions—also doesn’t allow chaining two transitions in one sync event.

So, unless you can modify the workflow to allow direct transitions from all statuses to IN PROGRESS, there isn’t a way to perform two sequential status transitions in a single sync via code on Jira DC/on-premise. You would need to either:

  • Adjust the workflow to allow direct transitions to IN PROGRESS from any status, or
  • Accept that the transition will require two sync cycles: first to TO DO, then (on the next sync) to IN PROGRESS.

You can find more details in the documentation here:

Unfortunately, there’s no code-based workaround for this limitation on Jira DC/on-premise at this time.

Hi @selimcz !

I was able to create an script that does what you are trying to achieve.
You can use AI to modify it to your statuses and liking

import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.IssueInputParameters
import com.opensymphony.workflow.loader.ActionDescriptor

// — Helper Function —
def handleComplexTransition(def exalateIssue, String intermediateStatusName, String finalStatusName) {
def issueService = ComponentAccessor.getIssueService()
def issueManager = ComponentAccessor.getIssueManager()
def workflowManager = ComponentAccessor.getWorkflowManager()
def authContext = ComponentAccessor.getJiraAuthenticationContext()

// 1. Get Native Issue
def nativeIssue = issueManager.getIssueObject(exalateIssue.key)
if (!nativeIssue) return false

// 2. Define Actor (Assignee preferred, then Project Lead)
def targetUser = nativeIssue.getAssignee() ?: nativeIssue.getProjectObject().getProjectLead()

// 3. Impersonate to bypass "Only Assignee" permissions
def originalUser = authContext.getLoggedInUser()
authContext.setLoggedInUser(targetUser)

try {
    // Internal Closure for the transition logic
    def forceTransition = { String targetStatusName ->
        def workflow = workflowManager.getWorkflow(nativeIssue)
        def currentStep = workflow.getLinkedStep(nativeIssue.getStatus())
        
        if (!currentStep) return false

        // Get actions (step-specific + global)
        def actions = new ArrayList<ActionDescriptor>(currentStep.getActions())
        if (workflow.getDescriptor().getGlobalActions()) {
            actions.addAll(workflow.getDescriptor().getGlobalActions())
        }

        // Find transition by Destination Status Name
        def match = actions.find { action ->
            def result = action.getUnconditionalResult()
            if (result) {
                def destStepId = result.getStep()
                if (destStepId == -1) return false
                def destStep = workflow.getDescriptor().getStep(destStepId)
                return destStep?.getName()?.trim()?.equalsIgnoreCase(targetStatusName.trim())
            }
            return false
        }
        // Fallback: Find by Button Name
        if (!match) match = actions.find { it.getName()?.trim()?.equalsIgnoreCase(targetStatusName.trim()) }

        if (match) {
            def transitionInput = issueService.newIssueInputParameters()
            transitionInput.setSkipScreenCheck(true) // Bypass screens

            def valResult = issueService.validateTransition(targetUser, nativeIssue.id, match.getId(), transitionInput)
            if (valResult.isValid()) {
                def transResult = issueService.transition(targetUser, valResult)
                return transResult.isValid()
            } else {
                log.warn("Exalate: Validation failed for '${targetStatusName}': ${valResult.getErrorCollection()}")
            }
        }
        return false
    }

    // --- Execute the Double Jump ---
    
    // Step 1: To Intermediate
    if (!nativeIssue.status.name.equalsIgnoreCase(intermediateStatusName) && 
        !nativeIssue.status.name.equalsIgnoreCase(finalStatusName)) {
        if (forceTransition(intermediateStatusName)) {
            // Refresh issue object immediately after database commit
            nativeIssue = issueManager.getIssueObject(nativeIssue.id)
        } else {
            return false // Stop if first step fails
        }
    }

    // Step 2: To Final
    if (!nativeIssue.status.name.equalsIgnoreCase(finalStatusName)) {
        return forceTransition(finalStatusName)
    }
    
    return true

} finally {
    // Always restore original context
    authContext.setLoggedInUser(originalUser)
}

}

// — Main Logic —

def remoteStatus = replica.status.name
def localStatus = issue.status.name

if (remoteStatus == “Selected for Development” && localStatus == “To Do”) {

// Execute: To Do -> In Progress -> In Review
boolean success = handleComplexTransition(issue, "In Progress", "In Review")

if (success) {
    issue.setStatus(null) // Prevent Exalate from overwriting the status
} 

} else {
// Standard Behavior
if (remoteStatus == “Selected for Development”) {
issue.setStatus(“In Review”)
} else {
issue.setStatus(remoteStatus)
}
}

Please test this first in a testing environment
In my case what I’ve done is to have the workflow the next way

So every time if it has to go back it will first go to done to then move to the needed one.
of course this is not the most optimal but I hope the idea is there
BR

Tomas