Jira-Scripte/Scheduled Jobs/[CS] Schließe Tickets ohne Rückmeldung des Kunden.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}")