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) }