/* * ScriptRunner Cloud - Scheduled Job * * Zweck: * - Findet Issues, die seit 48-72h "Resolved" sind und noch ein DueDate haben * - Setzt Kommentar (ADF) VOR dem Schließen (da Closed nicht kommentierbar ist) * - Leert danach DueDate * - Transition nach Zielstatus "Closed" * * Ablauf pro Issue: * 1) Kommentar hinzufügen * 2) DueDate leeren * 3) Transition -> Closed * * Hinweise: * - Transition-ID ist optional; Standard ist Auflösung über Zielstatus (to.name). * - Bei gemischten Workflows ist die Zielstatus-Auflösung meist stabiler als feste IDs. */ // ===================== Konfiguration ===================== final String JQL = 'project = "Customer Service Desk" AND status = Resolved AND duedate IS NOT EMPTY AND resolutiondate >= -72h AND resolutiondate <= -48h' final int MAX_PER_PAGE = 50 // Optional: Wenn du eine Transition-ID erzwingen willst (z.B. "331"), hier setzen. Sonst null lassen. final String TRANSITION_ID = null // Zielstatus-Name (wird bevorzugt genutzt) final String TARGET_STATUS_NAME = "Closed" // Fallback über Aktionsnamen (falls "to.name" nicht hilft) final List ACTION_NAME_FALLBACKS = ["Schließen", "Close", "Closed"] // Kommentartext (als ADF gesendet) final String COMMENT_TEXT = """ Dieses Ticket wurde automatisch geschlossen, nachdem der Support die Lösung präsentiert hat und wir davon ausgehen, dass die Lösung korrekt ist. Sollten weiterhin Fragen bestehen oder erneut Unterstützung benötigt werden, können Sie eine neue Anfrage stellen. Ihr pds Support """.trim() final Map ADF_BODY = [ type : "doc", version: 1, content: [[ type : "paragraph", content: [[ type: "text", text: COMMENT_TEXT ]] ]] ] // ======================================================== // --------------------- Helper --------------------------- /** * Liefert die passende Transition-ID für das Issue. * Priorität: * 1) harte TRANSITION_ID (wenn gesetzt) * 2) Transition, deren Zielstatus to.name == TARGET_STATUS_NAME * 3) Transition, deren Aktionsname in ACTION_NAME_FALLBACKS ist */ def resolveTransitionId = { String issueKey -> if (TRANSITION_ID?.trim()) { return TRANSITION_ID.trim() } def resp = get("/rest/api/3/issue/${issueKey}/transitions").asObject(Map) if (resp.status != 200) { throw new IllegalStateException("Transitions nicht lesbar: HTTP ${resp.status} - ${resp.body}") } List transitions = (resp.body?.transitions as List) ?: [] // 1) nach Ziel-Status (to.name) def byTargetStatus = transitions.find { t -> (t?.to?.name as String)?.equalsIgnoreCase(TARGET_STATUS_NAME) } if (byTargetStatus?.id) return byTargetStatus.id as String // 2) nach Aktionsname (name) def byActionName = transitions.find { t -> ACTION_NAME_FALLBACKS.any { fn -> (t?.name as String)?.equalsIgnoreCase(fn) } } return byActionName?.id as String } /** Kommentar hinzufügen (ADF) */ def addComment = { String issueKey, Map adf -> def resp = post("/rest/api/3/issue/${issueKey}/comment") .header("Content-Type", "application/json") .body([ body: adf ]) .asObject(Map) if (resp.status != 201) { throw new IllegalStateException("Kommentar fehlgeschlagen: HTTP ${resp.status} - ${resp.body}") } } /** DueDate leeren */ def clearDueDate = { String issueKey -> def resp = put("/rest/api/3/issue/${issueKey}") .header("Content-Type", "application/json") .body([ fields: [ duedate: null ] ]) .asString() if (resp.status != 204) { throw new IllegalStateException("DueDate leeren fehlgeschlagen: HTTP ${resp.status} - ${resp.body}") } } /** Transition ausführen */ def doTransition = { String issueKey, String transitionId -> def resp = post("/rest/api/3/issue/${issueKey}/transitions") .header("Content-Type", "application/json") .body([ transition: [ id: transitionId ] ]) .asString() if (resp.status != 204) { throw new IllegalStateException("Transition fehlgeschlagen (id=${transitionId}): HTTP ${resp.status} - ${resp.body}") } } /** Kleiner Helper fürs Logging, damit Logs konsistent sind */ def logOk = { String issueKey, String msg -> logger.info("[AUTO-CLOSE] ${issueKey}: ${msg}") } def logWarn = { String issueKey, String msg -> logger.warn("[AUTO-CLOSE] ${issueKey}: ${msg}") } def logErr = { String issueKey, String msg -> logger.error("[AUTO-CLOSE] ${issueKey}: ${msg}") } // --------------------- Verarbeitung ---------------------- int processed = 0 int skipped = 0 int failed = 0 String nextPageToken = null boolean isLast = false logger.info("[AUTO-CLOSE] Job Start. JQL='${JQL}'") while (!isLast) { def req = get("/rest/api/3/search/jql") .queryString("jql", JQL) .queryString("fields", "status,duedate") // key ist immer dabei .queryString("maxResults", MAX_PER_PAGE as String) if (nextPageToken) { req = req.queryString("nextPageToken", nextPageToken) } def searchResp = req.asObject(Map) if (searchResp.status != 200) { throw new IllegalStateException("Search-Fehler: HTTP ${searchResp.status} - ${searchResp.body}") } Map body = searchResp.body as Map List issues = (body?.issues as List) ?: [] isLast = (body?.isLast == true) nextPageToken = body?.nextPageToken as String logger.info("[AUTO-CLOSE] Seite geladen: issues=${issues.size()}, isLast=${isLast}, nextPageToken=${nextPageToken}") issues.each { Map iss -> String key = iss["key"] as String try { // 1) Kommentar VOR dem Schließen addComment(key, ADF_BODY) logOk(key, "Kommentar gesetzt.") // 2) DueDate leeren clearDueDate(key) logOk(key, "DueDate geleert.") // 3) Transition -> Closed String transitionId = resolveTransitionId(key) if (!transitionId) { skipped++ logWarn(key, "Keine passende Transition nach '${TARGET_STATUS_NAME}' gefunden (Fallbacks=${ACTION_NAME_FALLBACKS}). Ticket bleibt Resolved.") return } doTransition(key, transitionId) logOk(key, "Transition ausgeführt (id=${transitionId}) -> '${TARGET_STATUS_NAME}'.") processed++ } catch (Throwable t) { failed++ logErr(key, "Fehler: ${t.class.simpleName}: ${t.message}") } } } logger.info("[AUTO-CLOSE] Job Ende. processed=${processed}, skipped=${skipped}, failed=${failed} (JQL='${JQL}')")