project/ValidatePullRequest.scala (485 lines of code) (raw):
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* license agreements; and to You under the Apache License, version 2.0:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* This file is part of the Apache Pekko project, which was derived from Akka.
*/
/*
* Copyright (C) 2009-2020 Lightbend Inc. <https://www.lightbend.com>
*/
import java.io._
import MimaWithPrValidation.{ MimaResult, NoErrors, Problems }
import net.virtualvoid.sbt.graph.ModuleGraph
import net.virtualvoid.sbt.graph.backend.SbtUpdateReport
import org.kohsuke.github.GHIssueComment
import org.kohsuke.github.GitHubBuilder
import sbt.Keys._
import sbt._
import sbt.access.Aggregation
import sbt.access.Aggregation.Complete
import sbt.access.Aggregation.KeyValue
import sbt.access.Aggregation.ShowConfig
import sbt.internal.util.MainAppender
import sbt.internal.Act
import sbt.internal.BuildStructure
import sbt.std.Transform.DummyTaskMap
import sbt.util.LogExchange
import sbtunidoc.BaseUnidocPlugin.autoImport.unidoc
import scala.collection.JavaConverters._
import scala.collection.immutable
import scala.sys.process._
import scala.util.Try
import scala.util.matching.Regex
object ValidatePullRequest extends AutoPlugin {
override def trigger = allRequirements
override def requires = plugins.JvmPlugin
sealed trait BuildMode {
def task: Option[TaskKey[_]]
def log(projectName: String, l: Logger): Unit
}
case object BuildSkip extends BuildMode {
override def task = None
def log(projectName: String, l: Logger) =
l.info(s"Skipping validation of [$projectName], as PR does NOT affect this project...")
}
case object BuildQuick extends BuildMode {
override def task = Some(ValidatePR / executeTests)
def log(projectName: String, l: Logger) =
l.info(s"Building [$projectName] in quick mode, as its dependencies were affected by PR.")
}
case object BuildProjectChangedQuick extends BuildMode {
override def task = Some(ValidatePR / executeTests)
def log(projectName: String, l: Logger) =
l.info(s"Building [$projectName] as the root `project/` directory was affected by this PR.")
}
final case class BuildCommentForcedAll(phrase: String, c: GHIssueComment) extends BuildMode {
override def task = Some(Test / executeTests)
def log(projectName: String, l: Logger) =
l.info(s"GitHub PR comment [${c.getUrl}] contains [$phrase], forcing BUILD ALL mode!")
}
val ValidatePR = config("pr-validation").extend(Test)
override lazy val projectConfigurations = Seq(ValidatePR)
/**
* Assumptions:
* Env variables set "by Jenkins" are assumed to come from this plugin:
* https://wiki.jenkins-ci.org/display/JENKINS/GitHub+pull+request+builder+plugin
*/
// settings
val PullIdEnvVarName = "ghprbPullId" // Set by "GitHub pull request builder plugin"
val TargetBranchEnvVarName = "PR_TARGET_BRANCH"
val TargetBranchJenkinsEnvVarName = "ghprbTargetBranch"
val SourceBranchEnvVarName = "PR_SOURCE_BRANCH"
val SourcePullIdJenkinsEnvVarName = "ghprbPullId" // used to obtain branch name in form of "pullreq/17397"
val sourceBranch = settingKey[String]("Branch containing the changes of this PR")
val targetBranch = settingKey[String]("Target branch of this PR, defaults to `main`")
// asking github comments if this PR should be PLS BUILD ALL
val gitHubEnforcedBuildAll =
taskKey[Option[BuildMode]]("Checks via GitHub API if comments included the PLS BUILD ALL keyword")
val buildAllKeyword = taskKey[Regex](
"Magic phrase to be used to trigger building of the entire project instead of analysing dependencies")
// determining touched dirs and projects
val changedDirectories = taskKey[immutable.Set[String]]("List of touched modules in this PR branch")
val validatePRprojectBuildMode =
taskKey[BuildMode]("Determines what will run when this project is affected by the PR and should be rebuilt")
// running validation
val validatePullRequest = taskKey[Unit]("Validate pull request and report aggregated results")
val executePullRequestValidation = taskKey[Seq[KeyValue[Result[Any]]]]("Run pull request per project")
val additionalTasks = settingKey[Seq[TaskKey[_]]]("Additional tasks for pull request validation")
// The set of (top-level) files or directories to watch for build changes.
val BuildFilesAndDirectories = Set("project", "build.sbt", ".github")
def changedDirectoryIsDependency(changedDirs: Set[String], name: String,
graphsToTest: Seq[(Configuration, ModuleGraph)])(log: Logger): Boolean = {
val dirsOrExperimental = changedDirs.flatMap(dir => Set(dir, s"$dir-experimental"))
graphsToTest.exists { case (ivyScope, deps) =>
log.debug(s"Analysing [$ivyScope] scoped dependencies...")
deps.nodes.foreach { m => log.debug(" -> " + m.id) }
// if this project depends on a modified module, we must test it
deps.nodes.exists { m =>
// match just by name, we'd rather include too much than too little
val dependsOnModule = dirsOrExperimental.find(m.id.name contains _)
val depends = dependsOnModule.isDefined
if (depends) log.info(s"Project [$name] must be verified, because depends on [${dependsOnModule.get}]")
depends
}
}
}
def localTargetBranch: Option[String] = System.getenv.asScala.get("PR_TARGET_BRANCH")
def jenkinsTargetBranch: Option[String] = System.getenv.asScala.get("ghprbTargetBranch")
def runningOnJenkins: Boolean = jenkinsTargetBranch.isDefined
def runningLocally: Boolean = !runningOnJenkins
override lazy val buildSettings = Seq(
ValidatePR / sourceBranch in Global := {
sys.env.get(SourceBranchEnvVarName).orElse(
sys.env.get(SourcePullIdJenkinsEnvVarName).map("pullreq/" + _)).getOrElse( // Set by "GitHub pull request builder plugin"
"HEAD")
},
ValidatePR / targetBranch in Global := {
(localTargetBranch, jenkinsTargetBranch) match {
case (Some(local), _) => local // local override
case (None, Some(branch)) => s"origin/$branch" // usually would be "main" or "release-10.1" etc
case (None, None) => "origin/main" // defaulting to diffing with the main branch
}
},
ValidatePR / buildAllKeyword in Global := """PLS BUILD ALL""".r,
ValidatePR / gitHubEnforcedBuildAll in Global := {
val log = streams.value.log
val buildAllMagicPhrase = (ValidatePR / buildAllKeyword).value
sys.env.get(PullIdEnvVarName).map(_.toInt).flatMap { prId =>
log.info("Checking GitHub comments for PR validation options...")
try {
import scala.collection.JavaConverters._
val gh = GitHubBuilder.fromEnvironment().withOAuthToken(GitHub.envTokenOrThrow).build()
val comments = gh.getRepository("apache/pekko-http").getIssue(prId).getComments.asScala
def triggersBuildAll(c: GHIssueComment): Boolean = buildAllMagicPhrase.findFirstIn(c.getBody).isDefined
comments.collectFirst {
case c if triggersBuildAll(c) =>
BuildCommentForcedAll(buildAllMagicPhrase.toString(), c)
}
} catch {
case ex: Exception =>
log.warn("Unable to reach GitHub! Exception was: " + ex.getMessage)
None
}
}
},
ValidatePR / changedDirectories in Global := {
val log = streams.value.log
val prId = (ValidatePR / sourceBranch).value
val target = (ValidatePR / targetBranch).value
log.info(s"Diffing [$prId] to determine changed modules in PR...")
val diffOutput = s"git diff $target --name-only".!!.split("\n")
val diffedModuleNames =
diffOutput
.map(l => l.trim)
.filter(l =>
l.startsWith("http") ||
l.startsWith("parsing") ||
l.startsWith("docs") ||
BuildFilesAndDirectories.exists(l startsWith))
.map(l => l.takeWhile(_ != '/'))
.toSet
val dirtyModuleNames: Set[String] =
if (runningOnJenkins) Set.empty
else {
val statusOutput = s"git status --short".!!.split("\n")
val dirtyDirectories = statusOutput
.map(l => l.trim.dropWhile(_ != ' ').drop(1))
.map(_.takeWhile(_ != '/'))
.filter(dir =>
dir.startsWith("http") || dir.startsWith("parsing") || dir.startsWith("docs") ||
BuildFilesAndDirectories.contains(dir))
.toSet
log.info(
"Detected uncommitted changes in directories (including in dependency analysis): " + dirtyDirectories.mkString(
"[", ",", "]"))
dirtyDirectories
}
val allModuleNames = dirtyModuleNames ++ diffedModuleNames
log.info("Detected changes in directories: " + allModuleNames.mkString("[", ", ", "]"))
allModuleNames
})
override lazy val projectSettings = inConfig(ValidatePR)(Defaults.testTasks) ++ Seq(
ValidatePR / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-l", "performance"),
ValidatePR / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-l", "long-running"),
ValidatePR / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-l", "timing"),
ValidatePR / validatePRprojectBuildMode := {
val log = streams.value.log
log.debug(s"Analysing project (for inclusion in PR validation): [${name.value}]")
val changedDirs = (ValidatePR / changedDirectories).value
val githubCommandEnforcedBuildAll = (ValidatePR / gitHubEnforcedBuildAll).value
val thisProjectId = CrossVersion(scalaVersion.value, scalaBinaryVersion.value)(projectID.value)
def graphFor(updateReport: UpdateReport, config: Configuration): (Configuration, ModuleGraph) =
config -> SbtUpdateReport.fromConfigurationReport(updateReport.configuration(config).get, thisProjectId)
def isDependency: Boolean = {
changedDirectoryIsDependency(
changedDirs,
name.value,
Seq(
graphFor((Compile / updateFull).value, Compile),
graphFor((Test / updateFull).value, Test),
graphFor((Runtime / updateFull).value, Runtime),
graphFor((Provided / updateFull).value, Provided),
graphFor((Optional / updateFull).value, Optional)))(log)
}
if (githubCommandEnforcedBuildAll.isDefined)
githubCommandEnforcedBuildAll.get
else if (changedDirs.exists(BuildFilesAndDirectories contains))
BuildProjectChangedQuick
else if (isDependency)
BuildQuick
else
BuildSkip
},
ValidatePR / additionalTasks := Seq.empty,
executePullRequestValidation := Def.taskDyn {
val log = streams.value.log
val buildMode = (ValidatePR / validatePRprojectBuildMode).value
buildMode.log(name.value, log)
val validationTasks: Seq[TaskKey[Any]] = (buildMode.task.toSeq ++ (buildMode match {
case BuildSkip => Seq.empty // do not run the additional task if project is skipped during pr validation
case _ => (ValidatePR / additionalTasks).value
})).asInstanceOf[Seq[TaskKey[Any]]]
val thisProject = Def.resolvedScoped.value.scope.project.toOption.get
// Create a task for every validation task key and
// then zip all of the tasks together discarding outputs.
// Task failures are propagated as normal.
val zero: Def.Initialize[Seq[Task[KeyValue[Result[Any]]]]] =
Def.setting { Seq(task(()).result.map(res => KeyValue(null, res))) }
validationTasks.map(taskKey =>
Def.task { taskKey.value }.result.map(res => KeyValue(thisProject / taskKey, res))).foldLeft(zero) {
(acc, current) =>
acc.zipWith(current) { case (taskSeq, task) =>
taskSeq :+ task.asInstanceOf[Task[KeyValue[Result[Any]]]]
}
}.apply { tasks: Seq[Task[KeyValue[Result[Any]]]] =>
tasks.join
}
}.value)
}
object AggregatePRValidation extends AutoPlugin {
import ValidatePullRequest._
override def trigger = noTrigger
override lazy val projectSettings = Seq(
validatePullRequest := {
val log = streams.value.log
val extracted = Project.extract(state.value)
val keys =
Aggregation.aggregatedKeys(extracted.currentRef / validatePullRequest, extracted.structure.extra, ScopeMask())
log.info(s"AGGREGATE KEYS for ${extracted.currentRef}")
keys.foreach(x => log.info(x.toString))
log.info("END")
def timedRun[T](
s: State, ts: Seq[sbt.internal.Aggregation.KeyValue[Task[T]]], extra: DummyTaskMap): Complete[Result[T]] = {
import EvaluateTask._
import std.TaskExtra._
val extracted = Project.extract(s)
import extracted.structure
val toRun: Seq[Task[KeyValue[Result[T]]]] = ts.map { case KeyValue(k, t) => t.result.map(v => KeyValue(k, v)) }
val joined = Task[Seq[KeyValue[Result[T]]]](Info(),
new Join(toRun,
{ (s: Seq[Result[KeyValue[Result[T]]]]) =>
val extracted = s.map {
case _: Inc => throw new IllegalStateException("Should not happen") // should not happen because of `result` above
case Value(kv) => kv
}
Right(extracted)
}))
val roots = ts.map { case KeyValue(k, _) => k }
val config = extractedTaskConfig(extracted, structure, s)
val start = System.currentTimeMillis
val (newS, result: Result[Seq[KeyValue[Result[T]]]]) = withStreams(structure, s) { str =>
val transform = nodeView(s, str, roots, extra)
runTask(joined, s, str, structure.index.triggers, config)(transform)
}
val stop = System.currentTimeMillis
Complete(start, stop, result, newS)
}
def runTasks[T](s: State, structure: BuildStructure, ts: Seq[sbt.internal.Aggregation.KeyValue[Task[T]]],
extra: DummyTaskMap, show: ShowConfig)(
implicit display: Show[ScopedKey[_]]): (State, Result[Seq[KeyValue[Result[T]]]]) = {
val complete = timedRun[T](s, ts, extra)
sbt.access.AggregationShowRun(complete, show)
val newState =
complete.results match {
case Inc(i) => complete.state.handleError(i)
case Value(_) => complete.state
}
(newState, complete.results)
}
def resolve[T](key: ScopedKey[T]): ScopedKey[T] =
Project.mapScope(Scope.resolveScope(GlobalScope, extracted.currentRef.build, extracted.rootProject))(
key.scopedKey)
def runAggregated[T](key: TaskKey[T], state: State): (State, Result[Seq[KeyValue[Result[T]]]]) = {
val rkey = resolve(key.scopedKey)
val keys = Aggregation.aggregate(rkey, ScopeMask(), extracted.structure.extra)
val tasks = Act.keyValues(extracted.structure)(keys)
log.info(s"Tasks to aggregate are: $keys $tasks")
runTasks[T](state, extracted.structure, tasks, DummyTaskMap(Nil), show = Aggregation.defaultShow(state, false))(
extracted.showKey)
}
val (newState, result) = runAggregated(extracted.currentRef / executePullRequestValidation, state.value)
implicit class AddBetterEitherError[T](val e: Either[sbt.Incomplete, T]) {
def getSafe: T = e match {
case Left(i) => throw new IllegalStateException(s"Was not Right but [$i]", i.directCause.getOrElse(null))
case Right(t) => t
}
}
val allResults =
result
.toEither.getSafe
.flatMap(_.value.toEither.getSafe)
.filterNot(_.key == null)
.sortBy(_.key.scope.project.toString)
val onlyTestResults: Seq[KeyValue[Tests.Output]] = allResults.collect {
case KeyValue(key, Value(o: Tests.Output)) => KeyValue(key, o)
}
val (passed0, failed) = onlyTestResults.partition(_.value.overall == TestResult.Passed)
val failedTasks: Seq[KeyValue[Inc]] = allResults.collect {
case KeyValue(key, i: Inc) => KeyValue(key, i)
}
val mimaFailures: Seq[KeyValue[MimaResult]] = allResults.collect {
case KeyValue(key, Value(p: Problems)) => KeyValue(key, p)
}
val outputFile = new File(target.value, "pr-validation-report.txt")
val fw = new PrintWriter(new FileWriter(outputFile))
def write(msg: String): Unit = {
log.info(msg)
fw.println(msg)
}
val testLogger = LogExchange.logger("testLogger")
val appender = MainAppender.defaultBacked(useFormat = false)(fw)
LogExchange.bindLoggerAppenders("testLogger", appender -> Level.Info :: Nil)
log.info("")
if (failed.nonEmpty || mimaFailures.nonEmpty || failedTasks.nonEmpty) {
write("")
write("## Pull request validation report")
write("")
def showKey(key: ScopedKey[_]): String = Project.showContextKey2(extracted.session).show(key)
def totalCount(suiteResult: SuiteResult): Int = {
import suiteResult._
passedCount + failureCount + errorCount + skippedCount + ignoredCount + canceledCount + pendingCount
}
def hasExecutedTests(suiteResult: SuiteResult): Boolean = totalCount(suiteResult) > 0
def hasTests(result: Tests.Output): Boolean = result.events.exists(e => hasExecutedTests(e._2))
def printTestResults(result: KeyValue[Tests.Output]): Unit = {
write(s"Test result for `${showKey(result.key)}`")
write("")
write("```")
def safeLogTestResults(logger: Logger): Unit =
Try(TestResultLogger.Default.run(logger, result.value, showKey(result.key)))
// HACK: there is no logger which would both log to our file and the console, so we log twice
safeLogTestResults(log)
safeLogTestResults(testLogger)
// there seems to be some async logging going on, so let's wait for a while to be sure the appender has flushed
Thread.sleep(100)
write("```")
write("")
}
val passed = passed0.filter(t => hasTests(t.value))
if (failed.nonEmpty) {
write("<details><summary>Failed Test Suites</summary>")
write("")
failed.foreach(printTestResults)
write("</details>")
}
if (mimaFailures.nonEmpty) {
write("<details><summary>Mima Failures</summary>")
write("")
write("```")
mimaFailures.foreach {
case KeyValue(key, Problems(desc)) =>
write(s"Problems for `${key.scope.project.toOption.get.asInstanceOf[ProjectRef].project}`:\n$desc")
write("")
case KeyValue(_, NoErrors) =>
}
write("```")
write("</details>")
}
if (failedTasks.nonEmpty) {
write("<details><summary>Other Failed Tasks</summary>")
write("")
failedTasks.foreach { case KeyValue(key, Inc(inc: Incomplete)) =>
def parseIncomplete(inc: Incomplete): String =
"an underlying problem during task execution:\n" +
Incomplete.linearize(inc).filter(x => x.message.isDefined || x.directCause.isDefined)
.map { case i @ Incomplete(node, tpe, message, causes, directCause) =>
def nodeName: String = node match {
case Some(key: ScopedKey[_]) => showKey(key)
case Some(t: Task[_]) =>
t.info.name
.orElse(t.info.attributes.get(taskDefinitionKey).map(showKey))
.getOrElse(t.info.toString)
case Some(x) => s"<$x>"
case None => "<unknown>"
}
s" $nodeName: ${message.orElse(directCause.map(_.toString)).getOrElse(s"<unknown: ($i)>")}"
}.mkString("```\n", "\n", "\n```\n")
val problem = inc.directCause.map(_.toString).getOrElse(parseIncomplete(inc))
write(s"`${showKey(key)}` failed because of $problem")
}
write("</details>")
}
}
fw.close()
log.info(s"Wrote PR validation report to ${outputFile.getAbsolutePath}")
if (failed.nonEmpty) throw new RuntimeException(s"Pull request validation failed! Tests failed: $failed")
else if (mimaFailures.nonEmpty)
throw new RuntimeException(s"Pull request validation failed! Mima failures: $mimaFailures")
else if (failedTasks.nonEmpty)
throw new RuntimeException(s"Pull request validation failed! Failed tasks: $failedTasks")
()
})
}
/**
* This auto plugin adds MiMa binary issue reporting to validatePullRequest task,
* when a project has MimaPlugin auto plugin enabled.
*/
object MimaWithPrValidation extends AutoPlugin {
import ValidatePullRequest._
import com.typesafe.tools.mima.plugin._
import MimaKeys._
sealed trait MimaResult
case object NoErrors extends MimaResult
case class Problems(problemDescription: String) extends MimaResult
val mimaResult = taskKey[MimaResult]("Aggregates mima result strings")
override def trigger = allRequirements
override def requires = ValidatePullRequest && MimaPlugin
override lazy val projectSettings = Seq(
ValidatePR / additionalTasks += mimaResult,
mimaResult := {
import com.typesafe.tools.mima.core
def reportModuleErrors(module: ModuleID,
backward: List[core.Problem],
forward: List[core.Problem],
filters: Seq[core.ProblemFilter],
backwardFilters: Map[String, Seq[core.ProblemFilter]],
forwardFilters: Map[String, Seq[core.ProblemFilter]],
log: String => Unit, projectName: String): Boolean = {
// filters * found is n-squared, it's fixable in principle by special-casing known
// filter types or something, not worth it most likely...
val versionOrdering = {
// version string "x.y.z" is converted to an Int tuple (x, y, z) for comparison
val VersionRegex = """(\d+)\.?(\d+)?\.?(.*)?""".r
def int(versionPart: String) =
Try(versionPart.replace("x", Short.MaxValue.toString).filter(_.isDigit).toInt).getOrElse(0)
Ordering[(Int, Int, Int)].on[String] { case VersionRegex(x, y, z) => (int(x), int(y), int(z)) }
}
def isReported(module: ModuleID, verionedFilters: Map[String, Seq[core.ProblemFilter]])(
problem: core.Problem) = (verionedFilters.collect {
// get all filters that apply to given module version or any version after it
case f @ (version, filters) if versionOrdering.gteq(version, module.revision) => filters
}.flatten ++ filters).forall { f =>
if (f(problem)) {
true
} else {
// log(projectName + ": filtered out: " + problem.description + "\n filtered by: " + f)
false
}
}
val backErrors = backward.filter(isReported(module, backwardFilters))
val forwErrors = forward.filter(isReported(module, forwardFilters))
val filteredCount = backward.size + forward.size - backErrors.size - forwErrors.size
val filteredNote = if (filteredCount > 0) " (filtered " + filteredCount + ")" else ""
// TODO - Line wrapping an other magikz
def prettyPrint(p: core.Problem, affected: String): String = {
" * " + p.description(affected) + p.howToFilter.map("\n filter with: " + _).getOrElse("")
}
log(
s"$projectName: found ${backErrors.size + forwErrors.size} potential binary incompatibilities while checking against $module $filteredNote")
((backErrors.map { p: core.Problem => prettyPrint(p, "current") }) ++
(forwErrors.map { p: core.Problem => prettyPrint(p, "other") })).foreach { p =>
log(p)
}
backErrors.nonEmpty || forwErrors.nonEmpty
}
def withLogger[T](f: (String => Unit) => T): (String, T) = {
val stringWriter = new StringWriter()
val printWriter = new PrintWriter(stringWriter)
val result = f(printWriter.println)
printWriter.close()
stringWriter.close()
(stringWriter.toString, result)
}
val allResults =
mimaPreviousClassfiles.value.toSeq.map {
case (moduleId, file) =>
val problems = SbtMima.runMima(
file,
mimaCurrentClassfiles.value,
(mimaFindBinaryIssues / fullClasspath).value,
mimaCheckDirection.value,
scalaVersion.value,
streams.value.log,
Nil)
val binary = mimaBinaryIssueFilters.value
val backward = mimaBackwardIssueFilters.value
val forward = mimaForwardIssueFilters.value
withLogger { logger =>
reportModuleErrors(
moduleId,
problems._1, problems._2,
binary,
backward,
forward,
logger,
name.value)
}
}
val noErrors = allResults.forall(!_._2)
if (noErrors) NoErrors
else {
val erroneous = allResults.filter(_._2)
Problems(erroneous.map(_._1).mkString("\n"))
}
})
}
/**
* This auto plugin adds UniDoc unification to validatePullRequest task.
*/
object UniDocWithPrValidation extends AutoPlugin {
import ValidatePullRequest._
override def trigger = noTrigger
override lazy val projectSettings = Seq(
ValidatePR / additionalTasks += Compile / unidoc)
}