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 EXCLUDE_BY_NAME = [] as Set Set 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 pagedGetValues(String path, int pageSize=50) { int startAt = 0; List all = [] while (true) { Map body = getAsMap(path, [startAt:startAt, maxResults:pageSize]) List vals = (body.values ?: []) as List all.addAll(vals as List) 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 fetchCustomFields(int pageSize){ logInfo("Lade Custom Fields…") List 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 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 fetchScreens(int pageSize){ logInfo("Lade Screens…") List list = pagedGetValues("/rest/api/3/screens", pageSize) logInfo("Screens gefunden: " + list.size()) return list } List fetchScreenTabs(Long screenId){ Map body = getAsMap("/rest/api/3/screens/${screenId}/tabs", [startAt:0, maxResults:50]) return (body.values ?: []) as List } List 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 scanScreensFor(Set fieldIds, int pageSize){ Map 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 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 candidates, int limit){ int n = Math.min(limit, candidates.size()) if (n == 0) return logWarn("Beispiele (${n}/${candidates.size()}):") for (int i=0;i 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 excludeByName, Set excludeById){ // 1) Felder (limitierbar) List allFields = fetchCustomFields(pageSize) List 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 hasProjAssoc = [:].withDefault{false} List 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 forScreenScan = noScope.collect{ (it.id ?: "").toString() } as Set Map useOnScreens = scanScreensFor(forScreenScan, pageSize) // 4) Kandidaten bestimmen (MIGRATED_POLICY) List 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)