initialer upload

This commit is contained in:
Martin 2025-12-14 19:17:45 +01:00
parent c776ee7b40
commit 59bc58afb7
15 changed files with 1479 additions and 0 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,256 @@
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
logger.info("geht los ..")
// ============= KONFIG =============
// Lauf
boolean DRY_RUN = true
int PAGE_SIZE = 50
int CF_LIMIT = -1 // -1=alle, 10/100/... = erste n
int SAMPLE_LIMIT = 10 // wie viele Kandidaten exemplarisch loggen
// Behandlung von "(migrated)"-Feldern im Namen (case-insensitive):
// "off" = keine Sonderbehandlung
// "strict" = löschen nur wenn (migrated) UND ungenutzt
// "plus" = löschen wenn ungenutzt ODER Name enthält (migrated)
// "only" = NUR (migrated)-Felder löschen (Nutzung egal)
String MIGRATED_FILTER = "plus"
// Output-Drosselung // wie viele Kandidaten exemplarisch loggen
boolean PRINT_JSON = false // JSON-Dump AUS (Payload sparen)
boolean PRINT_CSV = false // CSV-Dump AUS
int CSV_MAX_CHARS = 20000 // harte Kappung
// Whitelist
Set<String> EXCLUDE_BY_NAME = [] as Set
Set<String> EXCLUDE_BY_ID = [] as Set
// ============= Logging =============
void logInfo(String m){ try{ logger.info(m) }catch(e){ println m } }
void logWarn(String m){ try{ logger.warn(m) }catch(e){ println "WARN: " + m } }
void logErr (String m){ try{ logger.error(m)}catch(e){ println "ERR: " + m } }
// ============= Utils =============
boolean isMigratedName(String name){ name?.toLowerCase()?.contains("migrated") ?: false }
// Ruhiger HTTP-Getter: asString() + Slurper (unterdrückt 'Serializing object...' Logs)
Map getAsMap(String path, Map q=[:]) {
def req = get(path)
q.each{ k,v -> req = req.queryString(k, v) }
def resp = req.asString()
if (!(resp.status in [200])) {
throw new RuntimeException("GET ${path} failed: HTTP ${resp.status} :: ${resp.body}")
}
def text = (resp.body ?: "")
if (!text) return [:]
return (new JsonSlurper().parseText(text) ?: [:]) as Map
}
List<Map> pagedGetValues(String path, int pageSize=50) {
int startAt = 0; List<Map> all = []
while (true) {
Map body = getAsMap(path, [startAt:startAt, maxResults:pageSize])
List vals = (body.values ?: []) as List
all.addAll(vals as List<Map>)
int total = (body.total ?: (startAt + vals.size())) as int
int nextStart = startAt + ((body.maxResults ?: vals.size()) as int)
if (vals.isEmpty() || nextStart >= total) break
startAt = nextStart
}
all
}
// ============= Fetch: Fields & Contexts =============
List<Map> fetchCustomFields(int pageSize){
logInfo("Lade Custom Fields…")
List<Map> list = pagedGetValues("/rest/api/3/field/search?type=custom", pageSize)
logInfo("Custom Fields gefunden: " + list.size())
return list
}
Map fetchFieldContexts(String fieldId, int pageSize){
Map ctx = getAsMap("/rest/api/3/field/${fieldId}/contexts", [startAt:0, maxResults:pageSize])
return [contexts: ctx]
}
boolean hasAnyProjectAssociation(Map ctxBundle){
Map ctx = (ctxBundle.contexts ?: [:]) as Map
List<Map> values = (ctx.values ?: []) as List
if (values.isEmpty()) return false
for (Map c : values){
Map scope = (c.scope ?: [:]) as Map
String t = (scope.type ?: "").toString().toUpperCase()
if (t == "GLOBAL") return true
if (t == "PROJECT") {
List pids = (scope.projectIds ?: []) as List
if (pids != null && !pids.isEmpty()) return true
}
}
return false
}
// ============= Fetch: Screens on demand =============
List<Map> fetchScreens(int pageSize){
logInfo("Lade Screens…")
List<Map> list = pagedGetValues("/rest/api/3/screens", pageSize)
logInfo("Screens gefunden: " + list.size())
return list
}
List<Map> fetchScreenTabs(Long screenId){
Map body = getAsMap("/rest/api/3/screens/${screenId}/tabs", [startAt:0, maxResults:50])
return (body.values ?: []) as List<Map>
}
List<Map> fetchScreenTabFields(Long screenId, Long tabId){
Map body = getAsMap("/rest/api/3/screens/${screenId}/tabs/${tabId}/fields", [startAt:0, maxResults:200])
return (body.values ?: []) as List<Map>
}
Map<String,Integer> scanScreensFor(Set<String> fieldIds, int pageSize){
Map<String,Integer> useCount = [:].withDefault{0}
if (fieldIds.isEmpty()) return useCount
def screens = fetchScreens(pageSize)
for (Map s : screens){
Long sid = (s.id == null ? null : Long.valueOf(s.id.toString()))
if (sid == null) continue
try{
for (Map t : fetchScreenTabs(sid)){
Long tid = (t.id == null ? null : Long.valueOf(t.id.toString()))
if (tid == null) continue
for (Map sf : fetchScreenTabFields(sid, tid)){
String fid = (sf.id ?: "").toString()
if (fieldIds.contains(fid)) useCount[fid] = useCount[fid] + 1
}
}
} catch (Exception ex){
logWarn("Screenscan Fehler bei Screen ${sid}: " + ex.message)
}
}
return useCount
}
// ============= Delete =============
boolean deleteCustomField(String fieldId, String name){
def resp = delete("/rest/api/3/field/${fieldId}").asString()
if (resp.status in [200,204]) { logInfo("Gelöscht: [${fieldId}] ${name}"); return true }
logWarn("Nicht gelöscht [${fieldId}] ${name} :: HTTP ${resp.status} :: ${resp.body}")
return false
}
// ============= Output helpers =============
void printSummary(List<Map> candidates, int totalChecked, String policy){
int migrated = candidates.count{ it.migrated == true }
int unused = candidates.count{ (it.onScreens == 0) && (it.hasProjectAssoc == false) }
logWarn("Kandidaten: ${candidates.size()} (Policy=${policy}, geprüft=${totalChecked}) | migrated=${migrated}, unused=${unused}")
}
void printSample(List<Map> candidates, int limit){
int n = Math.min(limit, candidates.size())
if (n == 0) return
logWarn("Beispiele (${n}/${candidates.size()}):")
for (int i=0;i<n;i++){
def c = candidates[i]
String flags = (c.migrated ? "migrated" : "") + (((c.onScreens==0)&&!c.hasProjectAssoc) ? (c.migrated ? ", unused" : "unused") : "")
logWarn(" - [${c.id}] ${c.name}" + (flags ? " ["+flags+"]" : ""))
}
}
String toCSV(List<Map> items){
String header = "id;name;type;migrated;onScreens;hasProjectAssoc\n"
StringBuilder sb = new StringBuilder(header)
items.each{ c ->
sb.append("${c.id};\"${(c.name as String).replaceAll('\"','\"\"')}\";${c.type};${c.migrated};${c.onScreens};${c.hasProjectAssoc}\n")
}
return sb.toString()
}
// ============= MAIN =============
void runCustomFieldCleanup(boolean dryRun, int pageSize, int cfLimit, String migratedPolicy,
Set<String> excludeByName, Set<String> excludeById){
// 1) Felder (limitierbar)
List<Map> allFields = fetchCustomFields(pageSize)
List<Map> customFields = allFields.findAll{ (it.id ?: "").toString().startsWith("customfield_") }
if (cfLimit > -1 && customFields.size() > cfLimit) {
customFields = customFields.subList(0, cfLimit)
logInfo("Begrenze Prüfung auf " + cfLimit + " Custom Fields.")
}
int totalChecked = customFields.size()
// 2) Kontexte nur Kandidaten ohne Projekt-Scope weiter prüfen
Map<String,Boolean> hasProjAssoc = [:].withDefault{false}
List<Map> noScope = []
for (Map f : customFields){
String fid = (f.id ?: "").toString()
if (!fid) continue
try {
Map bundle = fetchFieldContexts(fid, pageSize)
boolean assoc = hasAnyProjectAssociation(bundle)
hasProjAssoc[fid] = assoc
if (!assoc) noScope << f
} catch (Exception ex){
hasProjAssoc[fid] = true
logWarn("Kontexte für Feld ${fid} nicht vollständig lesbar: " + ex.message)
}
}
// 3) Nur für noScope die Screen-Verwendung prüfen
Set<String> forScreenScan = noScope.collect{ (it.id ?: "").toString() } as Set<String>
Map<String,Integer> useOnScreens = scanScreensFor(forScreenScan, pageSize)
// 4) Kandidaten bestimmen (MIGRATED_POLICY)
List<Map> candidates = []
for (Map f : customFields){
String fid = (f.id ?: "").toString()
String fname = (f.name ?: "").toString()
if (excludeById.contains(fid) || excludeByName.contains(fname)) continue
boolean migrated = isMigratedName(fname)
int onScreens = useOnScreens.containsKey(fid) ? useOnScreens[fid] : 0
boolean assoc = hasProjAssoc[fid]
boolean unused = (onScreens == 0 && !assoc)
boolean pick
switch (migratedPolicy?.toLowerCase()){
case "off": pick = unused; break
case "strict": pick = migrated && unused; break
case "plus": pick = unused || migrated; break
case "only": pick = migrated; break
default: pick = unused
}
if (pick){
candidates << [id: fid, name: fname, type: (f.schema?.type ?: f.type ?: "custom"),
migrated: migrated, onScreens: onScreens, hasProjectAssoc: assoc]
}
}
candidates.sort{ a,b -> a.name <=> b.name }
printSummary(candidates, totalChecked, migratedPolicy)
//printSample(candidates, SAMPLE_LIMIT)
if (PRINT_JSON) {
def json = JsonOutput.prettyPrint(JsonOutput.toJson(candidates))
logInfo("JSON (gekürzt) Länge=" + json.length())
logInfo(json.substring(0, Math.min(json.length(), 20000)))
}
if (PRINT_CSV) {
String csv = toCSV(candidates)
if (csv.length() > CSV_MAX_CHARS) {
logInfo("CSV gekappt von ${csv.length()} auf ${CSV_MAX_CHARS} Zeichen.")
csv = csv.substring(0, CSV_MAX_CHARS)
}
logInfo("CSV (gekürzt):\n" + csv)
}
if (dryRun) { logInfo("Dry-Run aktiv → nichts gelöscht."); return }
int deleted=0, skipped=0
for (Map c : candidates){
try {
if (deleteCustomField(c.id.toString(), c.name.toString())) deleted++ else skipped++
} catch (Exception ex){
skipped++; logErr("Fehler beim Löschen [${c.id}] ${c.name} :: " + ex.message)
}
}
logWarn("Fertig. Ergebnis: deleted=${deleted}, skipped=${skipped}")
}
// ---- Start ----
runCustomFieldCleanup(DRY_RUN, PAGE_SIZE, CF_LIMIT, MIGRATED_FILTER, EXCLUDE_BY_NAME, EXCLUDE_BY_ID)

View File

@ -0,0 +1,115 @@
import groovy.json.JsonOutput
// ------------------ Konfig ------------------
boolean DRY_RUN = true
int PAGE_SIZE = 50
Set<String> EXCLUDE_BY_NAME = [] as Set // optional Namen schützen
// ------------------ Logging -----------------
void logInfo(String m){ try{ logger.info(m) }catch(e){ println m } }
void logWarn(String m){ try{ logger.warn(m) }catch(e){ println "WARN: " + m } }
void logErr (String m){ try{ logger.error(m)}catch(e){ println "ERR: " + m } }
// ------------------ HTTP Helpers -----------
Map getAsMap(String path, Map q=[:]) {
def req = get(path)
q.each{ k,v -> req = req.queryString(k, v) }
def r = req.asObject(Map)
if (r.status != 200) throw new RuntimeException("GET " + path + " failed: HTTP " + r.status + " :: " + r.body)
(r.body ?: [:]) as Map
}
List<Map> pagedGetValues(String path, int pageSize=50) {
int startAt = 0; List<Map> all = []
while (true) {
Map body = getAsMap(path, [startAt:startAt, maxResults:pageSize])
List vals = (body.values ?: []) as List
all.addAll(vals as List<Map>)
int total = (body.total ?: (startAt + vals.size())) as int
int nextStart = startAt + ((body.maxResults ?: vals.size()) as int)
if (vals.isEmpty() || nextStart >= total) break
startAt = nextStart
}
all
}
// ------------------ Fetchers ----------------
List<Map> fetchITSS(int pageSize){
logInfo("Lade Issue Type Screen Schemes…")
def list = pagedGetValues("/rest/api/3/issuetypescreenscheme", pageSize)
logInfo("ITSS gefunden: " + list.size())
list
}
List<Map> fetchProjects(int pageSize){
logInfo("Lade Projekte…")
def list = pagedGetValues("/rest/api/3/project/search", pageSize)
logInfo("Projekte gefunden: " + list.size())
list
}
Long fetchITSSForProject(Long projectId){
// Liefert die ITSS-ID, die einem Projekt zugewiesen ist
def m = getAsMap("/rest/api/3/project/${projectId}/issuetypescreenscheme")
def id = m.get("issueTypeScreenSchemeId")
return (id == null ? null : Long.valueOf(id.toString()))
}
// ------------------ Delete ------------------
boolean deleteITSS(long id, String name){
def resp = delete("/rest/api/3/issuetypescreenscheme/${id}").asString()
if (resp.status in [200,204]) { logInfo("Gelöscht: [${id}] ${name}"); return true }
logWarn("Nicht gelöscht [${id}] ${name} :: HTTP ${resp.status} :: ${resp.body}")
false
}
// ------------------ Main --------------------
void runITSSCleanup(boolean dryRun, int pageSize, Set<String> excludeByName){
def itss = fetchITSS(pageSize)
if (itss.isEmpty()){ logInfo("Keine ITSS vorhanden nichts zu tun."); return }
Map<Long,Map> itssById = [:]
itss.each{ Map x -> if (x.id!=null) itssById[Long.valueOf(x.id.toString())] = x }
def projects = fetchProjects(pageSize)
Set<Long> referenced = new LinkedHashSet<>()
projects.each{ Map p ->
def pid = p.get("id"); if (pid==null) return
try {
Long ref = fetchITSSForProject(Long.valueOf(pid.toString()))
if (ref!=null) referenced << ref
} catch (Exception ex) {
logWarn("ITSS-Mapping für Projekt ${p.key ?: pid} nicht lesbar: " + ex.message)
}
}
logInfo("Referenzierte ITSS gesamt: " + referenced.size())
List<Map> candidates = []
itssById.each{ Long id, Map row ->
String name = (row.name ?: "") as String
if (!referenced.contains(id) && !excludeByName.contains(name)){
candidates << [id:id, name:name, description:(row.description ?: "")]
}
}
candidates.sort{ a,b -> a.name <=> b.name }
if (candidates.isEmpty()){ logInfo("Keine ungenutzten ITSS gefunden. ✅"); return }
logWarn("Ungenutzte ITSS (${candidates.size()}):")
candidates.each{ c -> logWarn(" - [${c.id}] ${c.name}") }
if (dryRun){
logInfo("Dry-Run aktiv → nichts gelöscht.")
logInfo("JSON:\n" + JsonOutput.prettyPrint(JsonOutput.toJson(candidates)))
return
}
int deleted=0, skipped=0
candidates.each{ c ->
try {
if (deleteITSS((c.id as Long), c.name.toString())) deleted++ else skipped++
} catch (Exception ex){
skipped++; logErr("Fehler beim Löschen [${c.id}] ${c.name} :: " + ex.message)
}
}
logWarn("Fertig. Ergebnis: deleted=${deleted}, skipped=${skipped}")
}
// ---- Start ----
runITSSCleanup(DRY_RUN, PAGE_SIZE, EXCLUDE_BY_NAME)

View File

@ -0,0 +1,224 @@
// -----------------------------------------------------------------------------
// Housekeeping: Inaktive Workflow Schemes (ohne Projektzuordnung) löschen
// Jira Cloud - ScriptRunner Console
// -----------------------------------------------------------------------------
import groovy.json.JsonOutput
// --- Konfiguration -----------------------------------------------------------
// false -> inaktive Workflow Schemes (ohne EXCLUDED_IDS) werden GELÖSCHT
// true -> nur Testlauf, es wird NICHT gelöscht
final boolean DRY_RUN = true
// Workflow-Scheme-IDs, die NIEMALS gelöscht werden sollen
// (z.B. Default-System-Schema; ID bitte ggf. anpassen/ergänzen)
final Set<Long> EXCLUDED_IDS = [10000L] as Set
// --- Schritt 1: Alle Workflow Schemes laden (paginiert) ---------------------
List<Map> allSchemes = []
int startAt = 0
int maxResults = 50
boolean finished = false
while (!finished) {
def resp = get("/rest/api/3/workflowscheme?startAt=${startAt}&maxResults=${maxResults}")
.asObject(Map)
if (resp.status != 200) {
logger.error("Konnte Workflow Schemes nicht laden (startAt=${startAt}): ${resp.status} - ${resp.body}")
return "Fehler beim Laden der Workflow Schemes. Siehe Log."
}
def body = resp.body ?: [:]
def values = (body.values ?: []) as List<Map>
allSchemes.addAll(values)
boolean isLast = (body.isLast == true)
int total = (body.total ?: (startAt + values.size())) as int
logger.info "Workflow Schemes geladen: ${allSchemes.size()} (total ~ ${total}), isLast=${isLast}"
if (isLast || values.isEmpty()) {
finished = true
} else {
startAt += maxResults
if (startAt >= total) {
finished = true
}
}
}
logger.info "Anzahl Workflow Schemes insgesamt: ${allSchemes.size()}"
// Map: schemeId -> [scheme: <Objekt>, used: boolean, projects: Set<projectKey>]
def schemeUsage = allSchemes.collectEntries { scheme ->
Long id = (scheme.id as Long)
[
(id): [
scheme : scheme,
used : false,
projects: [] as Set<String>
]
]
}
// --- Schritt 2: Mappings Workflow Scheme <-> Projekte laden -----------------
//
// GET /rest/api/3/workflowscheme/project
// liefert PageBean mit values[ { workflowSchemeId, projectId, projectKey, ... } ]
List<Map> allMappings = []
startAt = 0
finished = false
while (!finished) {
def resp = get("/rest/api/3/workflowscheme/project?startAt=${startAt}&maxResults=${maxResults}")
.asObject(Map)
if (resp.status != 200) {
logger.error("Konnte Workflow-Scheme-Mappings nicht laden (startAt=${startAt}): ${resp.status} - ${resp.body}")
break
}
def body = resp.body ?: [:]
def values = (body.values ?: []) as List<Map>
allMappings.addAll(values)
boolean isLast = (body.isLast == true)
int total = (body.total ?: (startAt + values.size())) as int
logger.info "Workflow-Scheme-Mappings geladen: ${allMappings.size()} (total ~ ${total}), isLast=${isLast}"
if (isLast || values.isEmpty()) {
finished = true
} else {
startAt += maxResults
if (startAt >= total) {
finished = true
}
}
}
// Mappings in schemeUsage eintragen
allMappings.each { m ->
Long schemeId = (m.workflowSchemeId as Long)
String projKey = m.projectKey?.toString()
def entry = schemeUsage[schemeId]
if (entry) {
entry.used = true
if (projKey) {
entry.projects << projKey
}
} else {
logger.warn "Mapping gefunden für Workflow Scheme ID=${schemeId}, das nicht in allSchemes war. Projekt=${projKey}"
}
}
def projectsWithWorkflowScheme = allMappings.collect { it.projectKey }.findAll { it }.toSet()
logger.info "Anzahl Projekte mit Workflow Scheme: ${projectsWithWorkflowScheme.size()}"
// --- Schritt 3: Inaktive (unbenutzte & nicht ausgeschlossene) Schemes -------
def inactive = schemeUsage.values()
.findAll { entry ->
Long id = (entry.scheme.id as Long)
!entry.used && !EXCLUDED_IDS.contains(id)
}
.sort { it.scheme.name?.toString()?.toLowerCase() }
logger.info "Ausgeschlossene Workflow-Scheme-IDs : ${EXCLUDED_IDS.join(', ')}"
logger.info "Inaktive Workflow Schemes (Kandidaten): ${inactive.size()}"
// --- Schritt 4: Optional löschen --------------------------------------------
List<Map> deleted = []
List<Map> failed = []
if (!DRY_RUN) {
inactive.each { entry ->
def s = entry.scheme
Long id = (s.id as Long)
logger.info "Lösche Workflow Scheme ID=${id}, Name=\"${s.name}\" ..."
def delResp = delete("/rest/api/3/workflowscheme/${id}")
.asString()
if (delResp.status in [200, 204]) {
logger.info "Erfolgreich gelöscht: ID=${id}, Name=\"${s.name}\""
deleted << [
id : id,
name : s.name,
description: s.description
]
} else {
logger.warn "Löschen fehlgeschlagen für ID=${id}, Name=\"${s.name}\": Status=${delResp.status}, Body=${delResp.body}"
failed << [
id : id,
name : s.name,
status : delResp.status,
body : delResp.body
]
}
}
} else {
logger.info "DRY_RUN = true -> Es wird NICHT gelöscht, nur Kandidaten ermittelt."
}
// --- Schritt 5: Zusammenfassung ---------------------------------------------
def lines = []
lines << "=== Workflow Schemes Housekeeping ==="
lines << "DRY_RUN : ${DRY_RUN}"
lines << "Gesamt Workflow Schemes : ${allSchemes.size()}"
lines << "Projekte mit Scheme-Mapping : ${projectsWithWorkflowScheme.size()}"
lines << "Ausgeschlossene IDs : ${EXCLUDED_IDS.join(', ')}"
lines << "Inaktive Kandidaten : ${inactive.size()}"
if (!DRY_RUN) {
lines << "Gelöscht : ${deleted.size()}"
lines << "Fehlgeschlagen : ${failed.size()}"
}
lines << ""
lines << "Inaktive (unbenutzte) Schemes, exkl. EXCLUDED_IDS:"
inactive.each { entry ->
def s = entry.scheme
lines << String.format(
"- ID=%s | Name=\"%s\" | Beschreibung=\"%s\" | Projekte=%s",
s.id,
s.name ?: "",
(s.description ?: "").replaceAll('\\s+', ' ').trim(),
entry.projects ?: []
)
}
def result = [
summary : [
dryRun : DRY_RUN,
totalWorkflowSchemes : allSchemes.size(),
projectsWithMapping : projectsWithWorkflowScheme.size(),
excludedIDs : EXCLUDED_IDS,
inactiveCandidates : inactive.size(),
deleted : deleted.size(),
failed : failed.size()
],
inactiveWorkflowSchemes: inactive.collect { e ->
def s = e.scheme
[
id : s.id,
name : s.name,
description : s.description,
projectsUsing: e.projects
]
},
deletedWorkflowSchemes: deleted,
failedDeletions : failed
]
logger.info lines.join("\n")
return lines.join("\n") + "\n\nJSON:\n" + JsonOutput.prettyPrint(JsonOutput.toJson(result))

View File

@ -0,0 +1,227 @@
// -----------------------------------------------------------------------------
// Housekeeping: Unbenutzte Notification Schemes finden (projektbasiert)
// Jira Cloud - ScriptRunner Console
// -----------------------------------------------------------------------------
import groovy.json.JsonOutput
// --- Konfiguration -----------------------------------------------------------
// true -> nur Testlauf, es wird NICHT gelöscht
// false -> unbenutzte Notification Schemes (ohne EXCLUDED_IDS) würden gelöscht
// (Löschlogik ist unten schon vorbereitet, aber per Default aus)
final boolean DRY_RUN = true
// Notification-Scheme-IDs, die auf keinen Fall gelöscht werden sollen
// (z.B. Standardschemata / System-Schemata)
final Set<Long> EXCLUDED_IDS = [10000L] as Set
// --- Schritt 1: Alle Notification Schemes laden (paginierte API) ------------
List<Map> allSchemes = []
int startAt = 0
int maxResults = 50
boolean finished = false
while (!finished) {
def resp = get("/rest/api/3/notificationscheme?startAt=${startAt}&maxResults=${maxResults}")
.asObject(Map)
if (resp.status != 200) {
logger.error("Konnte Notification Schemes nicht laden (startAt=${startAt}): ${resp.status} - ${resp.body}")
return "Fehler beim Laden der Notification Schemes. Siehe Log."
}
def body = resp.body ?: [:]
def values = (body.values ?: []) as List<Map>
allSchemes.addAll(values)
boolean isLast = (body.isLast == true)
int total = (body.total ?: (startAt + values.size())) as int
logger.info "Notification Schemes geladen: ${allSchemes.size()} (total ~ ${total}), isLast=${isLast}"
if (isLast || values.isEmpty()) {
finished = true
} else {
startAt += maxResults
if (startAt >= total) {
finished = true
}
}
}
logger.info "Anzahl Notification Schemes insgesamt: ${allSchemes.size()}"
// Map: schemeId -> [scheme: <Objekt>, used: boolean, projects: Set<projectKey>]
def schemeUsage = allSchemes.collectEntries { scheme ->
Long id = (scheme.id as Long)
[
(id): [
scheme : scheme,
used : false,
projects: [] as Set<String>
]
]
}
// --- Schritt 2: Alle Projekte laden und pro Projekt das Notification Scheme holen ---
int totalProjects = 0
startAt = 0
finished = false
while (!finished) {
def projResp = get("/rest/api/3/project/search?startAt=${startAt}&maxResults=${maxResults}")
.asObject(Map)
if (projResp.status != 200) {
logger.error("Konnte Projekte nicht laden (startAt=${startAt}): ${projResp.status} - ${projResp.body}")
break
}
def body = projResp.body ?: [:]
def projects = (body.values ?: []) as List<Map>
totalProjects += projects.size()
logger.info "Verarbeite Projekte ${startAt} bis ${startAt + projects.size() - 1} ..."
projects.each { proj ->
String projectKey = proj.key
String projectId = proj.id?.toString()
// Für jedes Projekt das zugeordnete Notification Scheme holen
def notifResp = get("/rest/api/3/project/${projectId}/notificationscheme")
.asObject(Map)
if (notifResp.status == 200) {
def schemeId = notifResp.body?.id
if (schemeId) {
Long idLong = (schemeId as Long)
def entry = schemeUsage[idLong]
if (entry) {
entry.used = true
entry.projects << projectKey
} else {
// Projekt nutzt ein Scheme, das nicht in der Liste war (sollte selten sein)
logger.warn "Projekt ${projectKey} nutzt Notification Scheme ID=${schemeId}, das nicht in der globalen Liste war."
}
}
} else if (notifResp.status == 404) {
// z.B. Team-managed-Projekte, die kein klassisches Notification Scheme haben
logger.debug "Projekt ${projectKey} hat kein klassisches Notification Scheme (404)."
} else {
logger.warn "Konnte Notification Scheme für Projekt ${projectKey} nicht laden: ${notifResp.status} - ${notifResp.body}"
}
}
int total = (body.total ?: totalProjects) as int
startAt += maxResults
if (startAt >= total) {
finished = true
}
}
// --- Schritt 3: Unbenutzte (und nicht ausgeschlossene) Schemes bestimmen -----
def unused = schemeUsage.values()
.findAll { entry ->
Long id = (entry.scheme.id as Long)
!entry.used && !EXCLUDED_IDS.contains(id)
}
.sort { it.scheme.name?.toString()?.toLowerCase() }
logger.info "Projekte insgesamt : ${totalProjects}"
logger.info "Ausgeschlossene Notification-IDs : ${EXCLUDED_IDS.join(', ')}"
logger.info "Unbenutzte Notification Schemes : ${unused.size()}"
// --- Schritt 4: Optional löschen (aktuell noch durch DRY_RUN geschützt) -----
List<Map> deleted = []
List<Map> failed = []
if (!DRY_RUN) {
unused.each { entry ->
def s = entry.scheme
Long id = (s.id as Long)
logger.info "Lösche Notification Scheme ID=${id}, Name=\"${s.name}\" ..."
def delResp = delete("/rest/api/3/notificationscheme/${id}")
.asString()
if (delResp.status in [200, 204]) {
logger.info "Erfolgreich gelöscht: ID=${id}, Name=\"${s.name}\""
deleted << [
id : id,
name : s.name,
description: s.description
]
} else {
logger.warn "Löschen fehlgeschlagen für ID=${id}, Name=\"${s.name}\": Status=${delResp.status}, Body=${delResp.body}"
failed << [
id : id,
name : s.name,
status : delResp.status,
body : delResp.body
]
}
}
} else {
logger.info "DRY_RUN = true -> Es wird NICHT gelöscht, nur Kandidaten ermittelt."
}
// --- Schritt 5: Zusammenfassung ---------------------------------------------
def lines = []
lines << "=== Notification Schemes Housekeeping (projektbasiert) ==="
lines << "DRY_RUN : ${DRY_RUN}"
lines << "Gesamt Notification Schemes : ${allSchemes.size()}"
lines << "Gesamt Projekte : ${totalProjects}"
lines << "Ausgeschlossene IDs : ${EXCLUDED_IDS.join(', ')}"
lines << "Kandidaten (unused) : ${unused.size()}"
if (!DRY_RUN) {
lines << "Gelöscht : ${deleted.size()}"
lines << "Fehlgeschlagen : ${failed.size()}"
}
lines << ""
lines << "Kandidaten (unbenutzte Schemes, exkl. EXCLUDED_IDS):"
unused.each { entry ->
def s = entry.scheme
lines << String.format(
"- ID=%s | Name=\"%s\" | Beschreibung=\"%s\" | Projekte=%s",
s.id,
s.name ?: "",
(s.description ?: "").replaceAll('\\s+', ' ').trim(),
entry.projects ?: []
)
}
def result = [
summary : [
dryRun : DRY_RUN,
totalNotificationSchemes: allSchemes.size(),
totalProjects : totalProjects,
excludedIDs : EXCLUDED_IDS,
candidateUnused : unused.size(),
deleted : deleted.size(),
failed : failed.size()
],
candidateUnusedSchemes: unused.collect { e ->
def s = e.scheme
[
id : s.id,
name : s.name,
description : s.description,
projectsUsing: e.projects
]
},
deletedNotificationSchemes: deleted,
failedDeletions : failed
]
logger.info lines.join("\n")
return lines.join("\n") + "\n\nJSON:\n" + JsonOutput.prettyPrint(JsonOutput.toJson(result))

View File

@ -0,0 +1,135 @@
import groovy.json.JsonOutput
// -------------------------- Konfiguration --------------------------
boolean DRY_RUN = true // erst prüfen; auf false stellen, wenn die Kandidatenliste stimmt
int PAGE_SIZE = 50
Set<String> EXCLUDE_BY_NAME = [
'Default Screen Scheme'
] as Set
// -------------------------- Logging -------------------------------
void logInfo(String msg){ try { logger.info(msg) } catch(e){ println msg } }
void logWarn(String msg){ try { logger.warn(msg) } catch(e){ println "WARN: " + msg } }
void logErr (String msg){ try { logger.error(msg)} catch(e){ println "ERR: " + msg } }
// -------------------------- HTTP Helpers --------------------------
Map getAsMap(String path, Map<String,Object> q=[:]) {
def req = get(path)
q.each { k,v -> req = req.queryString(k, v) }
def resp = req.asObject(Map)
if (resp.status != 200) {
throw new RuntimeException("GET " + path + " failed: HTTP " + resp.status + " :: " + resp.body)
}
return (resp.body ?: [:]) as Map
}
List<Map> pagedGetValues(String path, int pageSize) {
int startAt = 0
List<Map> all = []
while (true) {
Map body = getAsMap(path, [startAt: startAt, maxResults: pageSize])
List vals = (body.values ?: []) as List
all.addAll(vals as List<Map>)
int total = (body.total ?: (startAt + vals.size())) as int
int nextStart = startAt + ((body.maxResults ?: vals.size()) as int)
if (vals.isEmpty() || nextStart >= total) break
startAt = nextStart
}
return all
}
// -------------------------- Fetchers ------------------------------
List<Map> fetchScreenSchemes(int pageSize) {
logInfo("Lade Screen Schemes…")
List<Map> list = pagedGetValues("/rest/api/3/screenscheme", pageSize)
logInfo("Screen Schemes gefunden: " + list.size())
return list
}
List<Map> fetchIssueTypeScreenSchemes(int pageSize) {
logInfo("Lade Issue Type Screen Schemes…")
List<Map> list = pagedGetValues("/rest/api/3/issuetypescreenscheme", pageSize)
logInfo("Issue Type Screen Schemes gefunden: " + list.size())
return list
}
/**
* Holt alle Referenzen auf Screen Schemes:
* - defaultScreenSchemeId je ITSS
* - globale ITSSIssueTypeScreenScheme Mappings aus /issuetypescreenscheme/mapping
*/
Set<Long> fetchReferencedScreenSchemeIds(int pageSize, List<Map> itssList) {
Set<Long> refs = new LinkedHashSet<>()
// 1) Default-Zuordnungen je ITSS
for (Map itss : itssList) {
def defId = itss.get("defaultScreenSchemeId")
if (defId != null) refs.add(Long.valueOf(defId.toString()))
}
// 2) Globale Mappings
logInfo("Lade globale IssueType→ScreenScheme Mappings…")
List<Map> globalMaps = pagedGetValues("/rest/api/3/issuetypescreenscheme/mapping", pageSize)
for (Map m : globalMaps) {
def ssId = m.get("screenSchemeId")
if (ssId != null) refs.add(Long.valueOf(ssId.toString()))
}
logInfo("Referenzierte Screen Schemes gesamt: " + refs.size())
return refs
}
// -------------------------- Delete -------------------------------
boolean deleteScreenScheme(long id, String name) {
def resp = delete("/rest/api/3/screenscheme/" + id).asString()
if (resp.status in [200,204]) {
logInfo("Gelöscht: [" + id + "] " + name)
return true
}
logWarn("Nicht gelöscht [" + id + "] " + name + " :: HTTP " + resp.status + " :: " + resp.body)
return false
}
// -------------------------- Main -------------------------------
void runCleanup(boolean dryRun, int pageSize, Set<String> excludeByName) {
List<Map> screenSchemes = fetchScreenSchemes(pageSize)
if (screenSchemes.isEmpty()) { logInfo("Keine Screen Schemes vorhanden nichts zu tun."); return }
List<Map> itssList = fetchIssueTypeScreenSchemes(pageSize)
Set<Long> referenced = fetchReferencedScreenSchemeIds(pageSize, itssList)
List<Map> candidates = []
for (Map s : screenSchemes) {
long id = Long.valueOf(s.get("id").toString())
String name = (s.get("name") ?: "") as String
if (!referenced.contains(id) && !excludeByName.contains(name)) {
candidates.add([id: id, name: name, description: (s.get("description") ?: "")])
}
}
candidates.sort { a, b -> a.name <=> b.name }
if (candidates.isEmpty()) { logInfo("Keine ungenutzten Screen Schemes gefunden. ✅"); return }
logWarn("Ungenutzte Kandidaten (" + candidates.size() + "):")
for (Map c : candidates) logWarn(" - [" + c.id + "] " + c.name)
if (dryRun) {
logInfo("Dry-Run aktiv → nichts gelöscht.")
logInfo("JSON Dump:\n" + JsonOutput.prettyPrint(JsonOutput.toJson(candidates)))
return
}
int deleted = 0, skipped = 0
for (Map c : candidates) {
try {
if (deleteScreenScheme((c.id as Long), c.name.toString())) deleted++ else skipped++
} catch (Exception ex) {
skipped++
logErr("Fehler beim Löschen [" + c.id + "] " + c.name + " :: " + ex.message)
}
}
logWarn("Fertig. Ergebnis: deleted=" + deleted + ", skipped=" + skipped)
}
// ---- Start ----
runCleanup(DRY_RUN, PAGE_SIZE, EXCLUDE_BY_NAME)

View File

@ -0,0 +1,194 @@
// -----------------------------------------------------------------------------
// Housekeeping: Unbenutzte Berechtigungsschemata finden UND löschen
// Jira Cloud - ScriptRunner Console
// -----------------------------------------------------------------------------
import groovy.json.JsonOutput
// --- Konfiguration -----------------------------------------------------------
// 🔥 Wenn true -> nur Testlauf, nichts wird gelöscht.
// 🔥 Wenn false -> unbenutzte Schemas (ohne EXCLUDED_IDS) werden gelöscht.
final boolean DRY_RUN = true
// IDs, die niemals gelöscht werden sollen (z. B. Default/System-Schemata)
final Set<Long> EXCLUDED_IDS = [0] as Set // bei Bedarf ergänzen, z.B. 10000L etc.
// --- Schritt 1: Alle Berechtigungsschemata holen ----------------------------
def schemesResp = get("/rest/api/3/permissionscheme").asObject(Map)
if (schemesResp.status != 200) {
logger.error("Konnte Berechtigungsschemata nicht laden: ${schemesResp.status} - ${schemesResp.body}")
return "Fehler beim Laden der Berechtigungsschemata. Siehe Log."
}
def schemes = schemesResp.body?.permissionSchemes ?: []
logger.info "Anzahl Berechtigungsschemata insgesamt: ${schemes.size()}"
// Map: schemeId -> [scheme: <Objekt>, used: boolean, projects: [keys]]
def schemeUsage = schemes.collectEntries { scheme ->
def id = (scheme.id ?: scheme["id"]) as Long
[
(id): [
scheme : scheme,
used : false,
projects: []
]
]
}
// --- Schritt 2: Alle Projekte holen & Permission-Scheme je Projekt ermitteln --
int startAt = 0
int maxResults = 50
int totalProjects = 0
boolean finished = false
while (!finished) {
def projResp = get("/rest/api/3/project/search?startAt=${startAt}&maxResults=${maxResults}")
.asObject(Map)
if (projResp.status != 200) {
logger.error("Konnte Projekte nicht laden (startAt=${startAt}): ${projResp.status} - ${projResp.body}")
break
}
def body = projResp.body ?: [:]
def projects = body.values ?: []
totalProjects += projects.size()
projects.each { proj ->
def projectKey = proj.key
def projectId = proj.id
def permResp = get("/rest/api/3/project/${projectId}/permissionscheme")
.asObject(Map)
if (permResp.status == 200) {
def schemeId = permResp.body?.id
if (schemeId) {
def idLong = (schemeId as Long)
def entry = schemeUsage[idLong]
if (entry) {
entry.used = true
entry.projects << projectKey
} else {
logger.warn "Projekt ${projectKey} nutzt Berechtigungsschema ${schemeId}, das nicht in der globalen Liste war."
}
}
} else if (permResp.status == 404) {
// Team-managed-Projekte -> haben kein klassisches Permission Scheme
} else {
logger.warn "Konnte Permission Scheme für Projekt ${projectKey} nicht laden: ${permResp.status}"
}
}
int total = (body.total ?: totalProjects) as int
startAt += maxResults
if (startAt >= total) {
finished = true
}
}
// --- Schritt 3: Unbenutzte (und nicht ausgeschlossene) Schemata bestimmen ---
def unused = schemeUsage.values()
.findAll { entry ->
def id = (entry.scheme.id ?: 0L) as Long
!entry.used && !EXCLUDED_IDS.contains(id)
}
.sort { it.scheme.name?.toString()?.toLowerCase() }
logger.info "Projekte insgesamt : ${totalProjects}"
logger.info "Ausgeschlossene Schema-IDs : ${EXCLUDED_IDS.join(', ')}"
logger.info "Unbenutzte Schemata (Kandidaten): ${unused.size()}"
// --- Schritt 4: Optional löschen --------------------------------------------
def deleted = []
def failed = []
if (!DRY_RUN) {
unused.each { entry ->
def s = entry.scheme
def id = (s.id as Long)
logger.info "Lösche Berechtigungsschema ID=${id}, Name=\"${s.name}\" ..."
def delResp = delete("/rest/api/3/permissionscheme/${id}")
.asString()
if (delResp.status in [200, 204]) {
logger.info "Erfolgreich gelöscht: ID=${id}, Name=\"${s.name}\""
deleted << [
id : id,
name : s.name,
description: s.description
]
} else {
logger.warn "Löschen fehlgeschlagen für ID=${id}, Name=\"${s.name}\": Status=${delResp.status}, Body=${delResp.body}"
failed << [
id : id,
name : s.name,
status : delResp.status,
body : delResp.body
]
}
}
} else {
logger.info "DRY_RUN = true -> Es wird nichts gelöscht, nur Kandidaten ermittelt."
}
// --- Schritt 5: Zusammenfassung zurückgeben ---------------------------------
def lines = []
lines << "=== Berechtigungsschemata Housekeeping ==="
lines << "DRY_RUN : ${DRY_RUN}"
lines << "Gesamt-Schemata : ${schemes.size()}"
lines << "Gesamt-Projekte : ${totalProjects}"
lines << "Ausgeschlossene IDs : ${EXCLUDED_IDS.join(', ')}"
lines << "Kandidaten (unused) : ${unused.size()}"
if (!DRY_RUN) {
lines << "Gelöscht : ${deleted.size()}"
lines << "Fehlgeschlagen : ${failed.size()}"
}
lines << ""
lines << "Kandidaten (unbenutzte Schemas, exkl. EXCLUDED_IDS):"
unused.each { entry ->
def s = entry.scheme
lines << String.format(
"- ID=%s | Name=\"%s\" | Beschreibung=\"%s\" | Projekte=%s",
s.id,
s.name ?: "",
(s.description ?: "").replaceAll('\\s+', ' ').trim(),
entry.projects ?: []
)
}
def result = [
summary : [
dryRun : DRY_RUN,
totalSchemes : schemes.size(),
totalProjects : totalProjects,
excludedIDs : EXCLUDED_IDS,
candidateUnused : unused.size(),
deleted : deleted.size(),
failed : failed.size()
],
deletedPermissionSchemes: deleted,
failedDeletions : failed,
candidateUnusedSchemes : unused.collect { e ->
def s = e.scheme
[
id : s.id,
name : s.name,
description : s.description,
projectsUsing: e.projects
]
}
]
logger.info lines.join("\n")
return lines.join("\n") + "\n\nJSON:\n" + JsonOutput.prettyPrint(JsonOutput.toJson(result))

View File

@ -0,0 +1,32 @@
// ---------------------------------------------------------------------------------
// Postfunction: Setzt LabelManager-Feld Partneranfrage auf Partneranfrage-Abgelehnt
// ---------------------------------------------------------------------------------
// ID deines Label-Manager-Feldes
final LABEL_MANAGER_CF_ID = 'customfield_11037'
// Welchen Eintrag setzen?
final SELECTED_LABEL = 'Partneranfrage-Abgelehnt'
final LABEL_COLOR = 'red' // oder "red", "green", etc.
final LABEL_SOURCE = 'project' // weil eine projekt-spezifische Konfig verwendet wird
// Issue-Key aus dem Workflow-Kontext
def issueKey = issue.key
def response = put("/rest/api/3/issue/${issueKey}")
.header('Content-Type', 'application/json')
.body([
fields: [
// wichtig: Feld-ID als Schlüssel, Value ist ein Objekt mit labels/colors/labelSource
(LABEL_MANAGER_CF_ID): [
labels : [SELECTED_LABEL],
colors : [LABEL_COLOR],
labelSource: LABEL_SOURCE
]
]
])
.asString()
if (response.status != 204) {
logger.warn "Label-Manager-Feld konnte nicht aktualisiert werden: ${response.status} - ${response.body}"
}

View File

@ -0,0 +1,32 @@
// -----------------------------------------------------------------------------
// Postfunction: Setzt LabelManager-Feld Partneranfrage auf Partneranfrage-Check
// -----------------------------------------------------------------------------
// ID deines Label-Manager-Feldes
final LABEL_MANAGER_CF_ID = 'customfield_11037'
// Welchen Eintrag setzen?
final SELECTED_LABEL = 'Partneranfrage-Check'
final LABEL_COLOR = 'yellow' // oder "red", "green", etc.
final LABEL_SOURCE = 'project' // weil eine projekt-spezifische Konfig verwendet wird
// Issue-Key aus dem Workflow-Kontext
def issueKey = issue.key
def response = put("/rest/api/3/issue/${issueKey}")
.header('Content-Type', 'application/json')
.body([
fields: [
// wichtig: Feld-ID als Schlüssel, Value ist ein Objekt mit labels/colors/labelSource
(LABEL_MANAGER_CF_ID): [
labels : [SELECTED_LABEL],
colors : [LABEL_COLOR],
labelSource: LABEL_SOURCE
]
]
])
.asString()
if (response.status != 204) {
logger.warn "Label-Manager-Feld konnte nicht aktualisiert werden: ${response.status} - ${response.body}"
}

View File

@ -0,0 +1,72 @@
// -----------------------------------------------------------------------------
// Postfunction: Setzt LabelManager-Feld, Security-Level, Dropdown-Wert,
// leert ein Multi-User-Feld und setzt ein anderes Multi-User-Feld auf den Assignee
// -----------------------------------------------------------------------------
// LabelManager-Konfiguration
final LABEL_MANAGER_CF_ID = 'customfield_11037'
final SELECTED_LABEL = 'Partneranfrage-Angenommen'
final LABEL_COLOR = 'green'
final LABEL_SOURCE = 'project'
// Security-Level "CS"
final SECURITY_LEVEL_ID = '10125'
// Dropdown-Feld (Single Select)
final DROPDOWN_CF_ID = 'customfield_10039'
final DROPDOWN_VALUE = 'Customer Service'
// Multi-User-Felder
final USERFIELD_CLEAR_ID = 'customfield_10054' // Partner Bearbeiter wird geleert
final USERFIELD_SET_ID = 'customfield_10036' // pds Bearbeiter wird auf Assignee gesetzt
// Issue-Key aus dem Workflow-Kontext
def issueKey = issue.key
def assignee = issue.fields.assignee
// Wenn kein Assignee vorhanden ist, Log-Eintrag
if (!assignee) {
logger.warn "Kein Assignee für ${issueKey} vorhanden ${USERFIELD_SET_ID} bleibt leer."
}
// Request-Body zusammensetzen
def payload = [
fields: [
// LabelManager-Feld
(LABEL_MANAGER_CF_ID): [
labels : [SELECTED_LABEL],
colors : [LABEL_COLOR],
labelSource: LABEL_SOURCE
],
// Security-Level
security: [
id: SECURITY_LEVEL_ID
],
// Dropdown-Feld (Single Select)
(DROPDOWN_CF_ID): [
value: DROPDOWN_VALUE
],
// Multi-User-Feld leeren
(USERFIELD_CLEAR_ID): [],
// Multi-User-Feld auf aktuellen Assignee setzen (falls vorhanden)
(USERFIELD_SET_ID): assignee ? [[ accountId: assignee.accountId ]] : []
]
]
// PUT-Request an Jira REST API
def response = put("/rest/api/3/issue/${issueKey}")
.header('Content-Type', 'application/json')
.body(payload)
.asString()
// Erfolg / Fehler protokollieren
if (response.status == 204) {
logger.info "Felder erfolgreich aktualisiert für ${issueKey}: Label='${SELECTED_LABEL}', Security=CS, Dropdown='${DROPDOWN_VALUE}', " +
"${USERFIELD_CLEAR_ID} geleert, ${USERFIELD_SET_ID} auf Assignee gesetzt."
} else {
logger.warn "Update fehlgeschlagen (${response.status}) für ${issueKey}: ${response.body}"
}

View File

@ -0,0 +1,24 @@
/*
Workflow: CS: Workflow 4 SD
Transition: Ticket lösen
*/
import java.time.LocalDate
// aktuelles Datum + 2 Tage
def newDueDate = LocalDate.now().plusDays(2).toString() // yyyy-MM-dd
// nur setzen, wenn noch nicht vorhanden
if (!issue["fields"]["duedate"]) {
def resp = put("/rest/api/3/issue/${issue["key"]}")
.header("Content-Type", "application/json")
.body([
fields: [
duedate: newDueDate
]
])
.asObject(Map)
assert resp.status == 204 : "Update fehlgeschlagen: ${resp.status} - ${resp.body}"
}

View File

@ -0,0 +1,119 @@
// ScriptRunner Cloud - Scheduled Job
// 1) Issues per JQL suchen (/rest/api/3/search/jql + nextPageToken)
// 2) Transition nach "Closed" (per ID ODER Ziel-Status ODER Aktionsname)
// 3) DueDate leeren
// 4) Kommentar (ADF) hinzufügen
// ===================== Konfiguration =====================
def JQL = 'project = "Customer Service Desk" AND status = Resolved AND duedate IS NOT EMPTY AND due <= endOfDay()'
def MAX_PER_PAGE = 50
// Wenn du die Transition-ID kennst, HIER setzen (als String). Beispiel: "331" für "Schließen".
// Wenn null/leer, wird via Name gesucht (erst Ziel-Status, dann Aktionsname).
final String TRANSITION_ID = "331" // <- bei dir vorhanden; sonst auf null setzen
final String TARGET_STATUS_NAME = "Closed" // Ziel-Statusname
final List<String> ACTION_NAME_FALLBACKS = ["Schließen", "Close", "Closed"] // Aktionsnamen-Fallbacks
// Kommentartext (als ADF gesendet)
def commentText = """
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()
// ========================================================
def adfBody = [
type: "doc",
version: 1,
content: [[ type: "paragraph", content: [[ type: "text", text: commentText ]] ]]
]
// --- Helper: Transition-ID bestimmen ---
def resolveTransitionId = { String issueKey ->
if (TRANSITION_ID?.trim()) {
return TRANSITION_ID.trim()
}
def resp = get("/rest/api/3/issue/${issueKey}/transitions").asObject(Map)
assert resp.status == 200 : "Transitions nicht lesbar für ${issueKey}: ${resp.status} - ${resp.body}"
def transitions = (resp.body?.transitions as List) ?: []
// 1) nach Ziel-Status (to.name)
def byTargetStatus = transitions.find { it?.to?.name?.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?.equalsIgnoreCase(fn) }
}
return byActionName?.id as String
}
// --- Helper: 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()
assert resp.status == 204 : "Transition fehlgeschlagen für ${issueKey}: ${resp.status} - ${resp.body}"
}
// --- Helper: DueDate leeren ---
def clearDueDate = { String issueKey ->
def resp = put("/rest/api/3/issue/${issueKey}")
.header("Content-Type", "application/json")
.body([ fields: [ duedate: null ] ])
.asString()
assert resp.status == 204 : "DueDate leeren fehlgeschlagen für ${issueKey}: ${resp.status} - ${resp.body}"
}
// --- Helper: 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)
assert resp.status == 201 : "Kommentar fehlgeschlagen für ${issueKey}: ${resp.status} - ${resp.body}"
}
// --- Suche mit neuer API und Token-Pagination ---
def processed = 0
String nextPageToken = null
boolean isLast = false
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)
assert searchResp.status == 200 : "Search-Fehler: ${searchResp.status} - ${searchResp.body}"
def body = searchResp.body as Map
def issues = (body?.issues as List) ?: []
isLast = (body?.isLast == true)
nextPageToken = body?.nextPageToken as String
issues.each { Map iss ->
def key = iss["key"] as String
try {
def transitionId = resolveTransitionId(key)
if (!transitionId) {
logger.warn("Keine passende Transition für ${key} gefunden (Status='${TARGET_STATUS_NAME}', Fallbacks=${ACTION_NAME_FALLBACKS}) übersprungen.")
return
}
doTransition(key, transitionId)
clearDueDate(key)
addComment(key, adfBody)
processed++
logger.info("OK: ${key} -> Transition=${transitionId}, DueDate geleert, Kommentar gesetzt.")
} catch (Throwable t) {
logger.error("Fehler bei ${key}: ${t.message}")
}
}
}
logger.info("Fertig. Verarbeitete Tickets: ${processed} (JQL: ${JQL})")

View File

@ -0,0 +1,46 @@
<mxfile host="app.diagrams.net" modified="2025-11-06T00:00:00.000Z" agent="GPT-5" version="22.0.8">
<diagram name="JSM Auto Close 48h" id="autoCloseFlow">
<mxGraphModel dx="1250" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1200">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="2" value="Scheduled Job startet (z. B. täglich)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="200" y="50" width="300" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="API-Call: /rest/api/3/search/jql&#10;- Status = Resolved&#10;- resolutiondate 4872 h" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="200" y="150" width="300" height="80" as="geometry"/>
</mxCell>
<mxCell id="4" value="Für jedes Ticket" style="shape=rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="220" y="270" width="260" height="80" as="geometry"/>
</mxCell>
<mxCell id="5" value="Transition-ID ermitteln&#10;(über ID, Statusname oder Aktionsname)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="200" y="380" width="300" height="80" as="geometry"/>
</mxCell>
<mxCell id="6" value="Transition zu Closed&#10;(POST /issue/{key}/transitions)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="200" y="490" width="300" height="80" as="geometry"/>
</mxCell>
<mxCell id="7" value="DueDate löschen&#10;(PUT /issue/{key})" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="200" y="600" width="300" height="70" as="geometry"/>
</mxCell>
<mxCell id="8" value="Kommentar hinzufügen&#10;(POST /issue/{key}/comment)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="200" y="700" width="300" height="70" as="geometry"/>
</mxCell>
<mxCell id="9" value="Log schreiben (OK / Fehler)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="200" y="800" width="300" height="60" as="geometry"/>
</mxCell>
<mxCell id="10" value="Nächste Seite (Pagination) oder Ende" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#666666;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="200" y="900" width="300" height="60" as="geometry"/>
</mxCell>
<!-- Verbindungen -->
<mxCell id="11" edge="1" source="2" target="3" style="endArrow=block;rounded=1;strokeWidth=1.5;" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="12" edge="1" source="3" target="4" style="endArrow=block;rounded=1;strokeWidth=1.5;" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="13" edge="1" source="4" target="5" style="endArrow=block;rounded=1;strokeWidth=1.5;" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="14" edge="1" source="5" target="6" style="endArrow=block;rounded=1;strokeWidth=1.5;" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="15" edge="1" source="6" target="7" style="endArrow=block;rounded=1;strokeWidth=1.5;" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="16" edge="1" source="7" target="8" style="endArrow=block;rounded=1;strokeWidth=1.5;" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="17" edge="1" source="8" target="9" style="endArrow=block;rounded=1;strokeWidth=1.5;" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell>
<mxCell id="18" edge="1" source="9" target="10" style="endArrow=block;rounded=1;strokeWidth=1.5;" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>