Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/scripts/build-pr-classification-comment.sc
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env -S scala-cli shebang
//> using scala 3
//> using toolkit default
//> using options -Werror -Wunused:all

//> using file ./pr-classify-lib/Env.scala
//> using file ./pr-classify-lib/EnvNames.scala
//> using file ./pr-classify-lib/Category.scala
//> using file ./pr-classify-lib/OverrideKey.scala
//> using file ./pr-classify-lib/SuiteGroup.scala
//> using file ./pr-classify-lib/Signals.scala
//> using file ./pr-classify-lib/KeyValueFile.scala

// Builds the Markdown body for the PR sticky comment that summarizes how the
// `changes` job classified the diff and which suite groups will run / be
// skipped. Intended to be invoked from `ci.yml`'s `changes` job so the
// resulting comment reflects the exact classification CI is using; the
// `pr-classify-comment.yml` workflow then downloads the rendered body as
// an artifact and posts it as a sticky PR comment.
//
// Inputs (env vars):
// CLASSIFY_OUTPUT_FILE - KEY=VALUE file produced by classify-changes.sc.
// OVERRIDE_OUTPUT_FILE - KEY=VALUE file produced by check-override-keywords.sc.
// COMMENT_OUTPUT_FILE - Path to write the rendered Markdown to (default: comment.md).
// CI_RUN_ID - Run ID of the CI workflow run (optional).
// CI_RUN_URL - URL to the CI workflow run (optional).

import prclassify.*

/** Which active overrides add suite groups that wouldn't run on their own. */
def overrideContributions(signals: Signals): Seq[(OverrideKey, Seq[SuiteGroup])] =
val baseline = signals.withoutOverrides
val baselineRuns = SuiteGroup.values.iterator.filter(baseline.shouldRun).toSet
OverrideKey.ordered.flatMap: key =>
if !signals.has(key) then None
else
val probe = baseline.withOverride(key, enabled = true)
val added = SuiteGroup.ordered.filter(g => probe.shouldRun(g) && !baselineRuns.contains(g))
if added.isEmpty then None else Some(key -> added)

def renderComment(
signals: Signals,
ciRunId: Option[String],
ciRunUrl: Option[String]
): String =
val (runGroups, skipGroups) = SuiteGroup.ordered.partition(signals.shouldRun)

val categoryRows = Category.ordered
.map(c => s"| ${c.key} | ${signals.has(c)} |")
.mkString("\n")

def listLine(prefix: String, groups: Seq[SuiteGroup]): String =
if groups.isEmpty then s"- $prefix: _(none)_"
else s"- $prefix: ${groups.map(_.label).mkString(", ")}"

// categoryRows lines start with `|`, which is stripMargin's margin marker,
// so concatenate them after the stripped template instead of interpolating.
val headerSection =
s"""### CI change classification
|
|**Change categories**
|
|| Category | Changed |
|| --- | --- |
|""".stripMargin + categoryRows

val suitesSection =
s"""**Suite groups**
|
|${listLine("Will run", runGroups)}
|${listLine("Will be skipped", skipGroups)}""".stripMargin

val overridesSection: Option[String] = overrideContributions(signals) match
case Nil => None
case contributions =>
val rows = contributions
.map((key, added) => s"- `${key.marker}` forces on: ${added.map(_.label).mkString(", ")}")
.mkString("\n")
Some(
s"""**Override keywords affecting the run set**
|
|$rows""".stripMargin
)

val ciRunSection: Option[String] = ciRunId match
case Some(id) => Some(s"Full CI run: [#$id](${ciRunUrl.getOrElse("")})")
case None => ciRunUrl.map(url => s"Full CI run: $url")

val sections =
Seq(Some(headerSection), Some(suitesSection), overridesSection, ciRunSection).flatten

sections.mkString("\n\n") + "\n"

val categoryMap = KeyValueFile.read(Env.requiredFile(EnvNames.ClassifyOutputFile))
val overrideMap = KeyValueFile.read(Env.requiredFile(EnvNames.OverrideOutputFile))
val signals = Signals.fromKeyValueMaps(categoryMap, overrideMap)

val commentPath = Env.toAbsolutePath(
Env.withDefault(EnvNames.CommentOutputFile, "comment.md")
)

val body = renderComment(
signals,
ciRunId = Env.opt(EnvNames.CiRunId),
ciRunUrl = Env.opt(EnvNames.CiRunUrl)
)
println(
s"""Generated comment body:
|-----------------------
|$body
|-----------------------""".stripMargin
)

os.write.over(commentPath, body, createFolders = true)
println(s"Wrote comment to $commentPath")
59 changes: 59 additions & 0 deletions .github/scripts/check-override-keywords.sc
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env -S scala-cli shebang
//> using scala 3
//> using toolkit default
//> using options -Werror -Wunused:all

//> using file ./pr-classify-lib/Env.scala
//> using file ./pr-classify-lib/EnvNames.scala
//> using file ./pr-classify-lib/OverrideKey.scala
//> using file ./pr-classify-lib/KeyValueFile.scala
//> using file ./pr-classify-lib/GitHubOutput.scala

// Checks the PR body for [test_*] override keywords.
// Inputs (env vars):
// EVENT_NAME - GitHub event name (pull_request, push, ...).
// PR_BODY - The PR body to scan for override keywords.
// OVERRIDE_OUTPUT_FILE - Optional path of a KEY=VALUE file to also write
// override results into (in addition to $GITHUB_OUTPUT).
// Outputs:
// - `${keyword}=true|false` to $GITHUB_OUTPUT for every override,
// - a Markdown summary table to $GITHUB_STEP_SUMMARY,
// - and the same KEY=VALUE pairs to $OVERRIDE_OUTPUT_FILE when set.

import prclassify.*

val eventName = Env.opt(EnvNames.EventName).getOrElse("")
val prBody = Env.opt(EnvNames.PrBody).getOrElse("")

val active: Set[OverrideKey] =
if eventName != "pull_request" then
println("Non-PR event, setting all overrides to true")
OverrideKey.values.toSet
else OverrideKey.values.iterator.filter(o => prBody.contains(o.marker)).toSet

OverrideKey.ordered.foreach: o =>
if active.contains(o) then println(s"Override ${o.marker} found")

println("Override keywords:")
OverrideKey.ordered.foreach(o => println(s" ${o.keyword}=${active.contains(o)}"))

val entries: Seq[(String, String)] =
OverrideKey.ordered.map(o => o.keyword -> active.contains(o).toString)

entries.foreach((k, v) => GitHubOutput.writeScalar(k, v))

Env.opt(EnvNames.OverrideOutputFile).foreach: path =>
KeyValueFile.appendAll(Env.toAbsolutePath(path), entries)

val overrideRows = OverrideKey.ordered
.map(o => s"| ${o.marker} | ${active.contains(o)} |")
.mkString("\n")

// overrideRows lines start with `|` (stripMargin's margin marker), so
// concatenate them after the stripped template instead of interpolating.
GitHubOutput.writeSummary(
s"""## Override keywords
|| Keyword | Active |
||---------|--------|
|""".stripMargin + overrideRows
)
53 changes: 0 additions & 53 deletions .github/scripts/check-override-keywords.sh

This file was deleted.

80 changes: 80 additions & 0 deletions .github/scripts/classify-changes.sc
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env -S scala-cli shebang
//> using scala 3
//> using toolkit default
//> using options -Werror -Wunused:all

//> using file ./pr-classify-lib/Env.scala
//> using file ./pr-classify-lib/EnvNames.scala
//> using file ./pr-classify-lib/Category.scala
//> using file ./pr-classify-lib/KeyValueFile.scala
//> using file ./pr-classify-lib/GitHubOutput.scala

// Classifies changed files into categories for CI job filtering.
// Inputs (env vars):
// EVENT_NAME - GitHub event name (pull_request, push, ...).
// BASE_REF - Base ref of the PR (used to compute the diff).
// CHANGED_FILES_OVERRIDE - Optional newline-separated list of changed files.
// When set, overrides the git-diff-based detection.
// CLASSIFY_OUTPUT_FILE - Optional path of a KEY=VALUE file to also write
// category results into (in addition to $GITHUB_OUTPUT).
// Outputs:
// - `${category}=true|false` to $GITHUB_OUTPUT for every category,
// - a Markdown summary table to $GITHUB_STEP_SUMMARY,
// - and the same KEY=VALUE pairs to $CLASSIFY_OUTPUT_FILE when set.

import prclassify.*

enum ChangeSet:
case All
case Specific(files: Seq[String])

def splitLines(s: String): Seq[String] =
s.linesIterator.map(_.trim).filter(_.nonEmpty).toIndexedSeq

val eventName = Env.opt(EnvNames.EventName).getOrElse("")

val changeSet: ChangeSet =
if eventName != "pull_request" then
println(s"Non-PR event ($eventName), setting all categories to true")
ChangeSet.All
else
Env.opt(EnvNames.ChangedFilesOverride) match
case Some(list) =>
ChangeSet.Specific(splitLines(list))
case None =>
val baseRef = Env.required(EnvNames.BaseRef)
val result = os.proc("git", "diff", "--name-only", s"origin/$baseRef...HEAD")
.call(check = false, mergeErrIntoOut = false, stderr = os.Inherit)
if result.exitCode != 0 then
System.err.println("::warning::Failed to compute diff, running all jobs")
println("Diff computation failed, setting all categories to true")
ChangeSet.All
else ChangeSet.Specific(splitLines(result.out.text()))

val activeCategories: Set[Category] = changeSet match
case ChangeSet.All => Category.values.toSet
case ChangeSet.Specific(files) => files.iterator.flatMap(Category.forPath).toSet

println("Change categories:")
Category.ordered.foreach(c => println(s" ${c.key}=${activeCategories.contains(c)}"))

val entries: Seq[(String, String)] =
Category.ordered.map(c => c.key -> activeCategories.contains(c).toString)

entries.foreach((k, v) => GitHubOutput.writeScalar(k, v))

Env.opt(EnvNames.ClassifyOutputFile).foreach: path =>
KeyValueFile.appendAll(Env.toAbsolutePath(path), entries)

val categoryRows = Category.ordered
.map(c => s"| ${c.key} | ${activeCategories.contains(c)} |")
.mkString("\n")

// categoryRows lines start with `|` (stripMargin's margin marker), so
// concatenate them after the stripped template instead of interpolating.
GitHubOutput.writeSummary(
s"""## Change categories
|| Category | Changed |
||----------|---------|
|""".stripMargin + categoryRows
)
62 changes: 0 additions & 62 deletions .github/scripts/classify-changes.sh

This file was deleted.

Loading