Add Jira maintenance & housekeeping scripts
This commit is contained in:
parent
a08ff5b88f
commit
ff7b92c058
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Notification Scheme Housekeeping (Cloud) – Report + optional Delete
|
||||
* -------------------------------------------------------------------
|
||||
* Strategy:
|
||||
* - PROTECTED_PROJECT_KEYS sind die einzigen Projekte, die "leben"
|
||||
* - Ein Scheme darf nur gelöscht werden, wenn es in KEINEM dieser Projekte steckt
|
||||
*/
|
||||
|
||||
def PROTECTED_PROJECT_KEYS = ["NIN","NICS","NINPDS","NINPDSARC","CS","CRON"]
|
||||
def DRY_RUN = true // erst auf false, wenn der Report passt
|
||||
|
||||
logger.info("=== Notification Scheme Housekeeping ===")
|
||||
logger.info("Protected projects: ${PROTECTED_PROJECT_KEYS}")
|
||||
logger.info("DRY_RUN: ${DRY_RUN}")
|
||||
|
||||
// Helper: Project -> NotificationSchemeId (nur 6 Calls, ok)
|
||||
def projectToSchemeId = [:]
|
||||
|
||||
PROTECTED_PROJECT_KEYS.each { key ->
|
||||
def resp = get("/rest/api/3/project/${key}/notificationscheme")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status == 200) {
|
||||
projectToSchemeId[key] = resp.body?.id?.toString()
|
||||
} else {
|
||||
projectToSchemeId[key] = null
|
||||
logger.warn("WARN|PROJECT_LOOKUP_FAILED|${key}|status=${resp.status}")
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("INFO|PROJECT_SCHEME_MAP|${projectToSchemeId}")
|
||||
|
||||
// Alle Schemes holen
|
||||
def schemesResp = get("/rest/api/3/notificationscheme")
|
||||
.asObject(Map)
|
||||
|
||||
assert schemesResp.status == 200
|
||||
|
||||
def schemes = schemesResp.body?.values ?: []
|
||||
|
||||
def candidates = [] // [id, name]
|
||||
def kept = 0
|
||||
|
||||
schemes.each { scheme ->
|
||||
def schemeId = scheme.id?.toString()
|
||||
def schemeName = scheme.name?.toString()
|
||||
|
||||
def usedBy = projectToSchemeId.findAll { k, v -> v == schemeId }*.key
|
||||
|
||||
if (usedBy && !usedBy.isEmpty()) {
|
||||
kept++
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|usedBy=${usedBy}")
|
||||
} else {
|
||||
candidates << [schemeId, schemeName]
|
||||
logger.info("DEL?|schemeId=${schemeId}|name=${schemeName}|usedBy=[]")
|
||||
|
||||
if (!DRY_RUN) {
|
||||
def delResp = delete("/rest/api/3/notificationscheme/${schemeId}")
|
||||
.asString()
|
||||
|
||||
if (delResp.status == 204) {
|
||||
logger.info("DEL|OK|schemeId=${schemeId}|name=${schemeName}")
|
||||
} else {
|
||||
logger.error("DEL|FAIL|schemeId=${schemeId}|name=${schemeName}|status=${delResp.status}|body=${delResp.body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("=== SUMMARY ===")
|
||||
logger.info("Total schemes: ${schemes.size()}")
|
||||
logger.info("Kept (used by protected projects): ${kept}")
|
||||
logger.info("Delete candidates: ${candidates.size()}")
|
||||
|
||||
// Kandidaten am Ende nochmal gesammelt, damit du sie easy kopieren kannst
|
||||
candidates.each { c ->
|
||||
logger.info("CANDIDATE|schemeId=${c[0]}|name=${c[1]}")
|
||||
}
|
||||
|
||||
logger.info("=== DONE ===")
|
||||
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Permission Scheme Housekeeping (Cloud) – Report + optional Delete
|
||||
* ----------------------------------------------------------------
|
||||
* Delete rule:
|
||||
* - Scheme darf nur gelöscht werden, wenn:
|
||||
* 1) es in keinem PROTECTED_PROJECT verwendet wird
|
||||
* 2) es nicht über PROTECTED_SCHEME_IDS geschützt ist
|
||||
* 3) es nicht über PROTECTED_NAME_PATTERNS geschützt ist
|
||||
*/
|
||||
|
||||
def PROTECTED_PROJECT_KEYS = ["NIN","NICS","NINPDS","NINPDSARC","CS","CRON"]
|
||||
|
||||
// Zusätzliche Sicherung:
|
||||
def PROTECTED_SCHEME_IDS = [
|
||||
// "10000", // <- optional: hier IDs eintragen, die NIE gelöscht werden dürfen
|
||||
]
|
||||
def PROTECTED_NAME_PATTERNS = [
|
||||
"default",
|
||||
"system"
|
||||
]
|
||||
|
||||
def DRY_RUN = true // erst auf false, wenn der Report passt
|
||||
|
||||
logger.info("=== Permission Scheme Housekeeping ===")
|
||||
logger.info("Protected projects: ${PROTECTED_PROJECT_KEYS}")
|
||||
logger.info("Protected scheme IDs: ${PROTECTED_SCHEME_IDS}")
|
||||
logger.info("Protected name patterns: ${PROTECTED_NAME_PATTERNS}")
|
||||
logger.info("DRY_RUN: ${DRY_RUN}")
|
||||
|
||||
// Helper: Name-Schutz (case-insensitive)
|
||||
def isNameProtected = { String name ->
|
||||
def n = (name ?: "").toLowerCase()
|
||||
return PROTECTED_NAME_PATTERNS.any { p -> n.contains((p ?: "").toLowerCase()) }
|
||||
}
|
||||
|
||||
// Helper: ID-Schutz
|
||||
def isIdProtected = { String id ->
|
||||
return PROTECTED_SCHEME_IDS.any { it?.toString() == id?.toString() }
|
||||
}
|
||||
|
||||
// 1) Project -> PermissionSchemeId (nur 6 Calls)
|
||||
def projectToSchemeId = [:]
|
||||
|
||||
PROTECTED_PROJECT_KEYS.each { key ->
|
||||
def resp = get("/rest/api/3/project/${key}/permissionscheme")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status == 200) {
|
||||
// Jira liefert hier i.d.R. {id, name, ...}
|
||||
projectToSchemeId[key] = resp.body?.id?.toString()
|
||||
} else {
|
||||
projectToSchemeId[key] = null
|
||||
logger.warn("WARN|PROJECT_LOOKUP_FAILED|${key}|status=${resp.status}")
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("INFO|PROJECT_SCHEME_MAP|${projectToSchemeId}")
|
||||
|
||||
// 2) Alle Permission Schemes holen
|
||||
def schemesResp = get("/rest/api/3/permissionscheme")
|
||||
.asObject(Map)
|
||||
|
||||
assert schemesResp.status == 200
|
||||
|
||||
// Je nach API-Response ist das typischerweise body.permissionSchemes
|
||||
def schemes = schemesResp.body?.permissionSchemes ?: []
|
||||
logger.info("INFO|TOTAL_SCHEMES|${schemes.size()}")
|
||||
|
||||
def candidates = [] // [id, name, reason]
|
||||
def keptByUsage = 0
|
||||
def keptByRule = 0
|
||||
|
||||
schemes.each { scheme ->
|
||||
def schemeId = scheme.id?.toString()
|
||||
def schemeName = scheme.name?.toString()
|
||||
|
||||
def usedBy = projectToSchemeId.findAll { k, v -> v == schemeId }*.key
|
||||
def nameProtected = isNameProtected(schemeName)
|
||||
def idProtected = isIdProtected(schemeId)
|
||||
|
||||
// Schutz greift immer (egal ob benutzt oder nicht)
|
||||
if (idProtected || nameProtected) {
|
||||
keptByRule++
|
||||
def why = []
|
||||
if (idProtected) why << "ID_PROTECTED"
|
||||
if (nameProtected) why << "NAME_PROTECTED"
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|usedBy=${usedBy}|reason=${why}")
|
||||
return
|
||||
}
|
||||
|
||||
// Projekt-Usage entscheidet
|
||||
if (usedBy && !usedBy.isEmpty()) {
|
||||
keptByUsage++
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|usedBy=${usedBy}|reason=USED_BY_PROTECTED_PROJECT")
|
||||
return
|
||||
}
|
||||
|
||||
// Kandidat
|
||||
logger.info("DEL?|schemeId=${schemeId}|name=${schemeName}|usedBy=[]|reason=UNUSED")
|
||||
candidates << [schemeId, schemeName, "UNUSED"]
|
||||
|
||||
if (!DRY_RUN) {
|
||||
def delResp = delete("/rest/api/3/permissionscheme/${schemeId}")
|
||||
.asString()
|
||||
|
||||
if (delResp.status == 204) {
|
||||
logger.info("DEL|OK|schemeId=${schemeId}|name=${schemeName}")
|
||||
} else {
|
||||
logger.error("DEL|FAIL|schemeId=${schemeId}|name=${schemeName}|status=${delResp.status}|body=${delResp.body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("=== SUMMARY ===")
|
||||
logger.info("Total schemes: ${schemes.size()}")
|
||||
logger.info("Kept (used by protected projects): ${keptByUsage}")
|
||||
logger.info("Kept (protected by rules): ${keptByRule}")
|
||||
logger.info("Delete candidates: ${candidates.size()}")
|
||||
|
||||
candidates.each { c ->
|
||||
logger.info("CANDIDATE|schemeId=${c[0]}|name=${c[1]}|reason=${c[2]}")
|
||||
}
|
||||
|
||||
logger.info("=== DONE ===")
|
||||
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Issue Type Scheme Housekeeping (Cloud) – Report + optional Delete
|
||||
* ----------------------------------------------------------------
|
||||
* Korrekte Strategie:
|
||||
* - Hole alle Projekte (id + key)
|
||||
* - Für jedes Projekt: hole IssueTypeScheme-Zuordnung
|
||||
* - Delete nur, wenn Scheme an KEINEM Projekt hängt
|
||||
* - Extra-Schutz über IDs + Name-Patterns
|
||||
*/
|
||||
|
||||
def PROTECTED_PROJECT_KEYS = ["NIN","NICS","NINPDS","NINPDSARC","CS","CRON"]
|
||||
|
||||
// Zusätzliche Sicherung:
|
||||
def PROTECTED_SCHEME_IDS = [
|
||||
// "10000"
|
||||
]
|
||||
def PROTECTED_NAME_PATTERNS = [
|
||||
"default",
|
||||
"system"
|
||||
]
|
||||
|
||||
def DRY_RUN = true // erst auf false setzen wenn Report passt
|
||||
|
||||
logger.info("=== Issue Type Scheme Housekeeping ===")
|
||||
logger.info("Protected projects (keys): ${PROTECTED_PROJECT_KEYS}")
|
||||
logger.info("Protected scheme IDs: ${PROTECTED_SCHEME_IDS}")
|
||||
logger.info("Protected name patterns: ${PROTECTED_NAME_PATTERNS}")
|
||||
logger.info("DRY_RUN: ${DRY_RUN}")
|
||||
|
||||
def isNameProtected = { String name ->
|
||||
def n = (name ?: "").toLowerCase()
|
||||
return PROTECTED_NAME_PATTERNS.any { p -> n.contains((p ?: "").toLowerCase()) }
|
||||
}
|
||||
def isIdProtected = { String id ->
|
||||
return PROTECTED_SCHEME_IDS.any { it?.toString() == id?.toString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) Alle Projekte holen (key + id)
|
||||
*/
|
||||
def projects = []
|
||||
def startAt = 0
|
||||
def maxResults = 50
|
||||
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/project/search?startAt=${startAt}&maxResults=${maxResults}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|PROJECT_SEARCH_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
projects.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
|
||||
startAt += maxResults
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_PROJECTS|${projects.size()}")
|
||||
|
||||
def projectIdToKey = [:]
|
||||
projects.each { p ->
|
||||
def pid = p?.id?.toString()
|
||||
def pkey = p?.key?.toString()
|
||||
if (pid && pkey) projectIdToKey[pid] = pkey
|
||||
}
|
||||
|
||||
/**
|
||||
* 2) Mapping: schemeId -> [projectIds]
|
||||
* (korrekt: Endpoint braucht projectId)
|
||||
*/
|
||||
def schemeToProjectIds = [:].withDefault { [] }
|
||||
|
||||
projectIdToKey.keySet().each { pid ->
|
||||
def resp = get("/rest/api/3/issuetypescheme/project?projectId=${pid}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.warn("WARN|ISSUETYPE_SCHEME_LOOKUP_FAILED|projectId=${pid}|status=${resp.status}")
|
||||
return
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
values.each { row ->
|
||||
def schemeId = row?.issueTypeScheme?.id?.toString()
|
||||
def pids = (row?.projectIds ?: []).collect { it?.toString() }.findAll { it != null }
|
||||
|
||||
if (schemeId) {
|
||||
// Achtung: pids enthält hier normalerweise genau das pid, wir mergen trotzdem sauber
|
||||
schemeToProjectIds[schemeId] = (schemeToProjectIds[schemeId] + pids).unique()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) Geschützte Projekt-IDs berechnen (für Reporting)
|
||||
*/
|
||||
def protectedProjectIds = projectIdToKey.findAll { id, key -> PROTECTED_PROJECT_KEYS.contains(key) }*.key
|
||||
logger.info("INFO|PROTECTED_PROJECT_IDS|${protectedProjectIds}")
|
||||
|
||||
/**
|
||||
* 4) Alle Issue Type Schemes holen
|
||||
*/
|
||||
def schemes = []
|
||||
startAt = 0
|
||||
maxResults = 100
|
||||
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/issuetypescheme?startAt=${startAt}&maxResults=${maxResults}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|SCHEME_LIST_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
schemes.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
|
||||
startAt += maxResults
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_SCHEMES|${schemes.size()}")
|
||||
|
||||
/**
|
||||
* 5) Auswertung
|
||||
*/
|
||||
def keptByUsage = 0
|
||||
def keptByRule = 0
|
||||
def candidates = [] // [id,name,reason]
|
||||
|
||||
schemes.each { s ->
|
||||
def schemeId = s?.id?.toString()
|
||||
def schemeName = s?.name?.toString()
|
||||
|
||||
def assocProjectIds = schemeToProjectIds[schemeId] ?: []
|
||||
def assocProjectKeys = assocProjectIds.collect { projectIdToKey[it] }.findAll { it != null }.unique()
|
||||
|
||||
def usedByProtectedKeys = assocProjectKeys.intersect(PROTECTED_PROJECT_KEYS)
|
||||
|
||||
def nameProtected = isNameProtected(schemeName)
|
||||
def idProtected = isIdProtected(schemeId)
|
||||
|
||||
if (idProtected || nameProtected) {
|
||||
keptByRule++
|
||||
def why = []
|
||||
if (idProtected) why << "ID_PROTECTED"
|
||||
if (nameProtected) why << "NAME_PROTECTED"
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|assocProjects=${assocProjectKeys}|usedByProtected=${usedByProtectedKeys}|reason=${why}")
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn es an irgendeinem Projekt hängt: nicht löschbar
|
||||
if (!assocProjectIds.isEmpty()) {
|
||||
keptByUsage++
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|assocProjects=${assocProjectKeys}|usedByProtected=${usedByProtectedKeys}|reason=ASSOCIATED_TO_PROJECTS")
|
||||
return
|
||||
}
|
||||
|
||||
// wirklich unzugeordnet: Kandidat
|
||||
logger.info("DEL?|schemeId=${schemeId}|name=${schemeName}|assocProjects=[]|reason=UNASSOCIATED")
|
||||
candidates << [schemeId, schemeName, "UNASSOCIATED"]
|
||||
|
||||
if (!DRY_RUN) {
|
||||
def delResp = delete("/rest/api/3/issuetypescheme/${schemeId}")
|
||||
.asString()
|
||||
|
||||
if (delResp.status == 204) {
|
||||
logger.info("DEL|OK|schemeId=${schemeId}|name=${schemeName}")
|
||||
} else {
|
||||
logger.error("DEL|FAIL|schemeId=${schemeId}|name=${schemeName}|status=${delResp.status}|body=${delResp.body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("=== SUMMARY ===")
|
||||
logger.info("Total schemes: ${schemes.size()}")
|
||||
logger.info("Kept (associated to any projects): ${keptByUsage}")
|
||||
logger.info("Kept (protected by rules): ${keptByRule}")
|
||||
logger.info("Delete candidates: ${candidates.size()}")
|
||||
|
||||
candidates.each { c ->
|
||||
logger.info("CANDIDATE|schemeId=${c[0]}|name=${c[1]}|reason=${c[2]}")
|
||||
}
|
||||
|
||||
logger.info("=== DONE ===")
|
||||
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Issue Type Screen Scheme Housekeeping (Cloud) – Report + optional Delete
|
||||
* -----------------------------------------------------------------------
|
||||
* Delete rule:
|
||||
* - Scheme darf nur gelöscht werden, wenn es mit KEINEM Projekt verknüpft ist.
|
||||
* - Extra-Schutz über PROTECTED_SCHEME_IDS und PROTECTED_NAME_PATTERNS.
|
||||
*/
|
||||
|
||||
def PROTECTED_SCHEME_IDS = [
|
||||
// "10000"
|
||||
]
|
||||
|
||||
def PROTECTED_NAME_PATTERNS = [
|
||||
"default",
|
||||
"system"
|
||||
]
|
||||
|
||||
def DRY_RUN = true // erst auf false setzen, wenn Report passt
|
||||
def PROJECT_PAGE_SIZE = 50 // paging für project/search
|
||||
def SCHEME_PAGE_SIZE = 100 // paging für issuetypescreenscheme
|
||||
|
||||
logger.info("=== Issue Type Screen Scheme Housekeeping ===")
|
||||
logger.info("Protected scheme IDs: ${PROTECTED_SCHEME_IDS}")
|
||||
logger.info("Protected name patterns: ${PROTECTED_NAME_PATTERNS}")
|
||||
logger.info("DRY_RUN: ${DRY_RUN}")
|
||||
|
||||
def isNameProtected = { String name ->
|
||||
def n = (name ?: "").toLowerCase()
|
||||
return PROTECTED_NAME_PATTERNS.any { p -> n.contains((p ?: "").toLowerCase()) }
|
||||
}
|
||||
def isIdProtected = { String id ->
|
||||
return PROTECTED_SCHEME_IDS.any { it?.toString() == id?.toString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) Alle Projekte holen (id + key)
|
||||
*/
|
||||
def projects = []
|
||||
def startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/project/search?startAt=${startAt}&maxResults=${PROJECT_PAGE_SIZE}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|PROJECT_SEARCH_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
projects.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
|
||||
startAt += PROJECT_PAGE_SIZE
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_PROJECTS|${projects.size()}")
|
||||
|
||||
def projectIdToKey = [:]
|
||||
projects.each { p ->
|
||||
def pid = p?.id?.toString()
|
||||
def pkey = p?.key?.toString()
|
||||
if (pid && pkey) projectIdToKey[pid] = pkey
|
||||
}
|
||||
|
||||
/**
|
||||
* 2) Mapping: issueTypeScreenSchemeId -> [projectIds]
|
||||
* (Endpoint braucht projectId, daher über alle Projekte iterieren)
|
||||
*/
|
||||
def schemeToProjectIds = [:].withDefault { [] }
|
||||
|
||||
projectIdToKey.keySet().each { pid ->
|
||||
def resp = get("/rest/api/3/issuetypescreenscheme/project?projectId=${pid}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.warn("WARN|SCHEME_LOOKUP_FAILED|projectId=${pid}|status=${resp.status}")
|
||||
return
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
values.each { row ->
|
||||
def schemeId = row?.issueTypeScreenScheme?.id?.toString()
|
||||
def pids = (row?.projectIds ?: []).collect { it?.toString() }.findAll { it != null }
|
||||
|
||||
if (schemeId) {
|
||||
schemeToProjectIds[schemeId] = (schemeToProjectIds[schemeId] + pids).unique()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) Alle Issue Type Screen Schemes holen
|
||||
*/
|
||||
def schemes = []
|
||||
startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/issuetypescreenscheme?startAt=${startAt}&maxResults=${SCHEME_PAGE_SIZE}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|SCHEME_LIST_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
schemes.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
|
||||
startAt += SCHEME_PAGE_SIZE
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_SCHEMES|${schemes.size()}")
|
||||
|
||||
/**
|
||||
* 4) Auswertung + optional Delete
|
||||
*/
|
||||
def keptAssociated = 0
|
||||
def keptProtected = 0
|
||||
def candidates = [] // [id,name,reason]
|
||||
|
||||
schemes.each { s ->
|
||||
def schemeId = s?.id?.toString()
|
||||
def schemeName = s?.name?.toString()
|
||||
|
||||
def assocProjectIds = schemeToProjectIds[schemeId] ?: []
|
||||
def assocProjectKeys = assocProjectIds.collect { projectIdToKey[it] }.findAll { it != null }.unique()
|
||||
|
||||
def nameProtected = isNameProtected(schemeName)
|
||||
def idProtected = isIdProtected(schemeId)
|
||||
|
||||
if (idProtected || nameProtected) {
|
||||
keptProtected++
|
||||
def why = []
|
||||
if (idProtected) why << "ID_PROTECTED"
|
||||
if (nameProtected) why << "NAME_PROTECTED"
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|assocProjects=${assocProjectKeys}|reason=${why}")
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn irgendwo zugeordnet: nicht löschen
|
||||
if (!assocProjectIds.isEmpty()) {
|
||||
keptAssociated++
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|assocProjects=${assocProjectKeys}|reason=ASSOCIATED_TO_PROJECTS")
|
||||
return
|
||||
}
|
||||
|
||||
// Wirklich unassoziiert: Kandidat
|
||||
logger.info("DEL?|schemeId=${schemeId}|name=${schemeName}|assocProjects=[]|reason=UNASSOCIATED")
|
||||
candidates << [schemeId, schemeName, "UNASSOCIATED"]
|
||||
|
||||
if (!DRY_RUN) {
|
||||
def delResp = delete("/rest/api/3/issuetypescreenscheme/${schemeId}")
|
||||
.asString()
|
||||
|
||||
if (delResp.status == 204) {
|
||||
logger.info("DEL|OK|schemeId=${schemeId}|name=${schemeName}")
|
||||
} else {
|
||||
logger.error("DEL|FAIL|schemeId=${schemeId}|name=${schemeName}|status=${delResp.status}|body=${delResp.body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("=== SUMMARY ===")
|
||||
logger.info("Total schemes: ${schemes.size()}")
|
||||
logger.info("Kept (associated to any projects): ${keptAssociated}")
|
||||
logger.info("Kept (protected by rules): ${keptProtected}")
|
||||
logger.info("Delete candidates: ${candidates.size()}")
|
||||
|
||||
candidates.each { c ->
|
||||
logger.info("CANDIDATE|schemeId=${c[0]}|name=${c[1]}|reason=${c[2]}")
|
||||
}
|
||||
|
||||
logger.info("=== DONE ===")
|
||||
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Field Configuration Scheme Housekeeping (Cloud) – Report + optional Delete
|
||||
* -------------------------------------------------------------------------
|
||||
* Delete rule:
|
||||
* - Scheme darf nur gelöscht werden, wenn es mit KEINEM Projekt verknüpft ist.
|
||||
* - Extra-Schutz über PROTECTED_SCHEME_IDS und PROTECTED_NAME_PATTERNS.
|
||||
*/
|
||||
|
||||
def PROTECTED_SCHEME_IDS = [
|
||||
// "10000"
|
||||
]
|
||||
|
||||
def PROTECTED_NAME_PATTERNS = [
|
||||
"default",
|
||||
"system"
|
||||
]
|
||||
|
||||
def DRY_RUN = true // erst auf false setzen, wenn Report passt
|
||||
def PROJECT_PAGE_SIZE = 50 // paging für project/search
|
||||
def SCHEME_PAGE_SIZE = 100 // paging für fieldconfigurationscheme
|
||||
|
||||
logger.info("=== Field Configuration Scheme Housekeeping ===")
|
||||
logger.info("Protected scheme IDs: ${PROTECTED_SCHEME_IDS}")
|
||||
logger.info("Protected name patterns: ${PROTECTED_NAME_PATTERNS}")
|
||||
logger.info("DRY_RUN: ${DRY_RUN}")
|
||||
|
||||
def isNameProtected = { String name ->
|
||||
def n = (name ?: "").toLowerCase()
|
||||
return PROTECTED_NAME_PATTERNS.any { p -> n.contains((p ?: "").toLowerCase()) }
|
||||
}
|
||||
def isIdProtected = { String id ->
|
||||
return PROTECTED_SCHEME_IDS.any { it?.toString() == id?.toString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) Alle Projekte holen (id + key)
|
||||
*/
|
||||
def projects = []
|
||||
def startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/project/search?startAt=${startAt}&maxResults=${PROJECT_PAGE_SIZE}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|PROJECT_SEARCH_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
projects.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
|
||||
startAt += PROJECT_PAGE_SIZE
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_PROJECTS|${projects.size()}")
|
||||
|
||||
def projectIdToKey = [:]
|
||||
projects.each { p ->
|
||||
def pid = p?.id?.toString()
|
||||
def pkey = p?.key?.toString()
|
||||
if (pid && pkey) projectIdToKey[pid] = pkey
|
||||
}
|
||||
|
||||
/**
|
||||
* 2) Mapping: fieldConfigurationSchemeId -> [projectIds]
|
||||
* (Endpoint braucht projectId, daher über alle Projekte iterieren)
|
||||
*
|
||||
* REST: GET /rest/api/3/fieldconfigurationscheme/project?projectId={projectId}
|
||||
*/
|
||||
def schemeToProjectIds = [:].withDefault { [] }
|
||||
|
||||
projectIdToKey.keySet().each { pid ->
|
||||
def resp = get("/rest/api/3/fieldconfigurationscheme/project?projectId=${pid}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.warn("WARN|SCHEME_LOOKUP_FAILED|projectId=${pid}|status=${resp.status}")
|
||||
return
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
values.each { row ->
|
||||
def schemeId = row?.fieldConfigurationScheme?.id?.toString()
|
||||
def pids = (row?.projectIds ?: []).collect { it?.toString() }.findAll { it != null }
|
||||
|
||||
if (schemeId) {
|
||||
schemeToProjectIds[schemeId] = (schemeToProjectIds[schemeId] + pids).unique()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) Alle Field Configuration Schemes holen
|
||||
*
|
||||
* REST: GET /rest/api/3/fieldconfigurationscheme
|
||||
*/
|
||||
def schemes = []
|
||||
startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/fieldconfigurationscheme?startAt=${startAt}&maxResults=${SCHEME_PAGE_SIZE}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|SCHEME_LIST_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
schemes.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
|
||||
startAt += SCHEME_PAGE_SIZE
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_SCHEMES|${schemes.size()}")
|
||||
|
||||
/**
|
||||
* 4) Auswertung + optional Delete
|
||||
*/
|
||||
def keptAssociated = 0
|
||||
def keptProtected = 0
|
||||
def candidates = [] // [id,name,reason]
|
||||
|
||||
schemes.each { s ->
|
||||
def schemeId = s?.id?.toString()
|
||||
def schemeName = s?.name?.toString()
|
||||
|
||||
def assocProjectIds = schemeToProjectIds[schemeId] ?: []
|
||||
def assocProjectKeys = assocProjectIds.collect { projectIdToKey[it] }.findAll { it != null }.unique()
|
||||
|
||||
def nameProtected = isNameProtected(schemeName)
|
||||
def idProtected = isIdProtected(schemeId)
|
||||
|
||||
if (idProtected || nameProtected) {
|
||||
keptProtected++
|
||||
def why = []
|
||||
if (idProtected) why << "ID_PROTECTED"
|
||||
if (nameProtected) why << "NAME_PROTECTED"
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|assocProjects=${assocProjectKeys}|reason=${why}")
|
||||
return
|
||||
}
|
||||
|
||||
if (!assocProjectIds.isEmpty()) {
|
||||
keptAssociated++
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|assocProjects=${assocProjectKeys}|reason=ASSOCIATED_TO_PROJECTS")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("DEL?|schemeId=${schemeId}|name=${schemeName}|assocProjects=[]|reason=UNASSOCIATED")
|
||||
candidates << [schemeId, schemeName, "UNASSOCIATED"]
|
||||
|
||||
if (!DRY_RUN) {
|
||||
def delResp = delete("/rest/api/3/fieldconfigurationscheme/${schemeId}")
|
||||
.asString()
|
||||
|
||||
if (delResp.status == 204) {
|
||||
logger.info("DEL|OK|schemeId=${schemeId}|name=${schemeName}")
|
||||
} else {
|
||||
logger.error("DEL|FAIL|schemeId=${schemeId}|name=${schemeName}|status=${delResp.status}|body=${delResp.body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("=== SUMMARY ===")
|
||||
logger.info("Total schemes: ${schemes.size()}")
|
||||
logger.info("Kept (associated to any projects): ${keptAssociated}")
|
||||
logger.info("Kept (protected by rules): ${keptProtected}")
|
||||
logger.info("Delete candidates: ${candidates.size()}")
|
||||
|
||||
candidates.each { c ->
|
||||
logger.info("CANDIDATE|schemeId=${c[0]}|name=${c[1]}|reason=${c[2]}")
|
||||
}
|
||||
|
||||
logger.info("=== DONE ===")
|
||||
180
Console - Maintenance/06. Unused Screen Schemes cleaner.groovy
Normal file
180
Console - Maintenance/06. Unused Screen Schemes cleaner.groovy
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Screen Scheme Housekeeping (Cloud) – über Issue Type Screen Scheme Mappings (zuverlässig)
|
||||
* --------------------------------------------------------------------------------------
|
||||
* Löschen nur, wenn ein Screen Scheme nirgendwo in IssueTypeScreenScheme-Mappings referenziert ist.
|
||||
* Jira verhindert Delete sowieso, wenn es noch referenziert wird. :contentReference[oaicite:2]{index=2}
|
||||
*/
|
||||
|
||||
def PROJECT_KEYS = ["NIN","NICS","NINPDS","NINPDSARC","CS","CRON"]
|
||||
|
||||
def PROTECTED_SCHEME_IDS = [
|
||||
// "1" // Default Screen Scheme ggf. hart schützen
|
||||
]
|
||||
def PROTECTED_NAME_PATTERNS = [
|
||||
"default",
|
||||
"system",
|
||||
"jira"
|
||||
]
|
||||
|
||||
def DRY_RUN = true
|
||||
def PROJECT_PAGE_SIZE = 50
|
||||
def SCREEN_SCHEME_PAGE_SIZE = 100
|
||||
def MAPPING_PAGE_SIZE = 100
|
||||
|
||||
logger.info("=== Screen Scheme Housekeeping (via IssueTypeScreenScheme mappings) ===")
|
||||
logger.info("Projects: ${PROJECT_KEYS}")
|
||||
logger.info("Protected scheme IDs: ${PROTECTED_SCHEME_IDS}")
|
||||
logger.info("Protected name patterns: ${PROTECTED_NAME_PATTERNS}")
|
||||
logger.info("DRY_RUN: ${DRY_RUN}")
|
||||
|
||||
def isNameProtected = { String name ->
|
||||
def n = (name ?: "").toLowerCase()
|
||||
PROTECTED_NAME_PATTERNS.any { p -> n.contains((p ?: "").toLowerCase()) }
|
||||
}
|
||||
def isIdProtected = { String id ->
|
||||
PROTECTED_SCHEME_IDS.any { it?.toString() == id?.toString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) Project Key -> Project ID
|
||||
*/
|
||||
def projectIds = [:] // key -> id
|
||||
PROJECT_KEYS.each { key ->
|
||||
def resp = get("/rest/api/3/project/${key}").asObject(Map)
|
||||
if (resp.status == 200) {
|
||||
projectIds[key] = resp.body?.id?.toString()
|
||||
logger.info("INFO|PROJECT|${key}|id=${projectIds[key]}")
|
||||
} else {
|
||||
logger.warn("WARN|PROJECT_LOOKUP_FAILED|${key}|status=${resp.status}")
|
||||
}
|
||||
}
|
||||
def validProjectIds = projectIds.values().findAll { it != null }.unique()
|
||||
|
||||
/**
|
||||
* 2) Aus Projekten die IssueTypeScreenSchemeIds einsammeln
|
||||
* GET /rest/api/3/issuetypescreenscheme/project?projectId=...
|
||||
*/
|
||||
def issueTypeScreenSchemeIds = [] as Set
|
||||
|
||||
validProjectIds.each { pid ->
|
||||
def resp = get("/rest/api/3/issuetypescreenscheme/project?projectId=${pid}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.warn("WARN|ITSCS_FOR_PROJECT_FAILED|projectId=${pid}|status=${resp.status}")
|
||||
return
|
||||
}
|
||||
|
||||
(resp.body?.values ?: []).each { row ->
|
||||
def itscsId = row?.issueTypeScreenScheme?.id?.toString()
|
||||
if (itscsId) issueTypeScreenSchemeIds << itscsId
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("INFO|ISSUETYPE_SCREENSCHEME_IDS|count=${issueTypeScreenSchemeIds.size()}|ids=${issueTypeScreenSchemeIds}")
|
||||
|
||||
/**
|
||||
* 3) Für jedes IssueTypeScreenSchemeId: Mappings holen und alle screenSchemeIds sammeln
|
||||
* GET /rest/api/3/issuetypescreenscheme/mapping?issueTypeScreenSchemeId=... (paging)
|
||||
*/
|
||||
def referencedScreenSchemeIds = [] as Set
|
||||
|
||||
issueTypeScreenSchemeIds.each { itscsId ->
|
||||
def startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/issuetypescreenscheme/mapping?issueTypeScreenSchemeId=${itscsId}&startAt=${startAt}&maxResults=${MAPPING_PAGE_SIZE}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.warn("WARN|ITSCS_MAPPING_FAILED|itscsId=${itscsId}|status=${resp.status}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
values.each { m ->
|
||||
def screenSchemeId = m?.screenSchemeId?.toString()
|
||||
if (screenSchemeId) referencedScreenSchemeIds << screenSchemeId
|
||||
}
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
startAt += MAPPING_PAGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("INFO|REFERENCED_SCREENSCHEME_IDS|count=${referencedScreenSchemeIds.size()}")
|
||||
|
||||
/**
|
||||
* 4) Alle Screen Schemes holen, Kandidaten bestimmen
|
||||
*/
|
||||
def allScreenSchemes = []
|
||||
def startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/screenscheme?startAt=${startAt}&maxResults=${SCREEN_SCHEME_PAGE_SIZE}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|SCREENSCHEME_LIST_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
allScreenSchemes.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
startAt += SCREEN_SCHEME_PAGE_SIZE
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_SCREEN_SCHEMES|${allScreenSchemes.size()}")
|
||||
|
||||
def keptReferenced = 0
|
||||
def keptProtected = 0
|
||||
def candidates = [] // [id,name,reason]
|
||||
|
||||
allScreenSchemes.each { ss ->
|
||||
def id = ss?.id?.toString()
|
||||
def name = ss?.name?.toString()
|
||||
|
||||
def nameProtected = isNameProtected(name)
|
||||
def idProtected = isIdProtected(id)
|
||||
|
||||
if (nameProtected || idProtected) {
|
||||
keptProtected++
|
||||
def why = []
|
||||
if (idProtected) why << "ID_PROTECTED"
|
||||
if (nameProtected) why << "NAME_PROTECTED"
|
||||
logger.info("KEEP|screenSchemeId=${id}|name=${name}|reason=${why}")
|
||||
return
|
||||
}
|
||||
|
||||
if (referencedScreenSchemeIds.contains(id)) {
|
||||
keptReferenced++
|
||||
logger.info("KEEP|screenSchemeId=${id}|name=${name}|reason=REFERENCED_BY_ISSUETYPE_SCREENSCHEME")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("DEL?|screenSchemeId=${id}|name=${name}|reason=NOT_REFERENCED_ANYWHERE")
|
||||
candidates << [id, name, "NOT_REFERENCED_ANYWHERE"]
|
||||
|
||||
if (!DRY_RUN) {
|
||||
def delResp = delete("/rest/api/3/screenscheme/${id}").asString()
|
||||
if (delResp.status == 204) {
|
||||
logger.info("DEL|OK|screenSchemeId=${id}|name=${name}")
|
||||
} else {
|
||||
logger.error("DEL|FAIL|screenSchemeId=${id}|name=${name}|status=${delResp.status}|body=${delResp.body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("=== SUMMARY ===")
|
||||
logger.info("Total screen schemes: ${allScreenSchemes.size()}")
|
||||
logger.info("Kept (referenced): ${keptReferenced}")
|
||||
logger.info("Kept (protected by rules): ${keptProtected}")
|
||||
logger.info("Delete candidates: ${candidates.size()}")
|
||||
|
||||
candidates.each { c ->
|
||||
logger.info("CANDIDATE|screenSchemeId=${c[0]}|name=${c[1]}|reason=${c[2]}")
|
||||
}
|
||||
|
||||
logger.info("=== DONE ===")
|
||||
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Workflow Scheme Housekeeping (Cloud) – Report + optional Delete
|
||||
* --------------------------------------------------------------
|
||||
* Delete rule:
|
||||
* - Scheme darf nur gelöscht werden, wenn es mit KEINEM Projekt verknüpft ist.
|
||||
* - Extra-Schutz über PROTECTED_SCHEME_IDS und PROTECTED_NAME_PATTERNS.
|
||||
*
|
||||
* Mapping:
|
||||
* - Wir holen alle Projekte (id + key)
|
||||
* - Dann: GET /rest/api/3/workflowscheme/project?projectId=...
|
||||
* -> liefert für das Projekt die Workflow-Scheme-Zuordnung
|
||||
*/
|
||||
|
||||
def PROTECTED_SCHEME_IDS = [
|
||||
// "10000"
|
||||
]
|
||||
|
||||
def PROTECTED_NAME_PATTERNS = [
|
||||
"default",
|
||||
"classic"
|
||||
]
|
||||
|
||||
def DRY_RUN = true
|
||||
def PROJECT_PAGE_SIZE = 50
|
||||
def SCHEME_PAGE_SIZE = 100
|
||||
|
||||
logger.info("=== Workflow Scheme Housekeeping ===")
|
||||
logger.info("Protected scheme IDs: ${PROTECTED_SCHEME_IDS}")
|
||||
logger.info("Protected name patterns: ${PROTECTED_NAME_PATTERNS}")
|
||||
logger.info("DRY_RUN: ${DRY_RUN}")
|
||||
|
||||
def isNameProtected = { String name ->
|
||||
def n = (name ?: "").toLowerCase()
|
||||
return PROTECTED_NAME_PATTERNS.any { p -> n.contains((p ?: "").toLowerCase()) }
|
||||
}
|
||||
def isIdProtected = { String id ->
|
||||
return PROTECTED_SCHEME_IDS.any { it?.toString() == id?.toString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) Alle Projekte holen (id + key)
|
||||
*/
|
||||
def projects = []
|
||||
def startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/project/search?startAt=${startAt}&maxResults=${PROJECT_PAGE_SIZE}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|PROJECT_SEARCH_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
projects.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
|
||||
startAt += PROJECT_PAGE_SIZE
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_PROJECTS|${projects.size()}")
|
||||
|
||||
def projectIdToKey = [:]
|
||||
projects.each { p ->
|
||||
def pid = p?.id?.toString()
|
||||
def pkey = p?.key?.toString()
|
||||
if (pid && pkey) projectIdToKey[pid] = pkey
|
||||
}
|
||||
|
||||
/**
|
||||
* 2) Mapping: workflowSchemeId -> [projectIds]
|
||||
* REST: GET /rest/api/3/workflowscheme/project?projectId={projectId}
|
||||
*/
|
||||
def schemeToProjectIds = [:].withDefault { [] }
|
||||
|
||||
projectIdToKey.keySet().each { pid ->
|
||||
|
||||
def resp = get("/rest/api/3/workflowscheme/project?projectId=${pid}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.warn("WARN|WFSCHEME_LOOKUP_FAILED|projectId=${pid}|status=${resp.status}")
|
||||
return
|
||||
}
|
||||
|
||||
// Response: values: [ { workflowScheme: {id,name,...}, projectIds:[...] }, ... ]
|
||||
def values = resp.body?.values ?: []
|
||||
values.each { row ->
|
||||
def schemeId = row?.workflowScheme?.id?.toString()
|
||||
def pids = (row?.projectIds ?: []).collect { it?.toString() }.findAll { it != null }
|
||||
|
||||
if (schemeId) {
|
||||
schemeToProjectIds[schemeId] = (schemeToProjectIds[schemeId] + pids).unique()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) Alle Workflow Schemes holen
|
||||
* REST: GET /rest/api/3/workflowscheme
|
||||
*/
|
||||
def schemes = []
|
||||
startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/workflowscheme?startAt=${startAt}&maxResults=${SCHEME_PAGE_SIZE}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|WFSCHEME_LIST_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
break
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
schemes.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
|
||||
startAt += SCHEME_PAGE_SIZE
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_WORKFLOW_SCHEMES|${schemes.size()}")
|
||||
|
||||
/**
|
||||
* 4) Auswertung + optional Delete
|
||||
*/
|
||||
def keptAssociated = 0
|
||||
def keptProtected = 0
|
||||
def candidates = [] // [id,name,reason]
|
||||
|
||||
schemes.each { s ->
|
||||
def schemeId = s?.id?.toString()
|
||||
def schemeName = s?.name?.toString()
|
||||
|
||||
def assocProjectIds = schemeToProjectIds[schemeId] ?: []
|
||||
def assocProjectKeys = assocProjectIds.collect { projectIdToKey[it] }.findAll { it != null }.unique()
|
||||
|
||||
def nameProtected = isNameProtected(schemeName)
|
||||
def idProtected = isIdProtected(schemeId)
|
||||
|
||||
if (idProtected || nameProtected) {
|
||||
keptProtected++
|
||||
def why = []
|
||||
if (idProtected) why << "ID_PROTECTED"
|
||||
if (nameProtected) why << "NAME_PROTECTED"
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|assocProjects=${assocProjectKeys}|reason=${why}")
|
||||
return
|
||||
}
|
||||
|
||||
if (!assocProjectIds.isEmpty()) {
|
||||
keptAssociated++
|
||||
logger.info("KEEP|schemeId=${schemeId}|name=${schemeName}|assocProjects=${assocProjectKeys}|reason=ASSOCIATED_TO_PROJECTS")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("DEL?|schemeId=${schemeId}|name=${schemeName}|assocProjects=[]|reason=UNASSOCIATED")
|
||||
candidates << [schemeId, schemeName, "UNASSOCIATED"]
|
||||
|
||||
if (!DRY_RUN) {
|
||||
def delResp = delete("/rest/api/3/workflowscheme/${schemeId}")
|
||||
.asString()
|
||||
|
||||
if (delResp.status == 204) {
|
||||
logger.info("DEL|OK|schemeId=${schemeId}|name=${schemeName}")
|
||||
} else {
|
||||
logger.error("DEL|FAIL|schemeId=${schemeId}|name=${schemeName}|status=${delResp.status}|body=${delResp.body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("=== SUMMARY ===")
|
||||
logger.info("Total workflow schemes: ${schemes.size()}")
|
||||
logger.info("Kept (associated to any projects): ${keptAssociated}")
|
||||
logger.info("Kept (protected by rules): ${keptProtected}")
|
||||
logger.info("Delete candidates: ${candidates.size()}")
|
||||
|
||||
candidates.each { c ->
|
||||
logger.info("CANDIDATE|schemeId=${c[0]}|name=${c[1]}|reason=${c[2]}")
|
||||
}
|
||||
|
||||
logger.info("=== DONE ===")
|
||||
163
Console - Maintenance/08. Unused Workflow cleaner.groovy
Normal file
163
Console - Maintenance/08. Unused Workflow cleaner.groovy
Normal file
@ -0,0 +1,163 @@
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Workflow Housekeeping (Cloud) – FIX: workflowSchemes.values korrekt auswerten
|
||||
* ----------------------------------------------------------------------------
|
||||
* Search: GET /rest/api/3/workflow/search -> values[].id.{name,entityId}
|
||||
* Usage: GET /rest/api/3/workflow/{workflowId}/workflowSchemes
|
||||
* Delete: DEL /rest/api/3/workflow/{workflowId}
|
||||
*
|
||||
* WICHTIG: Usage-Response hat workflowSchemes.values (nicht body.values).
|
||||
*/
|
||||
|
||||
def DRY_RUN = true // <<< für "hart löschen" auf false
|
||||
|
||||
def PROTECTED_WORKFLOW_ENTITY_IDS = [
|
||||
// z.B. "ec4480b2-623a-4b9b-78c0-2af0d15196ff" // classic default workflow
|
||||
]
|
||||
|
||||
def PROTECTED_NAME_PATTERNS = [
|
||||
"classic"
|
||||
]
|
||||
|
||||
def PAGE_SIZE = 50
|
||||
def USAGE_PAGE_SIZE = 50
|
||||
|
||||
logger.info("=== Workflow Housekeeping (FIX usage parsing) ===")
|
||||
logger.info("Protected workflow entityIds: ${PROTECTED_WORKFLOW_ENTITY_IDS}")
|
||||
logger.info("Protected name patterns: ${PROTECTED_NAME_PATTERNS}")
|
||||
logger.info("DRY_RUN: ${DRY_RUN}")
|
||||
|
||||
def isNameProtected = { String name ->
|
||||
def n = (name ?: "").toLowerCase()
|
||||
PROTECTED_NAME_PATTERNS.any { p -> n.contains((p ?: "").toLowerCase()) }
|
||||
}
|
||||
def isEntityIdProtected = { String entityId ->
|
||||
PROTECTED_WORKFLOW_ENTITY_IDS.any { it?.toString() == entityId?.toString() }
|
||||
}
|
||||
|
||||
// Encode für Pfadsegmente, damit auch "Builds Workflow" kein URI-Problem macht
|
||||
def encPath = { String s ->
|
||||
URLEncoder.encode(s ?: "", "UTF-8").replace("+", "%20")
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage korrekt lesen:
|
||||
* body.workflowSchemes.values
|
||||
* body.workflowSchemes.nextPageToken
|
||||
*
|
||||
* Doku: GET /rest/api/3/workflow/{workflowId}/workflowSchemes :contentReference[oaicite:1]{index=1}
|
||||
*/
|
||||
def getWorkflowSchemeUsageCount = { String workflowId ->
|
||||
int count = 0
|
||||
String token = null
|
||||
|
||||
while (true) {
|
||||
def url = "/rest/api/3/workflow/${encPath(workflowId)}/workflowSchemes?maxResults=${USAGE_PAGE_SIZE}" +
|
||||
(token ? "&nextPageToken=${URLEncoder.encode(token, 'UTF-8')}" : "")
|
||||
|
||||
def resp = get(url).asObject(Map)
|
||||
if (resp.status != 200) {
|
||||
return [failed: true, status: resp.status, body: resp.body, count: count]
|
||||
}
|
||||
|
||||
def body = resp.body ?: [:]
|
||||
def ws = body?.workflowSchemes ?: [:]
|
||||
def values = ws?.values ?: []
|
||||
count += values.size()
|
||||
|
||||
token = ws?.nextPageToken
|
||||
if (!token) break
|
||||
}
|
||||
|
||||
return [failed: false, count: count]
|
||||
}
|
||||
|
||||
// 1) Workflows holen (dein JSON: values[].id.{name,entityId})
|
||||
def raw = []
|
||||
def startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/workflow/search?startAt=${startAt}&maxResults=${PAGE_SIZE}")
|
||||
.asObject(Map)
|
||||
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|WF_SEARCH_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
return
|
||||
}
|
||||
|
||||
def values = resp.body?.values ?: []
|
||||
raw.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
|
||||
startAt += PAGE_SIZE
|
||||
}
|
||||
|
||||
logger.info("INFO|TOTAL_ITEMS_FROM_SEARCH|${raw.size()}")
|
||||
|
||||
def workflows = raw.collect { wf ->
|
||||
def idObj = wf?.id
|
||||
def name = (idObj instanceof Map) ? idObj?.name?.toString() : null
|
||||
def entityId = (idObj instanceof Map) ? idObj?.entityId?.toString() : null
|
||||
return [name: name, entityId: entityId]
|
||||
}.findAll { it.entityId && it.name }
|
||||
|
||||
logger.info("INFO|REAL_WORKFLOWS|${workflows.size()}")
|
||||
logger.info("INFO|SKIPPED_ITEMS_NO_NAME_OR_ENTITYID|${raw.size() - workflows.size()}")
|
||||
|
||||
// 2) Auswertung + optional Delete
|
||||
def deleted = 0
|
||||
def keptProtected = 0
|
||||
def keptUsed = 0
|
||||
def keptUsageLookupFailed = 0
|
||||
def deleteFailures = 0
|
||||
def candidates = 0
|
||||
|
||||
workflows.each { wf ->
|
||||
def name = wf.name
|
||||
def entityId = wf.entityId
|
||||
|
||||
if (isEntityIdProtected(entityId) || isNameProtected(name)) {
|
||||
keptProtected++
|
||||
logger.info("KEEP|entityId=${entityId}|name=${name}|reason=PROTECTED")
|
||||
return
|
||||
}
|
||||
|
||||
def usage = getWorkflowSchemeUsageCount(entityId)
|
||||
if (usage.failed) {
|
||||
keptUsageLookupFailed++
|
||||
logger.info("KEEP|entityId=${entityId}|name=${name}|reason=USAGE_LOOKUP_FAILED|status=${usage.status}")
|
||||
return
|
||||
}
|
||||
|
||||
if (usage.count > 0) {
|
||||
keptUsed++
|
||||
logger.info("KEEP|entityId=${entityId}|name=${name}|reason=USED_BY_SCHEMES|usedBySchemes=${usage.count}")
|
||||
return
|
||||
}
|
||||
|
||||
candidates++
|
||||
logger.info("DEL?|entityId=${entityId}|name=${name}|reason=UNUSED_NO_SCHEME_ASSOC")
|
||||
|
||||
if (!DRY_RUN) {
|
||||
def delResp = delete("/rest/api/3/workflow/${encPath(entityId)}").asString()
|
||||
if (delResp.status == 204) {
|
||||
deleted++
|
||||
logger.info("DEL|OK|entityId=${entityId}|name=${name}")
|
||||
} else {
|
||||
deleteFailures++
|
||||
logger.error("DEL|FAIL|entityId=${entityId}|name=${name}|status=${delResp.status}|body=${delResp.body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("=== SUMMARY ===")
|
||||
logger.info("Real workflows processed: ${workflows.size()}")
|
||||
logger.info("Delete candidates: ${candidates}")
|
||||
logger.info("Deleted: ${deleted}")
|
||||
logger.info("Kept (protected): ${keptProtected}")
|
||||
logger.info("Kept (used by schemes): ${keptUsed}")
|
||||
logger.info("Kept (usage lookup failed): ${keptUsageLookupFailed}")
|
||||
logger.info("Delete failures: ${deleteFailures}")
|
||||
logger.info("=== DONE ===")
|
||||
222
Console - Maintenance/09. Unused Screens cleaner.groovy
Normal file
222
Console - Maintenance/09. Unused Screens cleaner.groovy
Normal file
@ -0,0 +1,222 @@
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Screens Housekeeping (Cloud) – Kandidatenliste
|
||||
* -----------------------------------------------------------
|
||||
* Ziel: Screens finden, die in keinem Screen Scheme referenziert sind.
|
||||
*
|
||||
* Hinweis:
|
||||
* - Jira verhindert Delete, wenn Screen in Screen Scheme, Workflow oder Workflow Draft verwendet wird. :contentReference[oaicite:2]{index=2}
|
||||
* - Workflow/Draft-Usage kann man per REST nicht zuverlässig „auflisten“. :contentReference[oaicite:3]{index=3}
|
||||
* => Wir prüfen hier: "nicht in Screen Schemes". Beim echten Delete fängt Jira dann ggf. "used in workflow/draft" ab.
|
||||
************************************************************************************************************************
|
||||
* !!!! ES BESTEHT WEITERHIN DIE GEFAHR, DASS SCREENS GELÖSCHT WERDEN, DIE MAN WEITERHIN FÜR TRANSITIONEN BENÖTIGT!!!
|
||||
* !!!! DAHER IST DIESES SCRIPT MIT ÄUßERSTER SORGFALT ZU VERWENDEN!!!!
|
||||
************************************************************************************************************************
|
||||
*/
|
||||
|
||||
def DRY_RUN = true
|
||||
|
||||
def PROTECTED_SCREEN_IDS = [
|
||||
// "1"
|
||||
]
|
||||
|
||||
def PROTECTED_NAME_PATTERNS = [
|
||||
"default",
|
||||
"NICS: ",
|
||||
"NIN: ",
|
||||
"NINPDS",
|
||||
"CS: ",
|
||||
"PDS:"
|
||||
]
|
||||
|
||||
def PAGE_SIZE = 100
|
||||
|
||||
logger.info("=== Screens Housekeeping ===")
|
||||
logger.info("DRY_RUN: ${DRY_RUN}")
|
||||
logger.info("Protected screen IDs: ${PROTECTED_SCREEN_IDS}")
|
||||
logger.info("Protected name patterns: ${PROTECTED_NAME_PATTERNS}")
|
||||
|
||||
def isNameProtected = { String name ->
|
||||
def n = (name ?: "").toLowerCase()
|
||||
PROTECTED_NAME_PATTERNS.any { p -> n.contains((p ?: "").toLowerCase()) }
|
||||
}
|
||||
def isIdProtected = { String id ->
|
||||
PROTECTED_SCREEN_IDS.any { it?.toString() == id?.toString() }
|
||||
}
|
||||
|
||||
// für Pfadsegmente (IDs sind numerisch, aber sicher ist sicher)
|
||||
def encPath = { String s ->
|
||||
URLEncoder.encode(s ?: "", "UTF-8").replace("+", "%20")
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect "screen id" values from a map:
|
||||
* - Viele Jira Responses haben z.B. defaultScreenId / screenId / createScreenId etc.
|
||||
* - Wir sammeln konservativ: keys die "screen" und "id" enthalten.
|
||||
*/
|
||||
def collectScreenIdsRecursive
|
||||
collectScreenIdsRecursive = { Object node, Set<String> out ->
|
||||
if (node == null) return
|
||||
if (node instanceof Map) {
|
||||
(node as Map).each { k, v ->
|
||||
def key = k?.toString()?.toLowerCase()
|
||||
if (key && key.contains("screen") && key.contains("id")) {
|
||||
if (v != null) out << v.toString()
|
||||
}
|
||||
collectScreenIdsRecursive(v, out)
|
||||
}
|
||||
} else if (node instanceof List) {
|
||||
(node as List).each { item -> collectScreenIdsRecursive(item, out) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) Alle Screens holen
|
||||
* API: GET /rest/api/3/screens (plural). :contentReference[oaicite:4]{index=4}
|
||||
*/
|
||||
def screens = []
|
||||
def startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/screens?startAt=${startAt}&maxResults=${PAGE_SIZE}").asObject(Map)
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|SCREENS_LIST_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
return
|
||||
}
|
||||
def values = resp.body?.values ?: []
|
||||
screens.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
startAt += PAGE_SIZE
|
||||
}
|
||||
logger.info("INFO|TOTAL_SCREENS|${screens.size()}")
|
||||
|
||||
/**
|
||||
* 2) Alle Screen Schemes holen
|
||||
* API: GET /rest/api/3/screenscheme :contentReference[oaicite:5]{index=5}
|
||||
*/
|
||||
def screenSchemes = []
|
||||
startAt = 0
|
||||
while (true) {
|
||||
def resp = get("/rest/api/3/screenscheme?startAt=${startAt}&maxResults=${PAGE_SIZE}").asObject(Map)
|
||||
if (resp.status != 200) {
|
||||
logger.error("ERROR|SCREENSCHEME_LIST_FAILED|status=${resp.status}|body=${resp.body}")
|
||||
return
|
||||
}
|
||||
def values = resp.body?.values ?: []
|
||||
screenSchemes.addAll(values)
|
||||
|
||||
def isLast = resp.body?.isLast
|
||||
if (isLast == true || values.isEmpty()) break
|
||||
startAt += PAGE_SIZE
|
||||
}
|
||||
logger.info("INFO|TOTAL_SCREEN_SCHEMES|${screenSchemes.size()}")
|
||||
|
||||
/**
|
||||
* 3) Referenz-Mapping: screenId -> [schemeNames...]
|
||||
* Wir holen pro Scheme die Details und sammeln alle enthaltenen screenIds.
|
||||
*/
|
||||
def usedBySchemeNames = [:].withDefault { [] as List<String> } // screenId -> schemeNames
|
||||
def usedScreenIds = new HashSet<String>()
|
||||
|
||||
screenSchemes.each { ss ->
|
||||
def ssId = ss?.id?.toString()
|
||||
def ssName = ss?.name?.toString() ?: "?"
|
||||
|
||||
if (!ssId) return
|
||||
|
||||
def resp = get("/rest/api/3/screenscheme/${encPath(ssId)}").asObject(Map)
|
||||
if (resp.status != 200) {
|
||||
logger.warn("WARN|SCREENSCHEME_DETAILS_FAILED|schemeId=${ssId}|name=${ssName}|status=${resp.status}")
|
||||
return
|
||||
}
|
||||
|
||||
def found = new HashSet<String>()
|
||||
collectScreenIdsRecursive(resp.body, found)
|
||||
|
||||
found.each { sid ->
|
||||
usedScreenIds << sid
|
||||
usedBySchemeNames[sid] = (usedBySchemeNames[sid] + ssName).unique()
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("INFO|USED_SCREEN_IDS|${usedScreenIds.size()}")
|
||||
|
||||
/**
|
||||
* 4) Auswertung
|
||||
* Kandidat = Screen wird in keinem Screen Scheme referenziert + nicht protected
|
||||
*/
|
||||
def candidates = []
|
||||
def keptProtected = 0
|
||||
def keptUsed = 0
|
||||
|
||||
screens.each { s ->
|
||||
def screenId = s?.id?.toString()
|
||||
def screenName = s?.name?.toString()
|
||||
|
||||
if (!screenId) return
|
||||
|
||||
if (isIdProtected(screenId) || isNameProtected(screenName)) {
|
||||
keptProtected++
|
||||
logger.info("KEEP|screenId=${screenId}|name=${screenName}|reason=PROTECTED")
|
||||
return
|
||||
}
|
||||
|
||||
def schemes = usedBySchemeNames[screenId] ?: []
|
||||
if (!schemes.isEmpty()) {
|
||||
keptUsed++
|
||||
// optional kurz halten: nur bis zu 5 scheme names
|
||||
def preview = schemes.take(5)
|
||||
logger.info("KEEP|screenId=${screenId}|name=${screenName}|reason=USED_BY_SCREEN_SCHEMES|count=${schemes.size()}|examples=${preview}")
|
||||
return
|
||||
}
|
||||
|
||||
// sauberer Kandidat (bezogen auf Screen Schemes)
|
||||
candidates << [id: screenId, name: screenName]
|
||||
}
|
||||
|
||||
/**
|
||||
* 5) Saubere Kandidatenliste (Dry run)
|
||||
* 1 Zeile pro Screen, gut copy/paste
|
||||
*/
|
||||
candidates = candidates.sort { (it.name ?: "") as String }
|
||||
|
||||
logger.info("=== CANDIDATES (NOT IN ANY SCREEN SCHEME) ===")
|
||||
candidates.each { c ->
|
||||
logger.info("CANDIDATE|screenId=${c.id}|name=${c.name}")
|
||||
}
|
||||
|
||||
logger.info("=== SUMMARY ===")
|
||||
logger.info("Total screens: ${screens.size()}")
|
||||
logger.info("Kept (protected): ${keptProtected}")
|
||||
logger.info("Kept (used by screen schemes): ${keptUsed}")
|
||||
logger.info("Candidates (no screen scheme refs): ${candidates.size()}")
|
||||
|
||||
/**
|
||||
* 6) Optional delete
|
||||
* API: DELETE /rest/api/3/screens/{screenId} :contentReference[oaicite:6]{index=6}
|
||||
* Jira wird blocken, wenn Workflow/Draft etc. doch referenziert.
|
||||
*/
|
||||
if (!DRY_RUN) {
|
||||
logger.info("=== DELETE PHASE ===")
|
||||
def deleted = 0
|
||||
def failed = 0
|
||||
|
||||
candidates.each { c ->
|
||||
def delResp = delete("/rest/api/3/screens/${encPath(c.id)}").asString()
|
||||
if (delResp.status == 204) {
|
||||
deleted++
|
||||
logger.info("DEL|OK|screenId=${c.id}|name=${c.name}")
|
||||
} else {
|
||||
failed++
|
||||
logger.error("DEL|FAIL|screenId=${c.id}|name=${c.name}|status=${delResp.status}|body=${delResp.body}")
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("=== DELETE SUMMARY ===")
|
||||
logger.info("Deleted: ${deleted}")
|
||||
logger.info("Delete failures: ${failed}")
|
||||
}
|
||||
|
||||
logger.info("=== DONE ===")
|
||||
@ -1,115 +0,0 @@
|
||||
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)
|
||||
@ -1,224 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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))
|
||||
@ -1,227 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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))
|
||||
@ -1,135 +0,0 @@
|
||||
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 ITSS→IssueType→ScreenScheme 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)
|
||||
@ -1,194 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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))
|
||||
@ -0,0 +1,62 @@
|
||||
import utils.LinkedIssueTransitions
|
||||
import utils.FieldCopy
|
||||
|
||||
final String LINK_TYPE_NAME = "is cloned by"
|
||||
final String TARGET_PROJECT_KEY = "CSD"
|
||||
final String SOURCE_FIELD_ID = "customfield_11501" // ADF
|
||||
final String TARGET_FIELD_ID = "customfield_11501"
|
||||
final String LOG_PREFIX = "[CopyField->LinkedIssue]"
|
||||
|
||||
def sourceKey = issue?.key?.toString()
|
||||
if (!sourceKey) {
|
||||
logger.warn("${LOG_PREFIX} Kein issue.key im Kontext. Abbruch.")
|
||||
return
|
||||
}
|
||||
|
||||
// Source laden: Links + Feld
|
||||
def issueResp = get("/rest/api/3/issue/${sourceKey}")
|
||||
.queryString("fields", "issuelinks,${SOURCE_FIELD_ID}")
|
||||
.asObject(Map)
|
||||
|
||||
if (issueResp.status != 200) {
|
||||
logger.warn("${LOG_PREFIX} Konnte ${sourceKey} nicht laden (${issueResp.status}). Body=${issueResp.body}")
|
||||
return
|
||||
}
|
||||
|
||||
def sourceJson = issueResp.body
|
||||
|
||||
// Ziel ermitteln
|
||||
def targetKey = LinkedIssueTransitions.findSingleLinkedTargetKey(
|
||||
sourceJson,
|
||||
LINK_TYPE_NAME,
|
||||
TARGET_PROJECT_KEY
|
||||
)
|
||||
|
||||
if (!targetKey) {
|
||||
logger.warn("${LOG_PREFIX} Kein eindeutiges Ziel-Ticket gefunden (erwartet genau 1 Link ins Projekt ${TARGET_PROJECT_KEY}). Abbruch.")
|
||||
return
|
||||
}
|
||||
|
||||
// Feldwert lesen (ADF)
|
||||
def value = FieldCopy.getFieldValue(sourceJson, SOURCE_FIELD_ID)
|
||||
|
||||
// Falls leer: Ziel unverändert lassen
|
||||
if (value == null) {
|
||||
logger.warn("${LOG_PREFIX} Source-Feld ${SOURCE_FIELD_ID} ist null/leer in ${sourceKey}. Zielfeld bleibt unverändert.")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("${LOG_PREFIX} Kopiere ${SOURCE_FIELD_ID} von ${sourceKey} nach ${targetKey}")
|
||||
|
||||
def body = FieldCopy.buildSingleFieldUpdateBody(TARGET_FIELD_ID, value)
|
||||
|
||||
def putResp = put("/rest/api/3/issue/${targetKey}")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.asObject(Map)
|
||||
|
||||
if (putResp.status == 204) {
|
||||
logger.info("${LOG_PREFIX} OK: Feld ${TARGET_FIELD_ID} in ${targetKey} aktualisiert.")
|
||||
} else {
|
||||
logger.warn("${LOG_PREFIX} Feld-Update fehlgeschlagen: status=${putResp.status}, body=${putResp.body}")
|
||||
}
|
||||
122
Postfunctions/[CoE] Transition linked CS-Ticket.groovy
Normal file
122
Postfunctions/[CoE] Transition linked CS-Ticket.groovy
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* -----------------------------------------------------------------------------
|
||||
* Workflow Postfunction (ScriptRunner for Jira Cloud)
|
||||
* -----------------------------------------------------------------------------
|
||||
*
|
||||
* Name
|
||||
* ----------------
|
||||
* [CoE] Transition linked CSD ticket on close
|
||||
*
|
||||
* Zweck
|
||||
* -----
|
||||
* Beim Ausführen der Transition im CoE-Ticket soll ein eindeutig verlinktes
|
||||
* Ticket im Zielprojekt (z.B. CSD-xxxx) automatisch per Transition
|
||||
* weitergeschaltet werden (z.B. Status "Back from CoE").
|
||||
*
|
||||
* Prozess-Annahme
|
||||
* ---------------
|
||||
* - Es existiert genau EIN Link vom CoE-Ticket zu einem Ticket im Zielprojekt.
|
||||
* - Der Linktyp (Name) ist bekannt, z.B. "is cloned by".
|
||||
*
|
||||
* Technischer Ansatz
|
||||
* ------------------
|
||||
* - HTTP-Calls (get/post) bleiben im Workflow-Kontext, weil ScriptRunner Cloud
|
||||
* diese Helper dort zuverlässig bereitstellt.
|
||||
* - Die Ermittlung des Ziel-Tickets ist in eine Script-Manager-Utility ausgelagert:
|
||||
* utils.LinkedIssueTransitions.findSingleLinkedTargetKey(...)
|
||||
*
|
||||
* Konfiguration
|
||||
* -------------
|
||||
* - LINK_TYPE_NAME: Name der Link-Richtung (inward oder outward), wie er in Jira
|
||||
* angezeigt wird (z.B. "is cloned by").
|
||||
* - TRANSITION_ID: Die ID der Transition, die im Ziel-Ticket ausgeführt werden soll.
|
||||
* - TARGET_PROJECT_KEY: Projekt-Key des Zielprojekts (z.B. "CSD").
|
||||
*
|
||||
* Logging
|
||||
* -------
|
||||
* Das Skript loggt:
|
||||
* - Start und Konfiguration
|
||||
* - Fehlerzustände (kein Source-Key, HTTP Fehler, kein eindeutiges Target)
|
||||
* - Erfolg/Misserfolg der Transition im Ziel-Ticket
|
||||
*
|
||||
* -----------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import utils.LinkedIssueTransitions
|
||||
|
||||
// ------------------------- Konfiguration ------------------------------------
|
||||
// Linktyp-Name (Richtung) – z.B. "is cloned by"
|
||||
final String LINK_TYPE_NAME = "is cloned by"
|
||||
|
||||
// Transition im Zielprojekt – z.B. "CoE erledigt" (ID = 441)
|
||||
final String TRANSITION_ID = "441"
|
||||
|
||||
// Zielprojekt, in dem das verlinkte Ticket liegt
|
||||
final String TARGET_PROJECT_KEY = "CSD"
|
||||
|
||||
// Einheitlicher Log-Prefix (macht das Filtern in Logs leichter)
|
||||
final String LOG_PREFIX = "[CoE->Linked Transition]"
|
||||
|
||||
// ------------------------- Guard: Source Issue Key --------------------------
|
||||
// issue kommt aus dem Workflow-Kontext der Postfunction.
|
||||
def sourceKey = issue?.key?.toString()
|
||||
if (!sourceKey) {
|
||||
logger.warn("${LOG_PREFIX} Kein issue.key im Kontext. Abbruch.")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("${LOG_PREFIX} Start. Source=${sourceKey}, linkType='${LINK_TYPE_NAME}', transitionId=${TRANSITION_ID}, targetProject=${TARGET_PROJECT_KEY}")
|
||||
|
||||
// ------------------------- 1) Source Issue laden ----------------------------
|
||||
// Wir brauchen die Issue-Links (issuelinks), weil dort die verknüpften Tickets stehen.
|
||||
// Hinweis: Wir laden nur das Feld "issuelinks", um Payload klein und schnell zu halten.
|
||||
def issueResp = get("/rest/api/3/issue/${sourceKey}")
|
||||
.queryString("fields", "issuelinks")
|
||||
.asObject(Map)
|
||||
|
||||
// Jira REST: 200 = OK
|
||||
if (issueResp.status != 200) {
|
||||
logger.warn("${LOG_PREFIX} Konnte ${sourceKey} nicht laden (${issueResp.status}). Body=${issueResp.body}")
|
||||
return
|
||||
}
|
||||
|
||||
// ------------------------- 2) Zielkey finden (Utility) ----------------------
|
||||
// In der Utility stecken unsere Regeln:
|
||||
// - Filter nach Linktyp-Name (inward/outward)
|
||||
// - Filter nach Zielprojekt-Key-Prefix (z.B. "CSD-")
|
||||
// - Es muss GENAU ein Treffer sein, sonst null.
|
||||
def targetKey = LinkedIssueTransitions.findSingleLinkedTargetKey(
|
||||
issueResp.body,
|
||||
LINK_TYPE_NAME,
|
||||
TARGET_PROJECT_KEY
|
||||
)
|
||||
|
||||
if (!targetKey) {
|
||||
logger.warn("${LOG_PREFIX} Kein eindeutiges Ziel-Ticket gefunden (erwartet genau 1 Link ins Projekt ${TARGET_PROJECT_KEY}). Abbruch.")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("${LOG_PREFIX} Ziel-Ticket: ${targetKey}. Führe Transition aus…")
|
||||
|
||||
// ------------------------- 3) Transition im Ziel-Ticket ausführen -----------
|
||||
// Jira REST Transition Endpoint:
|
||||
// POST /rest/api/3/issue/{issueIdOrKey}/transitions
|
||||
//
|
||||
// Body:
|
||||
// { "transition": { "id": "441" } }
|
||||
//
|
||||
// Erfolg: typischerweise 204 (No Content)
|
||||
def transResp = post("/rest/api/3/issue/${targetKey}/transitions")
|
||||
.header("Content-Type", "application/json")
|
||||
.body([ transition: [ id: TRANSITION_ID ] ])
|
||||
.asObject(Map)
|
||||
|
||||
if (transResp.status == 204) {
|
||||
logger.info("${LOG_PREFIX} OK: ${targetKey} erfolgreich transitioniert (ID=${TRANSITION_ID}).")
|
||||
} else {
|
||||
// Häufige Fehlerursachen:
|
||||
// - Run-as User / Add-on User hat keine Berechtigung im Zielprojekt
|
||||
// - Transition-ID passt nicht zum Workflow/Status des Ziel-Tickets
|
||||
// - Ziel-Ticket ist in einem Status, in dem die Transition nicht verfügbar ist
|
||||
logger.warn("${LOG_PREFIX} Transition fehlgeschlagen für ${targetKey}: status=${transResp.status}, body=${transResp.body}")
|
||||
}
|
||||
20
README.md
20
README.md
@ -1,21 +1,3 @@
|
||||
# Jira-Scripte
|
||||
|
||||
Scripte für Automations und Workflows (pds)
|
||||
|
||||
## Repo vom Gitea auf den Server holen (clone)
|
||||
|
||||
git clone https://git.bartschatten.de/mfredrich/<repo>.git
|
||||
|
||||
## Änderungen anschauen
|
||||
|
||||
git status
|
||||
|
||||
## Änderungen committen + pushen
|
||||
|
||||
git add .
|
||||
git commit -m "describe change"
|
||||
git push
|
||||
|
||||
## Updates vom Repo holen
|
||||
|
||||
git pull
|
||||
Scripte für Automations und Workflows (pds)
|
||||
@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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<String> 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}')")
|
||||
@ -0,0 +1,201 @@
|
||||
// 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}")
|
||||
@ -0,0 +1,19 @@
|
||||
import utils.AutoCloseJob
|
||||
|
||||
final String JQL = 'project = "TS" AND status = Gelöst AND resolutiondate <= startOfDay(-14d)'
|
||||
|
||||
// Optional: harte Transition-ID oder null
|
||||
final String TRANSITION_ID = null
|
||||
|
||||
final String TARGET_STATUS_NAME = "Closed"
|
||||
|
||||
AutoCloseJob.run([
|
||||
logger : logger,
|
||||
JQL : JQL,
|
||||
TRANSITION_ID : TRANSITION_ID,
|
||||
TARGET_STATUS_NAME: TARGET_STATUS_NAME,
|
||||
MAX_PER_PAGE : 50
|
||||
])
|
||||
|
||||
|
||||
// ruft den Job im Script Manager auf utils/AutoCloseJob.groovy
|
||||
@ -0,0 +1,149 @@
|
||||
// ScriptRunner for Jira Cloud - Scheduled Job
|
||||
// Auto-close issues in "Waiting for Customer" after X days inactivity.
|
||||
// Steps:
|
||||
// 1) JQL search (paging via nextPageToken)
|
||||
// 2) Add comment (ADF) BEFORE closing (so customer gets notification)
|
||||
// 3) Transition to "Closed" via transition ID
|
||||
|
||||
// -------------------- Konfiguration --------------------
|
||||
final String projectKey = "TS"
|
||||
final String waitingStatusName = "Waiting for Customer"
|
||||
//final String waitingStatusName = "Wartet auf Kunden"
|
||||
final int inactivityDays = 14
|
||||
final int maxPerPage = 50
|
||||
final boolean dryRun = false
|
||||
|
||||
// Transition "Schließen"
|
||||
final String transitionIdClose = "2"
|
||||
|
||||
// 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 = ${projectKey} AND status = '${waitingStatusName}' AND resolution IS EMPTY AND updated < startOfDay(-${inactivityDays})"
|
||||
|
||||
// ## Test mit Einzelticket #####
|
||||
// final String jql = "project = ${projectKey} AND key = TS-35"
|
||||
|
||||
// -------------------- Helper --------------------
|
||||
Map buildAdfComment(String text) {
|
||||
[
|
||||
type : "doc",
|
||||
version: 1,
|
||||
content: [[
|
||||
type : "paragraph",
|
||||
content: [[type: "text", text: text]]
|
||||
]]
|
||||
]
|
||||
}
|
||||
|
||||
boolean addComment(String issueKey, String commentText) {
|
||||
def resp = post("/rest/api/3/issue/${issueKey}/comment")
|
||||
.header("Content-Type", "application/json")
|
||||
.body([body: buildAdfComment(commentText)])
|
||||
.asObject(Map)
|
||||
|
||||
if (!(resp.status in [200, 201])) {
|
||||
logger.error("${issueKey}: Kommentar fehlgeschlagen: ${resp.status} - ${resp.body}")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
boolean closeIssue(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) {
|
||||
logger.error("${issueKey}: Schließen fehlgeschlagen (transitionId=${transitionId}): ${resp.status} - ${resp.body}")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// -------------------- Ablauf --------------------
|
||||
logger.info("=== Auto-Close Job gestartet ===")
|
||||
logger.info("JQL: ${jql}")
|
||||
logger.info("Transition ID (Close): ${transitionIdClose}")
|
||||
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())
|
||||
// Nur Felder, die wir wirklich brauchen
|
||||
.queryString("fields", "status,resolution,updated")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
logger.info("${issueKey}: Kandidat (updated=${updated})")
|
||||
|
||||
if (dryRun) {
|
||||
logger.info("${issueKey}: DRY_RUN -> würde kommentieren + schließen")
|
||||
return
|
||||
}
|
||||
|
||||
// 1) Kommentar vor dem Schließen
|
||||
String commentText = String.format(commentTemplate, inactivityDays, issueKey)
|
||||
if (!addComment(issueKey, commentText)) {
|
||||
failed++
|
||||
return
|
||||
}
|
||||
|
||||
// 2) Schließen
|
||||
if (!closeIssue(issueKey, transitionIdClose)) {
|
||||
failed++
|
||||
return
|
||||
}
|
||||
|
||||
closed++
|
||||
logger.info("${issueKey}: erfolgreich geschlossen (transitionId=${transitionIdClose})")
|
||||
|
||||
}
|
||||
|
||||
if (!nextPageToken) break
|
||||
}
|
||||
|
||||
logger.info("=== Auto-Close Job fertig ===")
|
||||
logger.info("Processed=${processed}, Closed=${closed}, Skipped=${skipped}, Failed=${failed}")
|
||||
128
Script Manager/utils/FieldCopy.groovy
Normal file
128
Script Manager/utils/FieldCopy.groovy
Normal file
@ -0,0 +1,128 @@
|
||||
package utils
|
||||
|
||||
/**
|
||||
* -----------------------------------------------------------------------------
|
||||
* FieldCopy (Utility)
|
||||
* -----------------------------------------------------------------------------
|
||||
*
|
||||
* Zweck
|
||||
* -----
|
||||
* - Extrahiert Feldwerte aus einem Issue-JSON (Map), wie von Jira REST geliefert
|
||||
* - Baut Update-Payloads für PUT /rest/api/3/issue/{key}
|
||||
* - Unterstützt mehrere Felder per Mapping-Liste
|
||||
*
|
||||
* WICHTIG
|
||||
* -------
|
||||
* - Kein HTTP hier drin (kein get/put/post). Nur pure Logik.
|
||||
* - ADF (Rich Text / Absatz) wird 1:1 als Map übernommen.
|
||||
*
|
||||
* Mapping-Format
|
||||
* --------------
|
||||
* List von Maps:
|
||||
* [
|
||||
* [source: "customfield_11501", target: "customfield_11501", allowNull: false],
|
||||
* [source: "customfield_12345", target: "customfield_99999", allowNull: true ]
|
||||
* ]
|
||||
*
|
||||
* allowNull=false: null wird NICHT geschrieben (Zielfeld bleibt unverändert)
|
||||
* allowNull=true : null wird geschrieben (Zielfeld wird geleert)
|
||||
* -----------------------------------------------------------------------------
|
||||
*/
|
||||
class FieldCopy {
|
||||
|
||||
/**
|
||||
* Liest den Rohwert eines Feldes aus dem Issue-JSON.
|
||||
*/
|
||||
static Object getFieldValue(Map issueJson, String fieldId) {
|
||||
if (issueJson == null) return null
|
||||
def fields = issueJson.get("fields")
|
||||
if (!(fields instanceof Map)) return null
|
||||
return (fields as Map).get(fieldId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut einen Update-Body für ein einzelnes Feld.
|
||||
*
|
||||
* @return Map im Format: [fields: [(fieldId): value]]
|
||||
*/
|
||||
static Map buildSingleFieldUpdateBody(String fieldId, Object value) {
|
||||
Map fieldsPayload = [:]
|
||||
fieldsPayload.put(fieldId, value)
|
||||
|
||||
Map body = [:]
|
||||
body.put("fields", fieldsPayload)
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut einen Update-Body für mehrere Felder anhand der Mapping-Liste.
|
||||
*
|
||||
* @return Map {fields:{...}} oder null, wenn nichts geschrieben werden soll
|
||||
*/
|
||||
static Map buildMultiFieldUpdateBody(Map sourceJson, List fieldMappings) {
|
||||
if (sourceJson == null) return null
|
||||
if (fieldMappings == null || fieldMappings.isEmpty()) return null
|
||||
|
||||
Map fieldsPayload = [:]
|
||||
|
||||
for (def m : fieldMappings) {
|
||||
if (!(m instanceof Map)) {
|
||||
continue
|
||||
}
|
||||
|
||||
String sourceField = (m.get("source") ?: "").toString()
|
||||
if (!sourceField) {
|
||||
continue
|
||||
}
|
||||
|
||||
String targetField = m.containsKey("target") && m.get("target") != null
|
||||
? m.get("target").toString()
|
||||
: sourceField
|
||||
|
||||
boolean allowNull = false
|
||||
if (m.containsKey("allowNull") && m.get("allowNull") != null) {
|
||||
allowNull = (m.get("allowNull") as Boolean)
|
||||
}
|
||||
|
||||
Object value = getFieldValue(sourceJson, sourceField)
|
||||
|
||||
// null nur setzen, wenn explizit erlaubt
|
||||
if (value == null && !allowNull) {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldsPayload.put(targetField, value)
|
||||
}
|
||||
|
||||
if (fieldsPayload.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
Map body = [:]
|
||||
body.put("fields", fieldsPayload)
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsfunktion: Liefert die Source-Feldliste (für Jira "fields" QueryString)
|
||||
* als Komma-Sequenz: "customfield_1,customfield_2"
|
||||
*/
|
||||
static String buildSourceFieldQuery(List fieldMappings) {
|
||||
if (fieldMappings == null || fieldMappings.isEmpty()) return ""
|
||||
|
||||
List result = []
|
||||
|
||||
for (def m : fieldMappings) {
|
||||
if (!(m instanceof Map)) continue
|
||||
def s = m.get("source")
|
||||
if (s == null) continue
|
||||
String sourceField = s.toString()
|
||||
if (!sourceField) continue
|
||||
if (!result.contains(sourceField)) {
|
||||
result.add(sourceField)
|
||||
}
|
||||
}
|
||||
|
||||
return result.join(",")
|
||||
}
|
||||
}
|
||||
33
Script Manager/utils/LinkedIssueT...tions.groovy
Normal file
33
Script Manager/utils/LinkedIssueT...tions.groovy
Normal file
@ -0,0 +1,33 @@
|
||||
package utils
|
||||
|
||||
class LinkedIssueTransitions {
|
||||
|
||||
static String findSingleLinkedTargetKey(Map issueJson,
|
||||
String linkTypeName,
|
||||
String targetProjectKey) {
|
||||
def links = (issueJson?.fields?.issuelinks ?: []) as List
|
||||
if (!links) return null
|
||||
|
||||
String prefix = "${targetProjectKey}-"
|
||||
def targets = [] as List<String>
|
||||
|
||||
links.each { l ->
|
||||
def inwardName = l?.type?.inward?.toString()
|
||||
def outwardName = l?.type?.outward?.toString()
|
||||
|
||||
if (inwardName == linkTypeName && l?.inwardIssue?.key) {
|
||||
def k = l.inwardIssue.key.toString()
|
||||
if (k.startsWith(prefix)) targets << k
|
||||
}
|
||||
if (outwardName == linkTypeName && l?.outwardIssue?.key) {
|
||||
def k = l.outwardIssue.key.toString()
|
||||
if (k.startsWith(prefix)) targets << k
|
||||
}
|
||||
}
|
||||
|
||||
targets = targets.unique()
|
||||
if (targets.size() != 1) return null
|
||||
|
||||
return targets[0]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user