|
| 1 | +#!/usr/bin/env -S scala-cli shebang |
| 2 | +//> using scala 3 |
| 3 | +//> using toolkit default |
| 4 | +//> using options -Werror -Wunused:all |
| 5 | + |
| 6 | +// Builds the Markdown body for the PR sticky comment that summarizes how the |
| 7 | +// `changes` job classified the diff and which suite groups will run / be skipped. |
| 8 | +// |
| 9 | +// Inputs (env vars): |
| 10 | +// CLASSIFY_OUTPUT_FILE - KEY=VALUE file produced by classify-changes.sh. |
| 11 | +// OVERRIDE_OUTPUT_FILE - KEY=VALUE file produced by check-override-keywords.sh. |
| 12 | +// COMMENT_OUTPUT_FILE - Path to write the resulting Markdown comment to |
| 13 | +// (default: ./comment.md). |
| 14 | +// CLASSIFY_RUN_ID - Run ID of the classification workflow (optional). |
| 15 | +// CLASSIFY_RUN_URL - URL to the classification workflow run (optional). |
| 16 | +// CI_RUN_ID - Run ID of the matching CI workflow run (optional; |
| 17 | +// empty means "not yet resolved", link falls back). |
| 18 | +// CI_RUN_URL - URL to the matching CI workflow run, or a fallback |
| 19 | +// link (e.g. the PR's /checks page). |
| 20 | + |
| 21 | +import java.nio.file.Paths |
| 22 | + |
| 23 | +def envOpt(name: String): Option[String] = |
| 24 | + sys.env.get(name).filter(_.nonEmpty) |
| 25 | + |
| 26 | +def envRequiredFile(name: String): os.Path = |
| 27 | + val raw = envOpt(name).getOrElse: |
| 28 | + System.err.println(s"::error::$name is missing") |
| 29 | + sys.exit(1) |
| 30 | + val path = toAbsolutePath(raw) |
| 31 | + if !os.exists(path) then |
| 32 | + System.err.println(s"::error::$name points to non-existent file: $path") |
| 33 | + sys.exit(1) |
| 34 | + path |
| 35 | + |
| 36 | +def toAbsolutePath(s: String): os.Path = |
| 37 | + if Paths.get(s).isAbsolute then os.Path(s) else os.Path(s, os.pwd) |
| 38 | + |
| 39 | +def readKeyValueFile(path: os.Path): Map[String, String] = |
| 40 | + os.read.lines(path).iterator.flatMap { line => |
| 41 | + val trimmed = line.trim |
| 42 | + if trimmed.isEmpty || trimmed.startsWith("#") then None |
| 43 | + else |
| 44 | + trimmed.split("=", 2) match |
| 45 | + case Array(k, v) => Some(k.trim -> v.trim) |
| 46 | + case _ => None |
| 47 | + }.toMap |
| 48 | + |
| 49 | +enum SuiteGroup(val label: String): |
| 50 | + case UnitAndMill extends SuiteGroup("Unit tests & fish shell") |
| 51 | + case JvmIntegration extends SuiteGroup("JVM integration tests") |
| 52 | + case NativeIntegration extends SuiteGroup("Native integration tests") |
| 53 | + case DocsTests extends SuiteGroup("Docs tests") |
| 54 | + case Checks extends SuiteGroup("Checks") |
| 55 | + case Format extends SuiteGroup("Format / scalafix") |
| 56 | + case ReferenceDoc extends SuiteGroup("Reference docs") |
| 57 | + case BloopMemoryFootprint extends SuiteGroup("Bloop memory footprint") |
| 58 | + case SbtExportVcRedist extends SuiteGroup("Sbt export / vc-redist") |
| 59 | + |
| 60 | +enum OverrideKey(val keyword: String): |
| 61 | + case TestAll extends OverrideKey("test_all") |
| 62 | + case TestNative extends OverrideKey("test_native") |
| 63 | + case TestIntegration extends OverrideKey("test_integration") |
| 64 | + case TestDocs extends OverrideKey("test_docs") |
| 65 | + case TestFormat extends OverrideKey("test_format") |
| 66 | + |
| 67 | +// Signals provided to suite-group expressions — keep the boolean predicates in |
| 68 | +// sync with the SHOULD_RUN expressions used in .github/workflows/ci.yml. |
| 69 | +case class Signals( |
| 70 | + code: Boolean, |
| 71 | + docs: Boolean, |
| 72 | + ci: Boolean, |
| 73 | + formatConfig: Boolean, |
| 74 | + benchmark: Boolean, |
| 75 | + gifs: Boolean, |
| 76 | + millWrapper: Boolean, |
| 77 | + testAll: Boolean, |
| 78 | + testNative: Boolean, |
| 79 | + testIntegration: Boolean, |
| 80 | + testDocs: Boolean, |
| 81 | + testFormat: Boolean |
| 82 | +): |
| 83 | + def withoutOverrides: Signals = copy( |
| 84 | + testAll = false, |
| 85 | + testNative = false, |
| 86 | + testIntegration = false, |
| 87 | + testDocs = false, |
| 88 | + testFormat = false |
| 89 | + ) |
| 90 | + |
| 91 | + def withOverride(key: OverrideKey, enabled: Boolean): Signals = key match |
| 92 | + case OverrideKey.TestAll => copy(testAll = enabled) |
| 93 | + case OverrideKey.TestNative => copy(testNative = enabled) |
| 94 | + case OverrideKey.TestIntegration => copy(testIntegration = enabled) |
| 95 | + case OverrideKey.TestDocs => copy(testDocs = enabled) |
| 96 | + case OverrideKey.TestFormat => copy(testFormat = enabled) |
| 97 | + |
| 98 | + def shouldRun(group: SuiteGroup): Boolean = group match |
| 99 | + case SuiteGroup.UnitAndMill => |
| 100 | + code || ci || millWrapper || testAll |
| 101 | + case SuiteGroup.JvmIntegration => |
| 102 | + code || ci || testAll || testIntegration |
| 103 | + case SuiteGroup.NativeIntegration => |
| 104 | + code || ci || testAll || testNative |
| 105 | + case SuiteGroup.DocsTests => |
| 106 | + code || docs || ci || gifs || testAll || testDocs |
| 107 | + case SuiteGroup.Checks => |
| 108 | + code || docs || ci || formatConfig || testAll |
| 109 | + case SuiteGroup.Format => |
| 110 | + code || docs || ci || formatConfig || testAll || testFormat |
| 111 | + case SuiteGroup.ReferenceDoc => |
| 112 | + code || docs || ci || testAll |
| 113 | + case SuiteGroup.BloopMemoryFootprint => |
| 114 | + code || ci || benchmark || testAll |
| 115 | + case SuiteGroup.SbtExportVcRedist => |
| 116 | + code || ci || testAll |
| 117 | + |
| 118 | +object Signals: |
| 119 | + def apply(categories: Map[String, String], overrides: Map[String, String]): Signals = |
| 120 | + def boolAt(map: Map[String, String], key: String): Boolean = |
| 121 | + map.get(key).exists(_.equalsIgnoreCase("true")) |
| 122 | + Signals( |
| 123 | + code = boolAt(categories, "code"), |
| 124 | + docs = boolAt(categories, "docs"), |
| 125 | + ci = boolAt(categories, "ci"), |
| 126 | + formatConfig = boolAt(categories, "format_config"), |
| 127 | + benchmark = boolAt(categories, "benchmark"), |
| 128 | + gifs = boolAt(categories, "gifs"), |
| 129 | + millWrapper = boolAt(categories, "mill_wrapper"), |
| 130 | + testAll = boolAt(overrides, "test_all"), |
| 131 | + testNative = boolAt(overrides, "test_native"), |
| 132 | + testIntegration = boolAt(overrides, "test_integration"), |
| 133 | + testDocs = boolAt(overrides, "test_docs"), |
| 134 | + testFormat = boolAt(overrides, "test_format") |
| 135 | + ) |
| 136 | + |
| 137 | +/** Which individual override keywords (among those currently active) actually |
| 138 | + * add suite groups that wouldn't otherwise run. Used to list only the |
| 139 | + * overrides that matter. |
| 140 | + */ |
| 141 | +extension (signals: Signals) |
| 142 | + def isOverrideActive(key: OverrideKey): Boolean = key match |
| 143 | + case OverrideKey.TestAll => signals.testAll |
| 144 | + case OverrideKey.TestNative => signals.testNative |
| 145 | + case OverrideKey.TestIntegration => signals.testIntegration |
| 146 | + case OverrideKey.TestDocs => signals.testDocs |
| 147 | + case OverrideKey.TestFormat => signals.testFormat |
| 148 | + |
| 149 | +def overrideContributions(signals: Signals): Seq[(OverrideKey, Seq[SuiteGroup])] = |
| 150 | + val baseline = signals.withoutOverrides |
| 151 | + val baselineRuns = SuiteGroup.values.filter(baseline.shouldRun).toSet |
| 152 | + OverrideKey.values.toIndexedSeq.flatMap { key => |
| 153 | + if !signals.isOverrideActive(key) then None |
| 154 | + else |
| 155 | + val probe = baseline.withOverride(key, enabled = true) |
| 156 | + val added = SuiteGroup.values.toIndexedSeq |
| 157 | + .filter(g => probe.shouldRun(g) && !baselineRuns.contains(g)) |
| 158 | + if added.isEmpty then None else Some(key -> added) |
| 159 | + } |
| 160 | + |
| 161 | +def renderComment( |
| 162 | + signals: Signals, |
| 163 | + classifyRunId: Option[String], |
| 164 | + classifyRunUrl: Option[String], |
| 165 | + ciRunId: Option[String], |
| 166 | + ciRunUrl: Option[String] |
| 167 | +): String = |
| 168 | + val groups = SuiteGroup.values.toIndexedSeq |
| 169 | + val (runGroups, skipGroups) = groups.partition(signals.shouldRun) |
| 170 | + val contributions = overrideContributions(signals) |
| 171 | + |
| 172 | + val sb = StringBuilder() |
| 173 | + sb ++= "### CI change classification\n\n" |
| 174 | + sb ++= "**Change categories**\n\n" |
| 175 | + sb ++= "| Category | Changed |\n" |
| 176 | + sb ++= "| --- | --- |\n" |
| 177 | + sb ++= s"| code | ${signals.code} |\n" |
| 178 | + sb ++= s"| docs | ${signals.docs} |\n" |
| 179 | + sb ++= s"| ci | ${signals.ci} |\n" |
| 180 | + sb ++= s"| format_config | ${signals.formatConfig} |\n" |
| 181 | + sb ++= s"| benchmark | ${signals.benchmark} |\n" |
| 182 | + sb ++= s"| gifs | ${signals.gifs} |\n" |
| 183 | + sb ++= s"| mill_wrapper | ${signals.millWrapper} |\n" |
| 184 | + sb ++= "\n**Suite groups**\n\n" |
| 185 | + sb ++= ( |
| 186 | + if runGroups.isEmpty then "- Will run: _(none)_\n" |
| 187 | + else s"- Will run: ${runGroups.map(_.label).mkString(", ")}\n" |
| 188 | + ) |
| 189 | + sb ++= ( |
| 190 | + if skipGroups.isEmpty then "- Will be skipped: _(none)_\n" |
| 191 | + else s"- Will be skipped: ${skipGroups.map(_.label).mkString(", ")}\n" |
| 192 | + ) |
| 193 | + |
| 194 | + if contributions.nonEmpty then |
| 195 | + sb ++= "\n**Override keywords affecting the run set**\n\n" |
| 196 | + for (key, added) <- contributions do |
| 197 | + sb ++= s"- `[${key.keyword}]` forces on: ${added.map(_.label).mkString(", ")}\n" |
| 198 | + |
| 199 | + ciRunId match |
| 200 | + case Some(id) => |
| 201 | + val url = ciRunUrl.getOrElse("") |
| 202 | + sb ++= s"\nFull CI run: [#$id]($url)\n" |
| 203 | + case None => |
| 204 | + ciRunUrl.foreach { url => sb ++= s"\nFull CI run: $url\n" } |
| 205 | + |
| 206 | + classifyRunId.foreach { id => |
| 207 | + val url = classifyRunUrl.getOrElse("") |
| 208 | + sb ++= s"\n_Classified in run [#$id]($url)._\n" |
| 209 | + } |
| 210 | + |
| 211 | + sb.result() |
| 212 | + |
| 213 | +val categories = readKeyValueFile(envRequiredFile("CLASSIFY_OUTPUT_FILE")) |
| 214 | +val overrides = readKeyValueFile(envRequiredFile("OVERRIDE_OUTPUT_FILE")) |
| 215 | +val signals = Signals(categories, overrides) |
| 216 | + |
| 217 | +val commentPath = toAbsolutePath(envOpt("COMMENT_OUTPUT_FILE").getOrElse("comment.md")) |
| 218 | + |
| 219 | +val body = renderComment( |
| 220 | + signals, |
| 221 | + classifyRunId = envOpt("CLASSIFY_RUN_ID"), |
| 222 | + classifyRunUrl = envOpt("CLASSIFY_RUN_URL"), |
| 223 | + ciRunId = envOpt("CI_RUN_ID"), |
| 224 | + ciRunUrl = envOpt("CI_RUN_URL") |
| 225 | +) |
| 226 | + |
| 227 | +os.write.over(commentPath, body, createFolders = true) |
| 228 | +println(s"Wrote comment to $commentPath") |
0 commit comments