|
3 | 3 | //> using toolkit default |
4 | 4 | //> using options -Werror -Wunused:all |
5 | 5 |
|
| 6 | +//> using file ./pr-classify-lib/Env.scala |
| 7 | +//> using file ./pr-classify-lib/EnvNames.scala |
| 8 | +//> using file ./pr-classify-lib/Category.scala |
| 9 | +//> using file ./pr-classify-lib/OverrideKey.scala |
| 10 | +//> using file ./pr-classify-lib/SuiteGroup.scala |
| 11 | +//> using file ./pr-classify-lib/Signals.scala |
| 12 | +//> using file ./pr-classify-lib/KeyValueFile.scala |
| 13 | + |
6 | 14 | // 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. |
| 15 | +// `changes` job classified the diff and which suite groups will run / be |
| 16 | +// skipped. |
8 | 17 | // |
9 | 18 | // 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). |
| 19 | +// CLASSIFY_OUTPUT_FILE - KEY=VALUE file produced by classify-changes.sc. |
| 20 | +// OVERRIDE_OUTPUT_FILE - KEY=VALUE file produced by check-override-keywords.sc. |
| 21 | +// COMMENT_OUTPUT_FILE - Path to write the rendered Markdown to (default: comment.md). |
14 | 22 | // CLASSIFY_RUN_ID - Run ID of the classification workflow (optional). |
15 | 23 | // 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 |
| 24 | +// CI_RUN_ID - Run ID of the matching CI workflow run (optional). |
| 25 | +// CI_RUN_URL - URL to the matching CI workflow run, or a fallback. |
148 | 26 |
|
| 27 | +import prclassify.* |
| 28 | + |
| 29 | +/** Which active overrides add suite groups that wouldn't run on their own. */ |
149 | 30 | def overrideContributions(signals: Signals): Seq[(OverrideKey, Seq[SuiteGroup])] = |
150 | 31 | 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 |
| 32 | + val baselineRuns = SuiteGroup.values.iterator.filter(baseline.shouldRun).toSet |
| 33 | + OverrideKey.ordered.flatMap: key => |
| 34 | + if !signals.has(key) then None |
154 | 35 | else |
155 | 36 | val probe = baseline.withOverride(key, enabled = true) |
156 | | - val added = SuiteGroup.values.toIndexedSeq |
157 | | - .filter(g => probe.shouldRun(g) && !baselineRuns.contains(g)) |
| 37 | + val added = SuiteGroup.ordered.filter(g => probe.shouldRun(g) && !baselineRuns.contains(g)) |
158 | 38 | if added.isEmpty then None else Some(key -> added) |
159 | | - } |
160 | 39 |
|
161 | 40 | def renderComment( |
162 | | - signals: Signals, |
163 | | - classifyRunId: Option[String], |
164 | | - classifyRunUrl: Option[String], |
165 | | - ciRunId: Option[String], |
166 | | - ciRunUrl: Option[String] |
| 41 | + signals: Signals, |
| 42 | + classifyRunId: Option[String], |
| 43 | + classifyRunUrl: Option[String], |
| 44 | + ciRunId: Option[String], |
| 45 | + ciRunUrl: Option[String] |
167 | 46 | ): 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")) |
| 47 | + val (runGroups, skipGroups) = SuiteGroup.ordered.partition(signals.shouldRun) |
| 48 | + |
| 49 | + val categoryRows = Category.ordered |
| 50 | + .map(c => s"| ${c.key} | ${signals.has(c)} |") |
| 51 | + .mkString("\n") |
| 52 | + |
| 53 | + def listLine(prefix: String, groups: Seq[SuiteGroup]): String = |
| 54 | + if groups.isEmpty then s"- $prefix: _(none)_" |
| 55 | + else s"- $prefix: ${groups.map(_.label).mkString(", ")}" |
| 56 | + |
| 57 | + // categoryRows lines start with `|`, which is stripMargin's margin marker, |
| 58 | + // so concatenate them after the stripped template instead of interpolating. |
| 59 | + val headerSection = |
| 60 | + s"""### CI change classification |
| 61 | + | |
| 62 | + |**Change categories** |
| 63 | + | |
| 64 | + || Category | Changed | |
| 65 | + || --- | --- | |
| 66 | + |""".stripMargin + categoryRows |
| 67 | + |
| 68 | + val suitesSection = |
| 69 | + s"""**Suite groups** |
| 70 | + | |
| 71 | + |${listLine("Will run", runGroups)} |
| 72 | + |${listLine("Will be skipped", skipGroups)}""".stripMargin |
| 73 | + |
| 74 | + val overridesSection: Option[String] = overrideContributions(signals) match |
| 75 | + case Nil => None |
| 76 | + case contributions => |
| 77 | + val rows = contributions |
| 78 | + .map((key, added) => s"- `${key.marker}` forces on: ${added.map(_.label).mkString(", ")}") |
| 79 | + .mkString("\n") |
| 80 | + Some( |
| 81 | + s"""**Override keywords affecting the run set** |
| 82 | + | |
| 83 | + |$rows""".stripMargin |
| 84 | + ) |
| 85 | + |
| 86 | + val ciRunSection: Option[String] = ciRunId match |
| 87 | + case Some(id) => Some(s"Full CI run: [#$id](${ciRunUrl.getOrElse("")})") |
| 88 | + case None => ciRunUrl.map(url => s"Full CI run: $url") |
| 89 | + |
| 90 | + val classifySection: Option[String] = classifyRunId.map: id => |
| 91 | + s"_Classified in run [#$id](${classifyRunUrl.getOrElse("")})._" |
| 92 | + |
| 93 | + val sections = |
| 94 | + Seq( |
| 95 | + Some(headerSection), |
| 96 | + Some(suitesSection), |
| 97 | + overridesSection, |
| 98 | + ciRunSection, |
| 99 | + classifySection |
| 100 | + ).flatten |
| 101 | + |
| 102 | + sections.mkString("\n\n") + "\n" |
| 103 | + |
| 104 | +val categoryMap = KeyValueFile.read(Env.requiredFile(EnvNames.ClassifyOutputFile)) |
| 105 | +val overrideMap = KeyValueFile.read(Env.requiredFile(EnvNames.OverrideOutputFile)) |
| 106 | +val signals = Signals.fromKeyValueMaps(categoryMap, overrideMap) |
| 107 | + |
| 108 | +val commentPath = Env.toAbsolutePath( |
| 109 | + Env.withDefault(EnvNames.CommentOutputFile, "comment.md") |
| 110 | +) |
218 | 111 |
|
219 | 112 | val body = renderComment( |
220 | 113 | 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") |
| 114 | + classifyRunId = Env.opt(EnvNames.ClassifyRunId), |
| 115 | + classifyRunUrl = Env.opt(EnvNames.ClassifyRunUrl), |
| 116 | + ciRunId = Env.opt(EnvNames.CiRunId), |
| 117 | + ciRunUrl = Env.opt(EnvNames.CiRunUrl) |
225 | 118 | ) |
226 | 119 |
|
227 | 120 | os.write.over(commentPath, body, createFolders = true) |
|
0 commit comments