257 lines
9.6 KiB
Groovy
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)
|
|
|
|
|