Jira-Scripte/Console - Maintenance/Custom Fields – Unused Cleaner (kein Screen & kein Projekt-Kontext).groovy
2025-12-14 19:17:45 +01:00

257 lines
9.6 KiB
Groovy

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