blob: 3bb6ceb4c4c07432c3211116b02e546152f78494 [file]
// SPDX-License-Identifier: Apache-2.0
// https://www.apache.org/licenses/LICENSE-2.0
import groovy.json.JsonSlurper
import groovy.xml.MarkupBuilder
// groovy.json and groovy.xml are bundled with the Groovy distribution
// since 3.0; no @Grab required.
/**
* Deterministic reference implementation of the issue-reassess-stats
* dashboard. Reads verdict.json files from a campaign directory,
* computes aggregates per issue-reassess-stats/aggregate.md, and emits
* the HTML per issue-reassess-stats/render.md.
*
* Invocation:
* groovy reference.groovy <campaign-dir> [--output <file>]
*/
def args = this.args as List
if (args.size() < 1) {
System.err.println('usage: groovy reference.groovy <campaign-dir> [--output <file>]')
System.exit(2)
}
def campaignDir = new File(args[0])
if (!campaignDir.exists() || !campaignDir.isDirectory()) {
System.err.println("error: not a directory: ${campaignDir}")
System.exit(2)
}
def outputFile = null
def i = 1
while (i < args.size()) {
if (args[i] == '--output' && i + 1 < args.size()) {
outputFile = new File(args[i + 1])
i += 2
} else { i++ }
}
// --- Step 1: Fetch ---
def slurper = new JsonSlurper()
def verdicts = []
def parseErrors = []
campaignDir.eachFile { entry ->
if (entry.isDirectory()) {
def vf = new File(entry, 'verdict.json')
if (vf.exists()) {
try {
verdicts << slurper.parse(vf)
} catch (Throwable t) {
parseErrors << [path: vf.path, error: t.message]
}
}
}
}
if (verdicts.isEmpty()) {
System.err.println("error: no verdict.json files found under ${campaignDir}")
System.exit(2)
}
// --- Step 2: Classify ---
def classifyBuckets = [
'fixed': ['fixed-on-master'],
'still-failing': ['still-fails-same', 'still-fails-different'],
'closed-as-intended': ['intended-behaviour'],
'closed-as-duplicate':['duplicate-of-resolved'],
'unrun': ['cannot-run-extraction', 'cannot-run-environment',
'cannot-run-dependency', 'timeout', 'needs-separate-workspace'],
]
def bucketOf = { String c -> classifyBuckets.find { k, vs -> c in vs }?.key ?: 'unknown' }
def total = verdicts.size()
// 'unknown' is included so a verdict whose classification isn't in
// any bucket increments a real 0 rather than null++ (which throws).
def buckets = (classifyBuckets.keySet() + 'unknown').collectEntries { [(it): 0] }
verdicts.each { v -> buckets[bucketOf(v.classification)]++ }
def partialFix = verdicts.findAll { v ->
v.cases instanceof List && v.cases.any { it.match_on_master == true } && v.cases.any { it.match_on_master == false }
}
def newIssueCandidates = []
verdicts.each { v ->
def f1 = v.cross_type_probe?.findings
def f2 = v.operator_variants_probe?.findings
if (f1) newIssueCandidates << [key: v.key, family: 'cross-type', findings: f1]
if (f2) newIssueCandidates << [key: v.key, family: 'operator-variants', findings: f2]
}
// --- Step 3: Aggregate ---
def stillFailing = buckets['still-failing'] ?: 0
def unrun = buckets['unrun'] ?: 0
def stillFailingPct = total > 0 ? (stillFailing * 100.0 / total) : 0
def unrunPct = total > 0 ? (unrun * 100.0 / total) : 0
def rating
if (stillFailingPct < 5 && unrunPct < 20) rating = 'Healthy'
else if (stillFailingPct < 20 && unrunPct < 40) rating = 'Needs attention'
else rating = 'Action needed'
def heroColor = { String card ->
if (card == 'still-failing') return stillFailingPct > 20 ? 'red' : stillFailingPct > 5 ? 'amber' : 'green'
if (card == 'fixed') return 'green'
if (card == 'partial-fix') return partialFix.size() > 0 ? 'amber' : 'green'
if (card == 'unrun') return unrunPct > 50 ? 'red' : unrunPct > 30 ? 'amber' : 'green'
return 'neutral'
}
def actionCandidates = verdicts.findAll {
it.classification in ['still-fails-same', 'still-fails-different'] &&
it.nature == 'bug-as-advertised'
}.sort { it.key }
def closureCandidates = verdicts.findAll {
it.classification == 'fixed-on-master' && it.nature == 'bug-as-advertised'
}.sort { it.key }
def trackerHygiene = verdicts.findAll {
it.nature == 'feature-request-disguised-as-bug'
}.sort { it.key }
// --- Step 4: Render ---
def sw = new StringWriter()
def mb = new MarkupBuilder(sw)
mb.html {
head {
meta(charset: 'UTF-8')
title("Reassessment dashboard — ${campaignDir.name}")
style('''
body { font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; max-width: 1100px; margin: 24px auto; padding: 0 16px; background:#fff; color:#1f2328; }
h1, h2 { border-bottom: 1px solid #d0d7de; padding-bottom: 6px; }
.hero { display: flex; gap: 12px; margin: 16px 0; }
.card { flex: 1; padding: 16px; border-radius: 8px; background: #f6f8fa; }
.card.red { background: #ffe9e6; }
.card.amber { background: #fff8c5; }
.card.green { background: #dafbe1; }
.card .label { font-size: 11px; color: #57606a; text-transform: uppercase; }
.card .value { font-size: 28px; font-weight: 600; }
.rating { padding: 12px 16px; border-radius: 8px; margin: 12px 0; font-weight: 600; }
.rating.red { background: #cf222e; color: #fff; }
.rating.amber { background: #bf8700; color: #fff; }
.rating.green { background: #2da44e; color: #fff; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
th, td { padding: 6px 8px; text-align: left; border-bottom: 1px solid #d0d7de; font-size: 13px; }
tr:nth-child(even) { background: #f6f8fa; }
details summary { cursor: pointer; padding: 8px 0; }
.footer { font-size: 12px; color: #57606a; margin-top: 24px; padding-top: 12px; border-top: 1px solid #d0d7de; }
@media (prefers-color-scheme: dark) {
body { background: #0d1117; color: #e6edf3; }
.card { background: #161b22; }
.footer { color: #8b949e; }
}
''')
}
body {
h1("Reassessment dashboard — ${campaignDir.name}")
div(class: 'hero') {
[
['Candidates', total, 'neutral'],
['Still failing', stillFailing, heroColor('still-failing')],
['Fixed on master', buckets['fixed'], heroColor('fixed')],
['Partial fix', partialFix.size(), heroColor('partial-fix')],
['Unrun', unrun, heroColor('unrun')],
].each { label, val, color ->
div(class: "card ${color}") {
div(class: 'label', label)
div(class: 'value', String.valueOf(val))
}
}
}
div(class: "rating ${rating == 'Healthy' ? 'green' : rating == 'Action needed' ? 'red' : 'amber'}",
"Health: ${rating}")
h2('Action candidates')
if (actionCandidates) {
ol {
actionCandidates.each { v ->
li {
b(v.key)
mkp.yield(' — ')
span("classification=${v.classification}; nature=${v.nature}")
br()
small("Next: /issue-fix-workflow ${v.key}")
}
}
}
} else {
p('No still-failing × bug-as-advertised candidates in this campaign.')
}
h2('Closure candidates')
if (closureCandidates) {
ul {
closureCandidates.each { v ->
li("${v.key} — fixed-on-master (${v.rev}); confirm and close")
}
}
} else {
p('No fixed-on-master × bug-as-advertised candidates.')
}
h2('New-issue candidates')
if (newIssueCandidates) {
ul {
newIssueCandidates.each { c ->
li("${c.key} (${c.family}): ${c.findings}")
}
}
} else {
p('No probes surfaced new-issue candidates.')
}
h2('Tracker hygiene')
if (trackerHygiene) {
ul {
trackerHygiene.each { v ->
li("${v.key} — re-type as Improvement (feature-request-disguised-as-bug)")
}
}
} else {
p('No feature-request-disguised-as-bug verdicts.')
}
details {
summary('Per-issue table (collapsible)')
table {
thead {
tr { ['Key','Class','Nature','Shape','Rev'].each { th(it) } }
}
tbody {
verdicts.sort { it.key }.each { v ->
tr {
td(v.key)
td(v.classification)
td(v.nature)
td(v.shape)
td(v.rev)
}
}
}
}
}
div(class: 'footer') {
p("Campaign: ${campaignDir.name}")
p("Candidates: ${total} | Parse errors: ${parseErrors.size()}")
if (parseErrors) {
ul {
parseErrors.each { e -> li("${e.path}: ${e.error}") }
}
}
}
}
}
def html = sw.toString()
if (outputFile) {
outputFile.text = html
System.err.println("wrote ${outputFile.path} (${html.size()} bytes)")
} else {
println html
}