202 lines
6.9 KiB
Groovy
202 lines
6.9 KiB
Groovy
// ScriptRunner for Jira Cloud - Scheduled Job
|
|
// Auto-close issues in "Waiting for Customer" after X days inactivity.
|
|
// 1) JQL search (paging via nextPageToken)
|
|
// 2) If needed set customfield_11433 based on customfield_10039
|
|
// 3) Add comment (ADF) BEFORE closing (so customer gets notification)
|
|
// 4) Transition to "Closed" via fixed transition ID (411) and set Resolution "Keine Lösung" (10010)
|
|
|
|
final String projectNameOrKey = 'Customer Service Desk' // oder Projekt-Key "CSD"
|
|
final String waitingStatusName = 'Waiting for Customer'
|
|
final int inactivityDays = 2
|
|
final int maxPerPage = 50
|
|
final boolean dryRun = false
|
|
|
|
// Transition "Schließen"
|
|
final String transitionIdClose = "411"
|
|
|
|
// Resolution: "Keine Lösung"
|
|
final String resolutionIdNoSolution = "10010"
|
|
|
|
// Custom fields
|
|
final String cfChannel = "customfield_10039" // Single select: "Customer Service" | "Partner Support"
|
|
final String cfFlag = "customfield_11433" // Set to "Ja" or "Nein"
|
|
|
|
// Select values
|
|
final String channelCustomerService = "Customer Service"
|
|
final String channelPartnerSupport = "Partner Support"
|
|
|
|
// Comment template (issue key gets injected per issue)
|
|
final String commentTemplate =
|
|
"""Wir haben Ihre Supportanfrage geschlossen, da wir in den letzten %d Tagen keine Rückmeldung von Ihnen erhalten haben.
|
|
Sollten Sie weiterhin Unterstützung benötigen, erstellen Sie bitte eine neue Supportanfrage und verweisen Sie dabei gern auf die Anfragenummer %s."""
|
|
|
|
final String jql = "project = \"${projectNameOrKey}\" AND status = \"${waitingStatusName}\" AND resolution IS EMPTY AND updated < startOfDay(-${inactivityDays})"
|
|
// ## Test mit einzelticket #####
|
|
//final String jql = "project = \"${projectNameOrKey}\" AND key in (CSD-2124)"
|
|
|
|
Map buildAdfComment(String text) {
|
|
[
|
|
type: "doc",
|
|
version: 1,
|
|
content: [[
|
|
type: "paragraph",
|
|
content: [[type: "text", text: text]]
|
|
]]
|
|
]
|
|
}
|
|
|
|
logger.info("=== Auto-Close Job gestartet ===")
|
|
logger.info("JQL: ${jql}")
|
|
logger.info("Transition ID (Close): ${transitionIdClose}")
|
|
logger.info("Resolution ID (Keine Lösung): ${resolutionIdNoSolution}")
|
|
logger.info("DRY_RUN: ${dryRun}")
|
|
|
|
String nextPageToken = null
|
|
int processed = 0
|
|
int closed = 0
|
|
int skipped = 0
|
|
int failed = 0
|
|
|
|
while (true) {
|
|
|
|
def req = get("/rest/api/3/search/jql")
|
|
.queryString("jql", jql)
|
|
.queryString("maxResults", maxPerPage.toString())
|
|
// Wichtig: cfChannel mitsenden, sonst können wir es nicht auswerten
|
|
.queryString("fields", "status,resolution,updated,${cfChannel}")
|
|
|
|
if (nextPageToken) {
|
|
req = req.queryString("nextPageToken", nextPageToken)
|
|
}
|
|
|
|
def searchResp = req.asObject(Map)
|
|
|
|
if (searchResp.status != 200) {
|
|
logger.error("JQL-Suche fehlgeschlagen: ${searchResp.status} - ${searchResp.body}")
|
|
break
|
|
}
|
|
|
|
def issues = (searchResp.body?.issues ?: []) as List
|
|
nextPageToken = searchResp.body?.nextPageToken as String
|
|
|
|
logger.info("Seite: ${issues.size()} Issues, nextPageToken=${nextPageToken ?: 'none'}")
|
|
if (!issues) break
|
|
|
|
issues.each { i ->
|
|
processed++
|
|
|
|
String issueKey = i?.key
|
|
String statusName = i?.fields?.status?.name
|
|
def resolution = i?.fields?.resolution
|
|
String updated = i?.fields?.updated
|
|
|
|
if (!issueKey) {
|
|
skipped++
|
|
logger.warn("Issue ohne Key übersprungen: ${i}")
|
|
return
|
|
}
|
|
|
|
if (!statusName?.equalsIgnoreCase(waitingStatusName)) {
|
|
skipped++
|
|
logger.info("${issueKey}: Status '${statusName}' != '${waitingStatusName}' -> skip")
|
|
return
|
|
}
|
|
/*
|
|
if (resolution != null) {
|
|
skipped++
|
|
logger.info("${issueKey}: hat bereits Resolution -> skip")
|
|
return
|
|
}
|
|
*/
|
|
logger.info("${issueKey}: Kandidat (updated=${updated})")
|
|
|
|
if (dryRun) {
|
|
logger.info("${issueKey}: DRY_RUN -> würde Feld setzen + kommentieren + schließen")
|
|
return
|
|
}
|
|
|
|
// ========= Erweiterung: customfield_10039 auswerten und customfield_11433 setzen =========
|
|
def channelValueObj = i?.fields?."${cfChannel}" // i.fields.customfield_10039
|
|
String channelValue = channelValueObj?.value?.toString()
|
|
|
|
String flagValue = null
|
|
if (channelValue?.equalsIgnoreCase(channelCustomerService)) {
|
|
flagValue = "Ja"
|
|
} else if (channelValue?.equalsIgnoreCase(channelPartnerSupport)) {
|
|
flagValue = "Nein"
|
|
} else {
|
|
// Wenn leer/unerwartet: nichts setzen, aber loggen
|
|
logger.warn("${issueKey}: ${cfChannel} ist leer oder unerwartet ('${channelValue}'), setze ${cfFlag} nicht.")
|
|
}
|
|
|
|
if (flagValue != null) {
|
|
def updateResp = put("/rest/api/3/issue/${issueKey}")
|
|
.header("Content-Type", "application/json")
|
|
.body([
|
|
fields: [
|
|
(cfFlag): [value: flagValue] // Single select sauber setzen
|
|
]
|
|
])
|
|
.asString()
|
|
|
|
if (updateResp.status == 204) {
|
|
logger.info("${issueKey}: ${cfFlag} gesetzt auf '${flagValue}' (basierend auf ${cfChannel}='${channelValue}')")
|
|
} else {
|
|
failed++
|
|
logger.error("${issueKey}: Setzen von ${cfFlag} fehlgeschlagen: ${updateResp.status} - ${updateResp.body}")
|
|
return
|
|
}
|
|
}
|
|
|
|
|
|
// ========= Kommentar (vor dem Schließen) =========
|
|
String commentText = String.format(commentTemplate, inactivityDays, issueKey)
|
|
|
|
def commentResp = post("/rest/api/3/issue/${issueKey}/comment")
|
|
.header("Content-Type", "application/json")
|
|
.body([body: buildAdfComment(commentText)])
|
|
.asObject(Map)
|
|
|
|
if (!(commentResp.status in [200, 201])) {
|
|
failed++
|
|
logger.error("${issueKey}: Kommentar fehlgeschlagen: ${commentResp.status} - ${commentResp.body}")
|
|
return
|
|
}
|
|
|
|
// ========= Schließen via Transition-ID (ohne Resolution im Payload) =========
|
|
def closeResp = post("/rest/api/3/issue/${issueKey}/transitions")
|
|
.header("Content-Type", "application/json")
|
|
.body([transition: [id: transitionIdClose]])
|
|
.asString()
|
|
|
|
if (closeResp.status != 204) {
|
|
failed++
|
|
logger.error("${issueKey}: Schließen fehlgeschlagen: ${closeResp.status} - ${closeResp.body}")
|
|
return
|
|
}
|
|
|
|
logger.info("${issueKey}: erfolgreich geschlossen (transitionId=${transitionIdClose})")
|
|
|
|
// ========= Resolution nachträglich setzen =========
|
|
def resResp = put("/rest/api/3/issue/${issueKey}")
|
|
.header("Content-Type", "application/json")
|
|
.body([fields: [resolution: [id: resolutionIdNoSolution]]])
|
|
.asString()
|
|
|
|
if (resResp.status == 204) {
|
|
logger.info("${issueKey}: Resolution gesetzt auf '${resolutionIdNoSolution}' (Keine Lösung)")
|
|
} else {
|
|
failed++
|
|
logger.error("${issueKey}: Resolution setzen fehlgeschlagen: ${resResp.status} - ${resResp.body}")
|
|
// Ticket ist schon geschlossen, daher hier NICHT returnen zwingend,
|
|
// aber wir markieren es als failed für die Statistik.
|
|
}
|
|
|
|
}
|
|
|
|
if (!nextPageToken) break
|
|
}
|
|
|
|
logger.info("=== Auto-Close Job fertig ===")
|
|
logger.info("Processed=${processed}, Closed=${closed}, Skipped=${skipped}, Failed=${failed}")
|