1. Use case
A client has configured their Jira Cloud workflow so that a transition (e.g. Resolve / move to Done) shows a transition screen with a mandatory Comment field. A user cannot complete the transition in the Jira UI without typing a comment.
When Exalate synchronizes the status from the source side, we want the target Jira issue to:
- move through that same transition, and
- supply the mandatory comment in the same operation, so Jira accepts the transition.
Concrete example
- Source issue moves to Done.
- Target Jira has a Resolve transition whose screen requires a comment.
- Exalate must transition the target issue to Done and fill the mandatory comment automatically (e.g. with a fixed text, or with the last comment coming from the source).
2. Why the normal approach does not work
The standard Exalate status-sync line:
issue.setStatus("Done") // ❌ fails when the transition screen has a mandatory comment
drives the workflow transition but does not pass any data through the transition screen. Jira’s “comment is required” field validator on that screen therefore rejects the transition, and the sync errors out.
Key insight: A mandatory field on a transition screen must be provided during the transition API call. It cannot be set before or after the transition.
3. Solution
Call Jira’s transition REST API directly from the incoming script and provide the comment in the same payload via update.comment.add.body.
POST /rest/api/2/issue/{issueKey}/transitions
{
"transition": { "id": "<transitionId>" },
"update": {
"comment": [
{ "add": { "body": "<mandatory comment text>" } }
]
}
}
transition.id— the id of the transition that is valid from the issue’s current status.update.comment.add.body— the mandatory comment. On API v2 this is a plain string; on API v3 it must be ADF (see §6).
4. Reusable incoming script (Jira Cloud target)
if (firstSync) {
issue.projectKey = "TARGET"
issue.typeName = nodeHelper.getIssueType(replica.type?.name, issue.projectKey)?.name ?: "Task"
}
issue.summary = replica.summary
issue.description = replica.description
issue.attachments = attachmentHelper.mergeAttachments(issue, replica)
issue.labels = replica.labels
// Fire the transition only when the SOURCE has moved into the resolving state
// and the TARGET is not already there.
if (!firstSync && replica.status.name == "Done" && issue.status.name != "Done")
{
store(issue) // ensure issue.key exists before calling the REST API
def transitionId = "21" // <-- the "Resolve" transition id (see §5 on how to find it)
def mandatoryComment = "Resolved via Exalate. Source comment: " +
(replica.comments ? replica.comments.last().body : "n/a")
def payload = groovy.json.JsonOutput.toJson([
transition: [ id: transitionId ],
update : [ comment: [ [ add: [ body: mandatoryComment ] ] ] ]
])
httpClient.http("POST", "/rest/api/2/issue/${issue.key}/transitions",
payload, null, ["Content-Type":["application/json"]]) { req, res ->
if (res.code >= 300) {
debug.error("Transition failed (${res.code}): ${res.body}")
} else {
// Optional: trace the comment so Exalate does NOT echo it back as a new comment
store(issue)
def localCommentId = issue.comments ? issue.comments.last().id : null
if (localCommentId) {
def trace = new com.exalate.basic.domain.BasicNonPersistentTrace()
.setType(com.exalate.api.domain.twintrace.TraceType.COMMENT)
.setToSynchronize(false)
.setLocalId(localCommentId as String)
.setRemoteId(null)
.setAction(com.exalate.api.domain.twintrace.TraceAction.NONE)
traces.add(trace)
}
}
}
} else {
// Normal path when no mandatory-comment transition is involved
issue.comments = commentHelper.mergeComments(issue, replica)
issue.setStatus(replica.status.name)
}
5. How to find the transition id
The transition id is not the status id, and only transitions valid from the issue’s current status are returned.
Option A — REST:
GET /rest/api/3/issue/{issueKey}/transitions
Returns each available transition with its id and name. Pick the id of the transition that leads to the resolving status.
Option B — from the script :
import scala.compat.java8.FutureConverters
def transitionsFuture = FutureConverters.toJava(nodeHelper.jCloudClient.getTransitions(issue.id.toLong()))
def transitions = transitionsFuture.get()
transitions.foreach { t -> debug.error("Transition: ${t}") }
6. API v2 vs v3 — comment body format
| Endpoint | Comment body format | Notes |
|---|---|---|
/rest/api/2/issue/{key}/transitions |
Plain string (wiki markup) | Simplest. Used by the proven id=108 script. Recommended. |
/rest/api/3/issue/{key}/transitions |
ADF (Atlassian Document Format) | Body must be { "type":"doc", "version":1, "content":[ ... ] }. Use only if you specifically need v3. |
ADF example (v3):
def payloadV3 = groovy.json.JsonOutput.toJson([
transition: [ id: transitionId ],
update : [ comment: [ [ add: [ body: [
type: "doc", version: 1,
content: [ [ type: "paragraph", content: [ [ type: "text", text: mandatoryComment ] ] ] ]
] ] ] ] ]
])
7. Variation — mandatory field is NOT the built-in comment
If the transition screen requires a custom field (e.g. a “Resolution notes” text field or the resolution field) instead of the built-in comment, put it under fields rather than update.comment:
def payload = groovy.json.JsonOutput.toJson([
transition: [ id: transitionId ],
update : [ comment: [ [ add: [ body: mandatoryComment ] ] ] ], // keep if comment also required
fields : [
"customfield_12345": "required value",
resolution : [ name: "Done" ]
]
])
Note: Permissions: the Exalate Jira Cloud connection user must have permission to perform the transition and add comments.