Skip to content

Commit 0622d4a

Browse files
committed
Rewrite changes CI job scripts into Scala, extract commons with comment logic, refactor
1 parent 3715e2d commit 0622d4a

17 files changed

Lines changed: 602 additions & 456 deletions

.github/scripts/build-pr-classification-comment.sc

Lines changed: 95 additions & 202 deletions
Original file line numberDiff line numberDiff line change
@@ -3,225 +3,118 @@
33
//> using toolkit default
44
//> using options -Werror -Wunused:all
55

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+
614
// 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.
817
//
918
// 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).
1422
// CLASSIFY_RUN_ID - Run ID of the classification workflow (optional).
1523
// 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.
14826

27+
import prclassify.*
28+
29+
/** Which active overrides add suite groups that wouldn't run on their own. */
14930
def overrideContributions(signals: Signals): Seq[(OverrideKey, Seq[SuiteGroup])] =
15031
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
15435
else
15536
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))
15838
if added.isEmpty then None else Some(key -> added)
159-
}
16039

16140
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]
16746
): 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+
)
218111

219112
val body = renderComment(
220113
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)
225118
)
226119

227120
os.write.over(commentPath, body, createFolders = true)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env -S scala-cli shebang
2+
//> using scala 3
3+
//> using toolkit default
4+
//> using options -Werror -Wunused:all
5+
6+
//> using file ./pr-classify-lib/Env.scala
7+
//> using file ./pr-classify-lib/EnvNames.scala
8+
//> using file ./pr-classify-lib/OverrideKey.scala
9+
//> using file ./pr-classify-lib/KeyValueFile.scala
10+
//> using file ./pr-classify-lib/GitHubOutput.scala
11+
12+
// Checks the PR body for [test_*] override keywords.
13+
// Inputs (env vars):
14+
// EVENT_NAME - GitHub event name (pull_request, push, ...).
15+
// PR_BODY - The PR body to scan for override keywords.
16+
// OVERRIDE_OUTPUT_FILE - Optional path of a KEY=VALUE file to also write
17+
// override results into (in addition to $GITHUB_OUTPUT).
18+
// Outputs:
19+
// - `${keyword}=true|false` to $GITHUB_OUTPUT for every override,
20+
// - a Markdown summary table to $GITHUB_STEP_SUMMARY,
21+
// - and the same KEY=VALUE pairs to $OVERRIDE_OUTPUT_FILE when set.
22+
23+
import prclassify.*
24+
25+
val eventName = Env.opt(EnvNames.EventName).getOrElse("")
26+
val prBody = Env.opt(EnvNames.PrBody).getOrElse("")
27+
28+
val active: Set[OverrideKey] =
29+
if eventName != "pull_request" then
30+
println("Non-PR event, setting all overrides to true")
31+
OverrideKey.values.toSet
32+
else OverrideKey.values.iterator.filter(o => prBody.contains(o.marker)).toSet
33+
34+
OverrideKey.ordered.foreach: o =>
35+
if active.contains(o) then println(s"Override ${o.marker} found")
36+
37+
println("Override keywords:")
38+
OverrideKey.ordered.foreach(o => println(s" ${o.keyword}=${active.contains(o)}"))
39+
40+
val entries: Seq[(String, String)] =
41+
OverrideKey.ordered.map(o => o.keyword -> active.contains(o).toString)
42+
43+
entries.foreach((k, v) => GitHubOutput.writeScalar(k, v))
44+
45+
Env.opt(EnvNames.OverrideOutputFile).foreach: path =>
46+
KeyValueFile.appendAll(Env.toAbsolutePath(path), entries)
47+
48+
val overrideRows = OverrideKey.ordered
49+
.map(o => s"| ${o.marker} | ${active.contains(o)} |")
50+
.mkString("\n")
51+
52+
// overrideRows lines start with `|` (stripMargin's margin marker), so
53+
// concatenate them after the stripped template instead of interpolating.
54+
GitHubOutput.writeSummary(
55+
s"""## Override keywords
56+
|| Keyword | Active |
57+
||---------|--------|
58+
|""".stripMargin + overrideRows
59+
)

0 commit comments

Comments
 (0)