diff --git a/build.sbt b/build.sbt index 777a9c5..9c34f3e 100644 --- a/build.sbt +++ b/build.sbt @@ -128,7 +128,7 @@ lazy val core = (project in file("modules/core")) // Docker image; host setup-sbt PATH is irrelevant. Docker is required when these tests run. // Leave Testcontainers Ryuk enabled (do not set TESTCONTAINERS_RYUK_DISABLED): cleans up containers // after aborted runs locally and is fine on GHA. - libraryDependencies ++= V.testcontainersDeps, + libraryDependencies ++= V.testcontainersDeps ++ V.deps(V.zioJson), ) // Scala 3 compiler trees for catalog files. Not on a consumer's sbt session unless they load the plugin check @@ -181,7 +181,7 @@ lazy val plugin = (project in file("modules/sbt-plugin")) scalacOptions ++= V.commonScalacOptions, publishMavenStyle := true, pomIncludeRepository := { _ => false }, - libraryDependencies ++= V.zioDeps, + libraryDependencies ++= V.zioDeps :+ V.moduleID(V.mimaCore), testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"), Test / mainClass := None, // Bundle the remote-cache transport so consumers need one addSbtPlugin line. RemoteCachePlugin triggers on diff --git a/modules/core/src/main/scala/zipx/core/Advisory.scala b/modules/core/src/main/scala/zipx/core/Advisory.scala index fceb1b6..23bfb69 100644 --- a/modules/core/src/main/scala/zipx/core/Advisory.scala +++ b/modules/core/src/main/scala/zipx/core/Advisory.scala @@ -2,6 +2,7 @@ package zipx.core import java.net.URI import java.time.Duration +import zio.json.* enum AdvisorySeverity: case Low, Moderate, High, Critical @@ -42,21 +43,28 @@ end OsvAdvisorySource object OsvAdvisorySource: + private final case class Query(`package`: Pkg, version: String) derives JsonEncoder + private final case class Pkg(purl: String) derives JsonEncoder + private final case class Response(vulns: Option[List[Vuln]]) derives JsonDecoder + private final case class Vuln(id: Option[String], summary: Option[String], severity: Option[String]) + derives JsonDecoder + private[core] def queryBody(purl: Purl, version: String): String = - s"""{"package":{"purl":"${PinSnapshot.escape(purl)}"},"version":"${PinSnapshot.escape(version)}"}""" + Query(Pkg(purl), version).toJson /** Pulls `id`, `summary`, and a severity token out of an OSV query response. Empty or missing `vulns` is no finding. */ private[core] def parseResponse(json: String): Either[String, List[Advisory]] = - MiniJson.extractArray(json, "vulns") match - case None => Right(Nil) - case Some(Left(err)) => Left(err) - case Some(Right(arr)) => - Right(MiniJson.objects(arr).map { obj => - val id = MiniJson.stringField(obj, "id").getOrElse("unknown") - val summary = MiniJson.stringField(obj, "summary").getOrElse("") - val severity = - MiniJson.stringField(obj, "severity").map(AdvisorySeverity.parse).getOrElse(AdvisorySeverity.Low) - Advisory(id, severity, summary) - }) + json.fromJson[Response] match + case Left(err) => Left(s"osv: $err") + case Right(res) => + Right( + res.vulns.getOrElse(Nil).map { v => + Advisory( + id = v.id.getOrElse("unknown"), + severity = v.severity.map(AdvisorySeverity.parse).getOrElse(AdvisorySeverity.Low), + summary = v.summary.getOrElse(""), + ) + } + ) end OsvAdvisorySource diff --git a/modules/core/src/main/scala/zipx/core/Capability.scala b/modules/core/src/main/scala/zipx/core/Capability.scala index 0146d02..ed18471 100644 --- a/modules/core/src/main/scala/zipx/core/Capability.scala +++ b/modules/core/src/main/scala/zipx/core/Capability.scala @@ -405,6 +405,8 @@ object Capability: val FmtName: CapabilityName = CapabilityName("fmt") val WorkflowCheckName: CapabilityName = CapabilityName("workflow-check") val AdvisoriesName: CapabilityName = CapabilityName("advisories") + val ModverCheckName: CapabilityName = CapabilityName("modver-check") + val ModverSuggestName: CapabilityName = CapabilityName("modver-suggest") private def testBody(scope: CapabilityScope, matrixed: Boolean): Capability = Capability( name = TestName, @@ -452,6 +454,32 @@ object Capability: env = Map(PinCheck.BaseShaEnv -> EnvValue.typed(Expr.github("event.pull_request.base.sha"))), ) + def modverCheck(command: SbtCommand = SbtCommand.unsafeTask("zipxModverCheck")): Capability = + Capability.once( + name = ModverCheckName, + command = command, + phase = Phase.Verify, + gate = Gate.Always, + needsCapabilities = Nil, + permissions = Map("contents" -> "read"), + extraSteps = ModverCheck.fetchBaseSha, + condition = Some(JobCondition.eventIs("pull_request")), + env = Map(ModverCheck.BaseShaEnv -> EnvValue.typed(Expr.github("event.pull_request.base.sha"))), + ) + + def modverSuggest(command: SbtCommand = SbtCommand.unsafeTask("zipxModverSuggest")): Capability = + Capability.once( + name = ModverSuggestName, + command = command, + phase = Phase.Verify, + gate = Gate.Always, + needsCapabilities = Nil, + permissions = Map("contents" -> "read", "pull-requests" -> "write"), + extraSteps = ModverCheck.fetchBaseSha, + condition = Some(JobCondition.eventIs("pull_request")), + env = Map(ModverCheck.BaseShaEnv -> EnvValue.typed(Expr.github("event.pull_request.base.sha"))), + ) + /** A Verify Once job that prints `zipx: skipping : ` and exits 0. The check name stays on the PR. */ def skipOnce(name: CapabilityName, gate: String, reason: String): Capability = Capability.steps( diff --git a/modules/core/src/main/scala/zipx/core/GitHubActionMeta.scala b/modules/core/src/main/scala/zipx/core/GitHubActionMeta.scala index 8f968ab..be9cdc6 100644 --- a/modules/core/src/main/scala/zipx/core/GitHubActionMeta.scala +++ b/modules/core/src/main/scala/zipx/core/GitHubActionMeta.scala @@ -1,76 +1,87 @@ package zipx.core +import zio.json.* + /** Parse GitHub releases/tags/git-ref JSON for [[Action]] bumps. HTTP lives in [[GitHubActionLookup]]. */ object GitHubActionMeta: final case class Release(tag: String, sha: Option[String]) + private final case class GhRelease(tag_name: String, draft: Option[Boolean], prerelease: Option[Boolean]) + derives JsonDecoder + private final case class GhCommit(sha: Option[String]) derives JsonDecoder + private final case class GhTag(name: String, commit: Option[GhCommit]) derives JsonDecoder + private final case class GhGitObject(`type`: Option[String], sha: Option[String]) derives JsonDecoder + private final case class GhGitRef(`object`: Option[GhGitObject], sha: Option[String], `type`: Option[String]) + derives JsonDecoder + def pickLatestRelease(json: String): Either[String, Option[Release]] = - val items = MiniJson.objects(if json.trim.startsWith("[") then json else s"[$json]") - val tags = items - .filter { obj => - !MiniJson.boolField(obj, "draft").contains(true) && - !MiniJson.boolField(obj, "prerelease").contains(true) - } - .flatMap(obj => MiniJson.stringField(obj, "tag_name")) - .filterNot(isPrereleaseTag) - Right(VersionStrategy.npm.latestStable(tags).orElse(tags.headOption).map(tag => Release(tag, None))) + val wrapped = if json.trim.startsWith("[") then json else s"[$json]" + wrapped.fromJson[List[GhRelease]] match + case Left(err) => Left(s"github releases: $err") + case Right(items) => + val tags = items + .filter(r => !r.draft.contains(true) && !r.prerelease.contains(true)) + .map(_.tag_name) + .filterNot(isPrereleaseTag) + Right(VersionStrategy.npm.latestStable(tags).orElse(tags.headOption).map(tag => Release(tag, None))) end pickLatestRelease def pickLatestTag(json: String): Either[String, Option[Release]] = - val items = MiniJson.objects(if json.trim.startsWith("[") then json else s"[$json]") - val tags = items.flatMap { obj => - MiniJson.stringField(obj, "name").filterNot(isPrereleaseTag).map { tag => - val sha = MiniJson.objectField(obj, "commit").flatMap(MiniJson.stringField(_, "sha")) - tag -> sha - } - } - Right( - VersionStrategy.npm - .latestStable(tags.map(_._1)) - .orElse(tags.headOption.map(_._1)) - .flatMap(latest => tags.find(_._1 == latest).map { case (tag, sha) => Release(tag, sha) }) - ) + val wrapped = if json.trim.startsWith("[") then json else s"[$json]" + wrapped.fromJson[List[GhTag]] match + case Left(err) => Left(s"github tags: $err") + case Right(items) => + val tags = items.flatMap { t => + Option.when(!isPrereleaseTag(t.name))(t.name -> t.commit.flatMap(_.sha)) + } + Right( + VersionStrategy.npm + .latestStable(tags.map(_._1)) + .orElse(tags.headOption.map(_._1)) + .flatMap(latest => tags.find(_._1 == latest).map { case (tag, sha) => Release(tag, sha) }) + ) + end match end pickLatestTag def peelSha(refJson: String, tagObjectJson: Option[String] = None): Either[String, String] = - val obj = MiniJson.objectField(refJson, "object").getOrElse(refJson) - val sha = MiniJson.stringField(obj, "sha") - val tpe = MiniJson.stringField(obj, "type") - (sha, tpe) match - case (Some(s), Some("commit")) if GitSha.make(s).isRight => Right(s) - case (Some(_), Some("tag")) => - tagObjectJson match - case None => Left("annotated tag needs a git/tags object to peel to a commit SHA") - case Some(body) => - MiniJson - .objectField(body, "object") - .flatMap(MiniJson.stringField(_, "sha")) - .orElse( - MiniJson.stringField(body, "sha") - ) match - case Some(s) if GitSha.make(s).isRight => Right(s) - case Some(s) => Left(s"peeled tag object is not a 40-hex SHA: $s") - case None => Left("git/tags object has no sha") - case (Some(s), _) if GitSha.make(s).isRight => Right(s) - case (Some(s), _) => Left(s"git ref is not a 40-hex SHA: $s") - case _ => Left("git ref JSON has no object.sha") - end match + gitObject(refJson).flatMap { obj => + (obj.sha, obj.`type`) match + case (Some(s), Some("commit")) if GitSha.make(s).isRight => Right(s) + case (Some(_), Some("tag")) => + tagObjectJson match + case None => Left("annotated tag needs a git/tags object to peel to a commit SHA") + case Some(body) => + gitObject(body).flatMap { peeled => + peeled.sha match + case Some(s) if GitSha.make(s).isRight => Right(s) + case Some(s) => Left(s"peeled tag object is not a 40-hex SHA: $s") + case None => Left("git/tags object has no sha") + } + case (Some(s), _) if GitSha.make(s).isRight => Right(s) + case (Some(s), _) => Left(s"git ref is not a 40-hex SHA: $s") + case _ => Left("git ref JSON has no object.sha") + } end peelSha + private def gitObject(json: String): Either[String, GhGitObject] = + json.fromJson[GhGitRef] match + case Left(err) => Left(s"git ref JSON: $err") + case Right(r) => Right(r.`object`.getOrElse(GhGitObject(r.`type`, r.sha))) + def isPrereleaseTag(tag: String): Boolean = val t = tag.stripPrefix("v").toLowerCase t.contains("rc") || t.contains("alpha") || t.contains("beta") || t.contains("milestone") || t.contains("-m") def peelFromRef(refJson: String, loadTagObject: String => Either[String, String]): Either[String, String] = - val obj = MiniJson.objectField(refJson, "object").getOrElse(refJson) - MiniJson.stringField(obj, "type") match - case Some("tag") => - MiniJson.stringField(obj, "sha") match - case None => Left("annotated tag git ref has no sha") - case Some(tagSha) => - loadTagObject(tagSha).flatMap(body => peelSha(refJson, Some(body))) - case _ => peelSha(refJson, None) + gitObject(refJson).flatMap { obj => + obj.`type` match + case Some("tag") => + obj.sha match + case None => Left("annotated tag git ref has no sha") + case Some(tagSha) => loadTagObject(tagSha).flatMap(body => peelSha(refJson, Some(body))) + case _ => peelSha(refJson, None) + } def classify(from: String, to: String): BumpKind = VersionStrategy.npm.classify(from.stripPrefix("v"), to.stripPrefix("v")) diff --git a/modules/core/src/main/scala/zipx/core/MiniJson.scala b/modules/core/src/main/scala/zipx/core/MiniJson.scala deleted file mode 100644 index 3e90b21..0000000 --- a/modules/core/src/main/scala/zipx/core/MiniJson.scala +++ /dev/null @@ -1,148 +0,0 @@ -package zipx.core - -/** Tiny JSON extractors for OSV responses and the pin-inventory dump. Not a general parser. */ -private[core] object MiniJson: - - def extractArray(json: String, key: String): Option[Either[String, String]] = - val needle = s"\"$key\"" - val idx = json.indexOf(needle) - if idx < 0 then None - else - var i = idx + needle.length - while i < json.length && json.charAt(i).isWhitespace do i += 1 - if i < json.length && json.charAt(i) == ':' then - i += 1 - while i < json.length && json.charAt(i).isWhitespace do i += 1 - if i >= json.length then Some(Left(s"json: missing value for $key")) - else if json.charAt(i) == '[' then Some(balanced(json, i, '[', ']')) - else if json.startsWith("null", i) then None - else Some(Left(s"json: $key is not an array")) - end if - end extractArray - - def balanced(json: String, start: Int, open: Char, close: Char): Either[String, String] = - var depth = 0 - var i = start - var inStr = false - var esc = false - while i < json.length do - val c = json.charAt(i) - if inStr then - if esc then esc = false - else if c == '\\' then esc = true - else if c == '"' then inStr = false - else if c == '"' then inStr = true - else if c == open then depth += 1 - else if c == close then - depth -= 1 - if depth == 0 then return Right(json.substring(start, i + 1)) - i += 1 - end while - Left("json: unterminated JSON array") - end balanced - - def objects(arrayJson: String): List[String] = - val inner = arrayJson.substring(1, arrayJson.length - 1).trim - if inner.isEmpty then Nil - else - val buf = List.newBuilder[String] - var i = 0 - var depth = 0 - var start = -1 - var inStr = false - var esc = false - while i < inner.length do - val c = inner.charAt(i) - if inStr then - if esc then esc = false - else if c == '\\' then esc = true - else if c == '"' then inStr = false - else if c == '"' then inStr = true - else if c == '{' then - if depth == 0 then start = i - depth += 1 - else if c == '}' then - depth -= 1 - if depth == 0 && start >= 0 then - buf += inner.substring(start, i + 1) - start = -1 - end if - i += 1 - end while - buf.result() - end if - end objects - - def stringField(obj: String, key: String): Option[String] = - val needle = s"\"$key\"" - val idx = obj.indexOf(needle) - if idx < 0 then None - else - val colon = obj.indexOf(':', idx + needle.length) - if colon < 0 then None - else - var i = colon + 1 - while i < obj.length && obj.charAt(i).isWhitespace do i += 1 - if i >= obj.length || obj.charAt(i) != '"' then None - else - val from = i + 1 - var j = from - var esc = false - while j < obj.length do - val c = obj.charAt(j) - if esc then esc = false - else if c == '\\' then esc = true - else if c == '"' then return Some(unescape(obj.substring(from, j))) - j += 1 - None - end if - end if - end if - end stringField - - def boolField(obj: String, key: String): Option[Boolean] = - rawField(obj, key).flatMap { - case v if v.startsWith("true") => Some(true) - case v if v.startsWith("false") => Some(false) - case _ => None - } - - def objectField(obj: String, key: String): Option[String] = - rawField(obj, key).flatMap { v => - if v.startsWith("{") then balanced(v, 0, '{', '}').toOption else None - } - - private def rawField(obj: String, key: String): Option[String] = - val needle = s"\"$key\"" - val idx = obj.indexOf(needle) - if idx < 0 then None - else - val colon = obj.indexOf(':', idx + needle.length) - if colon < 0 then None - else - var i = colon + 1 - while i < obj.length && obj.charAt(i).isWhitespace do i += 1 - if i >= obj.length then None - else Some(obj.substring(i)) - end if - end rawField - - private def unescape(s: String): String = - val out = StringBuilder(s.length) - var i = 0 - while i < s.length do - if s.charAt(i) == '\\' && i + 1 < s.length then - s.charAt(i + 1) match - case '"' => out.append('"'); i += 2 - case '\\' => out.append('\\'); i += 2 - case 'n' => out.append('\n'); i += 2 - case 'r' => out.append('\r'); i += 2 - case 't' => out.append('\t'); i += 2 - case c => out.append(c); i += 2 - else - out.append(s.charAt(i)) - i += 1 - end while - out.toString - end unescape -end MiniJson diff --git a/modules/core/src/main/scala/zipx/core/Modver.scala b/modules/core/src/main/scala/zipx/core/Modver.scala index bfc5892..0a3cc23 100644 --- a/modules/core/src/main/scala/zipx/core/Modver.scala +++ b/modules/core/src/main/scala/zipx/core/Modver.scala @@ -115,7 +115,7 @@ object Modver: yield (major, minor, patch) end parseSemver - /** PR 1 stub: later rounds run MiMa then propagate. Identity so MatchBump cannot see Patch placeholders. */ + /** After MiMa kinds exist, PR 4 will walk reverse-deps. Identity so MatchBump cannot see Patch placeholders. */ def expand(bumps: BumpSet, graph: ModuleGraph, ships: ShipIndex): BumpSet = val _ = (graph, ships) bumps @@ -291,4 +291,123 @@ object Modver: } } } + + def isJsOnly(graph: ModuleGraph, root: ModuleId): Boolean = + val rows = graph.nodes.filter(n => n.matrixRoot == root && n.publishes) + rows.nonEmpty && rows.forall(n => (n.id: String).endsWith("JS")) + + def minBumpKind(version: String, scheme: String, probe: MemberProbe): BumpKind = + probe match + case MemberProbe.FirstPublish => BumpKind.None + case MemberProbe.JsOnly => BumpKind.Patch + case MemberProbe.Clean => BumpKind.Patch + case MemberProbe.BinaryBreak => + val earlyZero = parseSemver(version).exists(_._1 == 0) && isEarlySemver(scheme) + if earlyZero then BumpKind.Minor else BumpKind.Major + + def isEarlySemver(scheme: String): Boolean = + scheme.trim.toLowerCase match + case "semver-spec" => false + case _ => true + + def maxKind(kinds: Iterable[BumpKind]): BumpKind = + val counted = kinds.filter(k => k != BumpKind.None && k != BumpKind.PreRelease) + counted.maxOption(using minBumpOrd).getOrElse(BumpKind.None) + + def suggestedVersion(from: String, kind: BumpKind): Either[String, String] = + kind match + case BumpKind.None | BumpKind.PreRelease => Right(from) + case other => bumpVersion(from, other) + + def writtenStatus(base: String, written: String, floor: BumpKind): BumpStatus = + if floor == BumpKind.None || floor == BumpKind.PreRelease then BumpStatus.Ok + else if written == base then BumpStatus.Missing + else + val got = VersionStrategy.npm.classify(base, written) + val cmp = minBumpOrd.compare(got, floor) + if cmp < 0 then BumpStatus.Undersized + else if cmp > 0 then BumpStatus.OverBump + else BumpStatus.Ok + + def checkFails(status: BumpStatus): Boolean = + status == BumpStatus.Missing || status == BumpStatus.Undersized || status == BumpStatus.NewMemberDirty + + def suggestedCtor(row: PublishedRow, to: String): String = + row match + case s: Ship => s"""Ship("${s.id}", "$to")""" + case g: ShipGroup => + val mem = g.members.map(m => s""""$m"""").mkString(", ") + s"""ShipGroup("${g.name}", "$to")($mem)""" + + def minBumps( + lifted: Set[ShipRef], + index: ShipIndex, + graph: ModuleGraph, + previous: ShipIndex, + schemeOf: ModuleId => String, + probeOf: ModuleId => MemberProbe, + ): Map[ShipRef, BumpKind] = + lifted.iterator.map { ref => + index.byIdentity.get(ref) match + case None => ref -> BumpKind.None + case Some(row) => + val kinds = row.memberRoots.map { root => + val probe = + if previous.rowFor(root).isEmpty then MemberProbe.FirstPublish + else if isJsOnly(graph, root) then MemberProbe.JsOnly + else probeOf(root) + minBumpKind(row.version, schemeOf(root), probe) + } + ref -> maxKind(kinds) + }.toMap + + def newMemberDirtyRefs(moved: MovedRows, lifted: Set[ShipRef], index: ShipIndex): Set[ShipRef] = + moved.newMembers.flatMap { id => + index.byRoot.get(id).map(index.refOf).filter { ref => + lifted.contains(ref) && !moved.versionChanged.contains(ref) + } + } + + def report( + current: ShipIndex, + previous: ShipIndex, + lifted: Set[ShipRef], + moved: MovedRows, + kinds: Map[ShipRef, BumpKind], + mimaRan: Set[ShipRef], + ): Either[String, ModverReport] = + val dirty = newMemberDirtyRefs(moved, lifted, current) + val refs = (lifted ++ dirty).toList.sortBy { + case ShipRef.One(id) => id: String + case ShipRef.Group(name) => name: String + } + refs + .foldLeft[Either[String, List[ModverReportRow]]](Right(Nil)) { (acc, ref) => + acc.flatMap { rows => + current.byIdentity.get(ref) match + case None => Left(s"no catalog row for $ref") + case Some(row) => + val written = row.version: String + val from = previous.byIdentity.get(ref).map(r => r.version: String).getOrElse(written) + val floor = kinds.getOrElse(ref, BumpKind.None) + suggestedVersion(from, floor).map { suggested => + val status0 = writtenStatus(from, written, floor) + val status = + if dirty.contains(ref) && written == from then BumpStatus.NewMemberDirty else status0 + rows :+ ModverReportRow( + identity = row.identity, + label = row.label, + from = from, + written = written, + suggested = suggested, + constructor = suggestedCtor(row, suggested), + kind = floor, + mimaRan = mimaRan.contains(ref), + status = status, + ) + } + } + } + .map(ModverReport(_)) + end report end Modver diff --git a/modules/core/src/main/scala/zipx/core/ModverCheck.scala b/modules/core/src/main/scala/zipx/core/ModverCheck.scala new file mode 100644 index 0000000..6e3bafb --- /dev/null +++ b/modules/core/src/main/scala/zipx/core/ModverCheck.scala @@ -0,0 +1,28 @@ +package zipx.core + +import zipx.shell.* +import zipx.workflow.Step + +/** PR `modver-check` / `modver-suggest` extras: base SHA env and the fetch step both jobs need. */ +object ModverCheck: + + val BaseShaEnv: String = "ZIPX_MODVER_BASE_SHA" + + val fetchBaseSha: Steps = + Steps.of("fetch-modver-base")( + Step + .run( + Script( + Exec( + "git", + Word.lit("fetch"), + Word.lit("--no-tags"), + Word.lit("origin"), + Word.vq("ZIPX_MODVER_BASE_SHA"), + ) + ) + ) + .named("Fetch PR base SHA") + .build + ) +end ModverCheck diff --git a/modules/core/src/main/scala/zipx/core/ModverComment.scala b/modules/core/src/main/scala/zipx/core/ModverComment.scala new file mode 100644 index 0000000..058e4db --- /dev/null +++ b/modules/core/src/main/scala/zipx/core/ModverComment.scala @@ -0,0 +1,34 @@ +package zipx.core + +/** Sticky PR comment for suggested Ship edits. Tests assert the body; they never POST. */ +object ModverComment: + + val Marker: String = "" + + def body(report: ModverReport, suggestion: Option[String] = None): String = + val table = + if report.rows.isEmpty then "No Ship / ShipGroup bumps required on this PR." + else + val header = "| Identity | From | Suggested | Written | Status |" + val sep = "|---|---|---|---|---|" + val rows = report.rows.map { r => + s"| `${r.identity}` | `${r.from}` | `${r.suggested}` | `${r.written}` | ${r.status} |" + } + (header +: sep +: rows).mkString("\n") + val fence = suggestion.fold("") { ctor => + s""" + | + |```suggestion + |$ctor + |``` + |""".stripMargin + } + s"""$Marker + | + |## zipx module versions + | + |$table + |$fence + |""".stripMargin + end body +end ModverComment diff --git a/modules/core/src/main/scala/zipx/core/ModverReport.scala b/modules/core/src/main/scala/zipx/core/ModverReport.scala new file mode 100644 index 0000000..243e34c --- /dev/null +++ b/modules/core/src/main/scala/zipx/core/ModverReport.scala @@ -0,0 +1,32 @@ +package zipx.core + +import zio.json.* + +enum MemberProbe derives JsonCodec: + case FirstPublish, JsOnly, BinaryBreak, Clean + +enum BumpStatus derives JsonCodec: + case Ok, Missing, Undersized, OverBump, NewMemberDirty + +final case class ModverReportRow( + identity: String, + label: String, + from: String, + written: String, + suggested: String, + constructor: String, + kind: BumpKind, + mimaRan: Boolean, + status: BumpStatus, +) derives JsonCodec + +final case class ModverReport(rows: List[ModverReportRow]) derives JsonCodec + +object ModverReport: + val RelPath: String = "target/zipx-modver-report.json" + + def parse(json: String): Either[String, ModverReport] = + json.fromJson[ModverReport].left.map(err => s"modver report: $err") + + def render(report: ModverReport): String = report.toJson +end ModverReport diff --git a/modules/core/src/main/scala/zipx/core/PinFeed.scala b/modules/core/src/main/scala/zipx/core/PinFeed.scala index a5a99a1..22a7e09 100644 --- a/modules/core/src/main/scala/zipx/core/PinFeed.scala +++ b/modules/core/src/main/scala/zipx/core/PinFeed.scala @@ -2,6 +2,7 @@ package zipx.core import neotype.Subtype import zipx.workflow.Names +import zio.json.* /** A feed's name, which is also a snapshot manifest key and must be a GitHub Actions id. */ type PinFeedName = PinFeedName.Type @@ -21,7 +22,7 @@ enum PinAction: enum PinPrGate: case All, Introduced, Off -enum BumpKind: +enum BumpKind derives JsonCodec: case None, Patch, Minor, Major, PreRelease /** Whether catalog / pin lookup may list a pre-release as a bump. Default [[Skip]]. */ diff --git a/modules/core/src/main/scala/zipx/core/PinInventory.scala b/modules/core/src/main/scala/zipx/core/PinInventory.scala index 8e443e3..f90587c 100644 --- a/modules/core/src/main/scala/zipx/core/PinInventory.scala +++ b/modules/core/src/main/scala/zipx/core/PinInventory.scala @@ -1,54 +1,35 @@ package zipx.core +import zio.json.* + /** Setting-time inventory dump so [[PinPrGate.Introduced]] can diff against a worktree at the PR base SHA. */ object PinInventory: val RelPath: String = "target/zipx-pin-inventory.json" + final case class File(feeds: List[Feed]) derives JsonCodec + final case class Feed(name: String, pins: List[PinRow]) derives JsonCodec + final case class PinRow(id: String, current: String) derives JsonCodec + def render(feeds: Seq[PinFeed], pins: Seq[Pin]): String = - val items = feeds - .map { feed => - val feedPins = PinFeeds - .inventory(feed, pins) - .map { pin => - s"""{"id":"${PinSnapshot.escape(pin.id)}","current":"${PinSnapshot.escape(pin.current)}"}""" - } - .mkString(",") - s"""{"name":"${PinSnapshot.escape(feed.name)}","pins":[$feedPins]}""" + File( + feeds.toList.map { feed => + Feed( + name = feed.name, + pins = PinFeeds.inventory(feed, pins).map(p => PinRow(p.id, p.current)), + ) } - .mkString(",") - s"""{"feeds":[$items]}""" - end render + ).toJson def parse(json: String): Either[String, Map[PinFeedName, List[PinnedDep]]] = - MiniJson.extractArray(json, "feeds") match - case None => Right(Map.empty) - case Some(Left(err)) => Left(err) - case Some(Right(arr)) => - MiniJson.objects(arr).foldLeft[Either[String, Map[PinFeedName, List[PinnedDep]]]](Right(Map.empty)) { - (acc, obj) => - acc.flatMap { m => - MiniJson.stringField(obj, "name") match - case None => Left("pin inventory: feed missing name") - case Some(name) => - PinFeedName.make(name).flatMap { n => - pinsOf(obj).map(ps => m + (n -> ps)) - } - } - } - - private def pinsOf(feedObj: String): Either[String, List[PinnedDep]] = - MiniJson.extractArray(feedObj, "pins") match - case None => Right(Nil) - case Some(Left(err)) => Left(err) - case Some(Right(arr)) => - MiniJson - .objects(arr) - .foldLeft[Either[String, List[PinnedDep]]](Right(Nil)) { (acc, obj) => - acc.flatMap { pins => - (MiniJson.stringField(obj, "id"), MiniJson.stringField(obj, "current")) match - case (Some(id), Some(current)) => Right(pins :+ PinnedDep(id, current)) - case _ => Left("pin inventory: pin missing id or current") + json.fromJson[File] match + case Left(err) => Left(s"pin inventory: $err") + case Right(file) => + file.feeds.foldLeft[Either[String, Map[PinFeedName, List[PinnedDep]]]](Right(Map.empty)) { (acc, feed) => + acc.flatMap { m => + PinFeedName.make(feed.name).map { n => + m + (n -> feed.pins.map(p => PinnedDep(p.id, p.current))) } } + } end PinInventory diff --git a/modules/core/src/main/scala/zipx/core/PinSnapshot.scala b/modules/core/src/main/scala/zipx/core/PinSnapshot.scala index 8558274..9bfc3f9 100644 --- a/modules/core/src/main/scala/zipx/core/PinSnapshot.scala +++ b/modules/core/src/main/scala/zipx/core/PinSnapshot.scala @@ -1,10 +1,26 @@ package zipx.core -/** GitHub Dependency Submission snapshot JSON. Hand-rolled so core stays free of a JSON library. */ +import zio.json.* + +/** GitHub Dependency Submission snapshot JSON. */ object PinSnapshot: val Correlator: String = "zipx-pin-snapshot" + final case class Snapshot( + version: Int, + sha: String, + ref: String, + job: Job, + detector: Detector, + scanned: String, + manifests: Map[String, Manifest], + ) derives JsonEncoder + final case class Job(correlator: String, id: String) derives JsonEncoder + final case class Detector(name: String, version: String, url: String) derives JsonEncoder + final case class Manifest(name: String, resolved: Map[String, Resolved]) derives JsonEncoder + final case class Resolved(package_url: String, relationship: String, scope: String) derives JsonEncoder + def render( feeds: Seq[PinFeed], pins: Seq[Pin], @@ -15,62 +31,30 @@ object PinSnapshot: detectorVersion: String, ): String = val manifests = feeds - .map(feed => feed.name -> PinFeeds.inventory(feed, pins).filter(_.purl.isDefined)) - .filter(_._2.nonEmpty) - val manifestJson = - if manifests.isEmpty then "{}" - else - manifests - .map { (name, pins) => - val resolved = pins - .map { pin => - val purl = pin.purl.get - s""" "${escape(pin.id)}":{"package_url":"${escape( - purl - )}","relationship":"direct","scope":"runtime"}""" - } - .mkString(",\n") - s""" "${escape(name)}":{"name":"${escape(name)}","resolved":{\n$resolved\n }}""" + .map { feed => + val resolved = PinFeeds + .inventory(feed, pins) + .flatMap { pin => + pin.purl.map { purl => + pin.id -> Resolved(package_url = purl, relationship = "direct", scope = "runtime") + } } - .mkString(",\n") - s"""{ - "version": 0, - "sha": "${escape(sha)}", - "ref": "${escape(ref)}", - "job": {"correlator": "$Correlator", "id": "${escape(jobId)}"}, - "detector": {"name": "zipx", "version": "${escape(detectorVersion)}", "url": "https://github.com/early-effect/zipx"}, - "scanned": "${escape(scanned)}", - "manifests": { -$manifestJson - } -}""" + .toMap + (feed.name: String) -> Manifest(name = feed.name, resolved = resolved) + } + .filter(_._2.resolved.nonEmpty) + .toMap + Snapshot( + version = 0, + sha = sha, + ref = ref, + job = Job(Correlator, jobId), + detector = Detector("zipx", detectorVersion, "https://github.com/early-effect/zipx"), + scanned = scanned, + manifests = manifests, + ).toJson end render - def escape(s: String): String = - val out = StringBuilder(s.length) - var i = 0 - while i < s.length do - s.charAt(i) match - case '"' => out.append("\\\"") - case '\\' => out.append("\\\\") - case '\n' => out.append("\\n") - case '\r' => out.append("\\r") - case '\t' => out.append("\\t") - case c if c < 32 => - out.append("\\u") - val hex = Integer.toHexString(c) - var pad = 4 - hex.length - while pad > 0 do - out.append('0') - pad -= 1 - out.append(hex) - case c => out.append(c) - end match - i += 1 - end while - out.toString - end escape - /** POST the snapshot to GitHub's Dependency Submission API. Tests never call this. */ def submit( token: String, diff --git a/modules/core/src/main/scala/zipx/core/ZipxSettings.scala b/modules/core/src/main/scala/zipx/core/ZipxSettings.scala index 059f4df..24f388b 100644 --- a/modules/core/src/main/scala/zipx/core/ZipxSettings.scala +++ b/modules/core/src/main/scala/zipx/core/ZipxSettings.scala @@ -269,6 +269,30 @@ object ZipxSettings: ), ) + val modverCompat: SettingDef[Unit] = + SettingDef.task( + SettingName("zipxModverCompat"), + SettingPurpose( + "Compile the lifted bump set, run MiMa, write target/zipx-modver-report.json. Fail closed on a missing diff." + ), + ) + + val modverCheck: SettingDef[Unit] = + SettingDef.task( + SettingName("zipxModverCheck"), + SettingPurpose( + "Fail closed on a missing or undersized Ship bump. Reads the min-bump report after zipxModverCompat." + ), + ) + + val modverSuggest: SettingDef[Unit] = + SettingDef.task( + SettingName("zipxModverSuggest"), + SettingPurpose( + "Sticky PR comment with suggested Ship / ShipGroup constructors. Best-effort on forks." + ), + ) + val sbtVersionCoord: SettingDef[Option[SbtVersion]] = SettingDef.setting( SettingName("zipxSbt"), @@ -690,6 +714,9 @@ object ZipxSettings: depUpdate, actionUpdate, modverBump, + modverCompat, + modverCheck, + modverSuggest, ) /** Every public catalog entry, in docs-friendly order. */ diff --git a/modules/core/src/test/scala/zipx/core/ModverSpec.scala b/modules/core/src/test/scala/zipx/core/ModverSpec.scala index ceb5a82..4218f6b 100644 --- a/modules/core/src/test/scala/zipx/core/ModverSpec.scala +++ b/modules/core/src/test/scala/zipx/core/ModverSpec.scala @@ -398,6 +398,103 @@ object ModverSpec extends ZIOSpecDefault: case Right(pub) => assertTrue(pub.keySet == Set(ModuleId("core"), ModuleId("coreJS"))) }, ), + suite("min-bump")( + test("early-semver 0.y binary break is minor; 1.y is major") { + assertTrue( + Modver.minBumpKind("0.4.2", "early-semver", MemberProbe.BinaryBreak) == BumpKind.Minor, + Modver.minBumpKind("1.4.2", "early-semver", MemberProbe.BinaryBreak) == BumpKind.Major, + Modver.minBumpKind("1.4.2", "pvp", MemberProbe.BinaryBreak) == BumpKind.Major, + Modver.minBumpKind("1.4.2", "semver-spec", MemberProbe.BinaryBreak) == BumpKind.Major, + Modver.minBumpKind("1.4.2", "early-semver", MemberProbe.Clean) == BumpKind.Patch, + Modver.minBumpKind("1.4.2", "early-semver", MemberProbe.JsOnly) == BumpKind.Patch, + Modver.minBumpKind("1.4.2", "early-semver", MemberProbe.FirstPublish) == BumpKind.None, + ) + }, + test("group max ignores None and uses minBumpOrd") { + assertTrue( + Modver.maxKind(List(BumpKind.Patch, BumpKind.Major, BumpKind.None)) == BumpKind.Major, + Modver.maxKind(List(BumpKind.None)) == BumpKind.None, + Modver.maxKind(Nil) == BumpKind.None, + ) + }, + test("writtenStatus is missing when equal, undersized when below the floor, over-bump when above") { + assertTrue( + Modver.writtenStatus("1.4.2", "1.4.2", BumpKind.Patch) == BumpStatus.Missing, + Modver.writtenStatus("1.4.2", "1.4.3", BumpKind.Minor) == BumpStatus.Undersized, + Modver.writtenStatus("1.4.2", "1.5.0", BumpKind.Patch) == BumpStatus.OverBump, + Modver.writtenStatus("1.4.2", "1.4.3", BumpKind.Patch) == BumpStatus.Ok, + Modver.writtenStatus("1.4.2", "1.4.2", BumpKind.None) == BumpStatus.Ok, + Modver.checkFails(BumpStatus.Missing), + Modver.checkFails(BumpStatus.Undersized), + !Modver.checkFails(BumpStatus.OverBump), + !Modver.checkFails(BumpStatus.Ok), + ) + }, + test("JS-only roots skip MiMa and first publish has no floor") { + val jsOnly = GraphFixture( + List( + node("uiJS", sources = List("ui/src/main/scala", "ui/src/main/scalajs"), root = "ui") + ) + ) + assertTrue( + Modver.isJsOnly(jsOnly, ModuleId.unsafeMake("ui")), + !Modver.isJsOnly(matrix, ModuleId.unsafeMake("core")), + ) + }, + test("report JSON round-trips through zio-json and missing bump fails the gate") { + val kinds = Map[ShipRef, BumpKind](ShipRef.One(ModuleId.unsafeMake("client")) -> BumpKind.Patch) + val lifted = Set[ShipRef](ShipRef.One(ModuleId.unsafeMake("client"))) + Modver.report(index, index, lifted, MovedRows.empty, kinds, mimaRan = Set.empty) match + case Left(err) => assertTrue(err.isEmpty) + case Right(report) => + val json = ModverReport.render(report) + assertTrue( + ModverReport.parse(json) == Right(report), + report.rows.exists(r => r.identity == "client" && r.status == BumpStatus.Missing), + report.rows.exists(r => Modver.checkFails(r.status)), + ) + }, + test("comment body carries the sticky marker and a constructor table") { + val row = ModverReportRow( + identity = "client", + label = "Ship", + from = "0.3.0", + written = "0.3.0", + suggested = "0.3.1", + constructor = """Ship("client", "0.3.1")""", + kind = BumpKind.Patch, + mimaRan = true, + status = BumpStatus.Missing, + ) + val body = ModverComment.body(ModverReport(List(row)), Some("""Ship("client", "0.3.1")""")) + assertTrue( + body.contains(ModverComment.Marker), + body.contains("client"), + body.contains("0.3.1"), + body.contains("```suggestion"), + ) + }, + ), + suite("Capability.modverCheck")( + test("allJobIds matches plan job keys") { + val cap = Capability.modverCheck() + val cfg = PlanConfig(skipMergedPrPush = false, verifyCleanLabel = None, affected = AffectedMode.Always) + val ids = Planner.allJobIds(cap, graph, cfg).map(id => id: String).sorted + val wf = Planner.plan(graph, List(cap), cfg) + assertTrue(ids == List("modver-check"), wf.jobs.keys.toList.sorted == ids) + }, + test("planned YAML is pull_request and does not sit on test needs") { + val cfg = PlanConfig(skipMergedPrPush = false, verifyCleanLabel = None, affected = AffectedMode.Always) + val wf = Planner.plan(graph, List(Capability.modverCheck(), Capability.test), cfg) + val checkIf = wf.jobs("modver-check").`if`.getOrElse("") + assertTrue( + checkIf.contains("pull_request"), + !wf.jobs("modver-check").needs.contains("test"), + !wf.jobs("test").needs.contains("modver-check"), + wf.jobs("modver-check").env.contains(ModverCheck.BaseShaEnv), + ) + }, + ), suite("thisCommitReleases")( test("a version change, a first add, or a new member releases that row") { val versioned = MovedRows( diff --git a/modules/core/src/test/scala/zipx/core/ZipxSettingsSpec.scala b/modules/core/src/test/scala/zipx/core/ZipxSettingsSpec.scala index 6016929..e4a00f0 100644 --- a/modules/core/src/test/scala/zipx/core/ZipxSettingsSpec.scala +++ b/modules/core/src/test/scala/zipx/core/ZipxSettingsSpec.scala @@ -34,6 +34,9 @@ object ZipxSettingsSpec extends ZIOSpecDefault: ZipxSettings.names.contains("zipxShips"), ZipxSettings.names.contains("zipxMatrixRoot"), ZipxSettings.names.contains("zipxModverBump"), + ZipxSettings.names.contains("zipxModverCompat"), + ZipxSettings.names.contains("zipxModverCheck"), + ZipxSettings.names.contains("zipxModverSuggest"), ) }, test("build / project / task partitions cover every entry exactly once") { diff --git a/modules/sbt-plugin/src/main/scala/zipx/plugin/ZipxPlugin.scala b/modules/sbt-plugin/src/main/scala/zipx/plugin/ZipxPlugin.scala index 8e1fc79..18eae01 100644 --- a/modules/sbt-plugin/src/main/scala/zipx/plugin/ZipxPlugin.scala +++ b/modules/sbt-plugin/src/main/scala/zipx/plugin/ZipxPlugin.scala @@ -6,6 +6,7 @@ import zipx.core.* import zipx.workflow.Render import zipx.workflow.Step import zipx.workflow.Workflow +import zio.json.* /** zipx: the build describes its own GitHub Actions CI. * @@ -430,6 +431,9 @@ object ZipxPlugin extends AutoPlugin: val zipxDepUpdate = inputKey[Unit](ZipxSettings.depUpdate.description) val zipxActionUpdate = inputKey[Unit](ZipxSettings.actionUpdate.description) val zipxModverBump = inputKey[Unit](ZipxSettings.modverBump.description) + val zipxModverCompat = taskKey[Unit](ZipxSettings.modverCompat.description) + val zipxModverCheck = taskKey[Unit](ZipxSettings.modverCheck.description) + val zipxModverSuggest = taskKey[Unit](ZipxSettings.modverSuggest.description) end autoImport import autoImport.* @@ -550,6 +554,9 @@ object ZipxPlugin extends AutoPlugin: zipxDepUpdate := depUpdateTask.evaluated, zipxActionUpdate := actionUpdateTask.evaluated, zipxModverBump := modverBumpTask.evaluated, + zipxModverCompat := Def.uncached { modverCompatTask.value }, + zipxModverCheck := Def.uncached { modverCheckTask.value }, + zipxModverSuggest := Def.uncached { modverSuggestTask.value }, zipxDepUpdate / aggregate := false, zipxActionUpdate / aggregate := false, zipxPinUpdate / aggregate := false, @@ -859,7 +866,17 @@ object ZipxPlugin extends AutoPlugin: val userCaps = readBuildSetting(extracted, zipxCapabilities, Seq.empty) val verifyTask = readBuildSetting(extracted, zipxTestTask, CapabilityTasks.of(testFull)) val verify = orFail(ZipxVerify.validate(readBuildSetting(extracted, zipxVerify, ZipxVerify.Strict))) - combineCapabilities(builtinCapabilities(graph, verifyTask, verify), userCaps.toList) + val ships = readBuildSetting(extracted, zipxShips, Seq.empty) + val builtins = builtinCapabilities(graph, verifyTask, verify) + val modver = + if ships.isEmpty then Nil + else + List( + Capability.modverSuggest(CapabilityTasks.of(zipxModverSuggest)), + Capability.modverCheck(CapabilityTasks.of(zipxModverCheck)), + ) + combineCapabilities(builtins ++ modver, userCaps.toList) + end capabilitiesOf /** Same graph and capabilities as [[renderWorkflow]], so catalog generate and workflow generate agree on composites. */ @@ -1617,10 +1634,10 @@ object ZipxPlugin extends AutoPlugin: val diffFailed = changedFiles.isEmpty if diffFailed then streams.value.log.warn( - s"zipx: could not diff against '$baseRef', emitting ${jsonArray(Affected.AllSentinel)} so every job runs. " + + s"zipx: could not diff against '$baseRef', emitting ${List(Affected.AllSentinel).toJson} so every job runs. " + "Affected-only gating is disabled for this run." ) - val json = jsonArray(Affected.outputModules(graph, changedFiles)) + val json = Affected.outputModules(graph, changedFiles).toJson IO.write(root / "target" / "zipx-affected.json", json + "\n") println(json) } @@ -1641,7 +1658,211 @@ object ZipxPlugin extends AutoPlugin: if code == 0 then Some(lines.map(_.trim).filter(_.nonEmpty).toList) else None catch case scala.util.control.NonFatal(_) => None - private def jsonArray(items: List[String]): String = - items.map(s => "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"").mkString("[", ",", "]") + private def modverCompatTask: Def.Initialize[Task[ModverReport]] = Def.task { + val extracted = Project.extract(state.value) + val graph = buildGraph.value + val ships = readBuildSetting(extracted, zipxShips, Seq.empty) + val log = streams.value.log + val root = (LocalRootProject / baseDirectory).value + if ships.isEmpty then + log.info("zipx: zipxShips is empty; no min-bump report") + ModverReport(Nil) + else + val st = state.value + val report = orFail(writeModverReport(extracted, graph, ships, root, st)) + val file = root / ModverReport.RelPath + IO.write(file, ModverReport.render(report) + "\n") + log.info(s"zipx: wrote ${file.getPath}") + report + end if + } + + private def modverCheckTask: Def.Initialize[Task[Unit]] = Def.task { + val report = modverCompatTask.value + val bad = report.rows.filter(r => Modver.checkFails(r.status)) + if bad.nonEmpty then + val listed = bad + .map(r => s"${r.label}(\"${r.identity}\") ${r.from} -> ${r.suggested} (${r.status})") + .mkString("; ") + sys.error(s"missing or undersized Ship bump: $listed") + else streams.value.log.info("zipx: Ship versions meet the min bump") + } + + private def modverSuggestTask: Def.Initialize[Task[Unit]] = Def.task { + val report = modverCompatTask.value + val log = streams.value.log + val ctor = + report.rows.find(r => r.status == BumpStatus.Missing || r.status == BumpStatus.Undersized).map(_.constructor) + val body = ModverComment.body(report, ctor) + postModverComment(body, log) + } + + private def writeModverReport( + extracted: Extracted, + graph: ModuleGraph, + ships: Seq[PublishedRow], + root: File, + st: State, + ): Either[String, ModverReport] = + val rel = readBuildSetting(extracted, zipxVersionsFile, ZipxCatalog.DefaultVersionsFile) + val baseSha = sys.env.get(ModverCheck.BaseShaEnv).filter(_.nonEmpty).getOrElse("HEAD^") + for + index <- Modver.membership(graph, ships) + files <- gitDiffNames(root, baseSha).toRight(s"could not diff changed files against '$baseSha'") + lifted <- Modver.liftedBumpSet(graph, index, Some(files)) + previous <- previousShips(root, rel, baseSha).map(ShipIndex.from) + moved <- Modver.movedRows(index, Right(previous)) + kinds = Modver.minBumps( + lifted, + index, + graph, + previous, + schemeOf = id => versionSchemeOf(extracted, id), + probeOf = id => probeMember(extracted, graph, previous, id, st), + ) + mimaRan = + kinds.keySet.filter { ref => + index.byIdentity.get(ref).exists { row => + row.memberRoots.exists(rootId => !Modver.isJsOnly(graph, rootId) && previous.rowFor(rootId).isDefined) + } + } + bumps = Modver.expand(BumpSet(kinds), graph, index) + report <- Modver.report(index, previous, lifted, moved, bumps.asMap, mimaRan) + yield report + end for + end writeModverReport + + private def previousShips(root: File, rel: String, sha: String): Either[String, List[PublishedRow]] = + val out = scala.collection.mutable.ListBuffer.empty[String] + val err = scala.collection.mutable.ListBuffer.empty[String] + val code = + scala.sys.process + .Process(Seq("git", "show", s"$sha:$rel"), root) + .!(scala.sys.process.ProcessLogger(out += _, err += _)) + if code == 0 then zipx.syntax.CatalogSource.parse(out.mkString("\n"), rel).map(_.ships) + else Right(Nil) + + private def versionSchemeOf(extracted: Extracted, id: ModuleId): String = + val ref = extracted.structure.allProjectRefs.find(_.project == (id: String)) + ref + .flatMap(r => extracted.getOpt(r / versionScheme)) + .flatten + .orElse(extracted.getOpt(ThisBuild / versionScheme).flatten) + .getOrElse("early-semver") + + private def probeMember( + extracted: Extracted, + graph: ModuleGraph, + previous: ShipIndex, + id: ModuleId, + st: State, + ): MemberProbe = + if previous.rowFor(id).isEmpty then MemberProbe.FirstPublish + else if Modver.isJsOnly(graph, id) then MemberProbe.JsOnly + else + val ref = extracted.structure.allProjectRefs.find(_.project == (id: String)) + ref match + case None => MemberProbe.Clean + case Some(r) => + extracted.runTask(r / Compile / compile, st) + val classes = classDirectoryOf(extracted, r) + val prevVer = previous.rowFor(id).map(row => row.version: String).getOrElse("") + previousArtifactJar(extracted, r, prevVer) match + case None => MemberProbe.FirstPublish + case Some(old) => + val lib = new com.typesafe.tools.mima.lib.MiMaLib(Nil) + if lib.collectProblems(old, classes, Nil).isEmpty then MemberProbe.Clean + else MemberProbe.BinaryBreak + end match + + private def classDirectoryOf(extracted: Extracted, ref: ProjectRef): File = + val raw = extracted.get(ref / Compile / classDirectory) + try extracted.get(fileConverter).toPath(raw.asInstanceOf[xsbti.VirtualFileRef]).toFile + catch case _: ClassCastException => raw.asInstanceOf[File] + + private def previousArtifactJar(extracted: Extracted, ref: ProjectRef, version: String): Option[File] = + if version.isEmpty then None + else + val org = extracted.get(ref / organization) + val name = extracted.get(ref / moduleName) + val scalaBin = extracted.getOpt(ref / scalaBinaryVersion).getOrElse("") + val artifact = + extracted.getOpt(ref / crossVersion) match + case Some(_: sbt.librarymanagement.Disabled) => name + case _ if scalaBin.nonEmpty => s"${name}_$scalaBin" + case _ => name + val groupPath = org.replace('.', '/') + val url = s"https://repo1.maven.org/maven2/$groupPath/$artifact/$version/$artifact-$version.jar" + val dest = extracted.get(LocalRootProject / baseDirectory) / "target" / "zipx-mima" / s"$artifact-$version.jar" + dest.getParentFile.mkdirs() + downloadJar(url, dest) + + private def downloadJar(url: String, dest: File): Option[File] = + try + val client = java.net.http.HttpClient.newBuilder().connectTimeout(java.time.Duration.ofSeconds(10)).build() + val request = java.net.http.HttpRequest.newBuilder(java.net.URI.create(url)).GET().build() + val res = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofFile(dest.toPath)) + if res.statusCode() == 200 then Some(dest) + else + dest.delete() + None + catch case scala.util.control.NonFatal(_) => None + + private def postModverComment(body: String, log: Logger): Unit = + val repo = sys.env.getOrElse("GITHUB_REPOSITORY", "") + val pr = pullRequestNumber() + if repo.isEmpty || !repo.contains("/") || pr.isEmpty then log.info("zipx: not a GitHub PR; skipping sticky comment") + else + try + val n = pr.get + val list = osProcess(Seq("gh", "api", s"repos/$repo/issues/$n/comments"), None) + val existing = + list.toOption.flatMap { json => + json.fromJson[List[GhComment]].toOption.flatMap(_.find(_.body.contains(ModverComment.Marker))) + } + val payload = CommentBody(body).toJson + existing match + case Some(c) => + osProcess( + Seq("gh", "api", "--method", "PATCH", s"repos/$repo/issues/comments/${c.id}", "--input", "-"), + Some(payload), + ) + log.info(s"zipx: updated sticky comment ${c.id}") + case None => + osProcess( + Seq("gh", "api", "--method", "POST", s"repos/$repo/issues/$n/comments", "--input", "-"), + Some(payload), + ) + log.info("zipx: posted sticky comment") + end match + catch + case scala.util.control.NonFatal(e) => + log.warn(s"zipx: sticky comment failed (${e.getMessage}); continuing") + end if + end postModverComment + + private final case class GhComment(id: Long, body: String) derives JsonDecoder + private final case class GhEvent(number: Option[Int], pull_request: Option[GhPr]) derives JsonDecoder + private final case class GhPr(number: Int) derives JsonDecoder + private final case class CommentBody(body: String) derives JsonEncoder + + private def pullRequestNumber(): Option[Int] = + sys.env.get("GITHUB_EVENT_PATH").flatMap { path => + val file = new File(path) + if !file.exists then None + else IO.read(file).fromJson[GhEvent].toOption.flatMap(e => e.pull_request.map(_.number).orElse(e.number)) + } + + private def osProcess(cmd: Seq[String], stdin: Option[String]): Either[String, String] = + val out = new StringBuilder + val err = new StringBuilder + val proc = scala.sys.process.Process(cmd) + val piped = stdin match + case None => proc + case Some(body) => + proc #< new java.io.ByteArrayInputStream(body.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + val code = piped.!(scala.sys.process.ProcessLogger(out.append, err.append)) + if code == 0 then Right(out.toString) else Left(err.toString) + end osProcess end ZipxPlugin diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 3e85818..127c59a 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -10,6 +10,7 @@ object Dependencies: val scala3Version = "3.8.4" val zioVersion = "2.1.26" + val zioJsonVersion = "0.10.0" val zioBlocksVersion = "0.0.51" val remoteCacheVersion = "2.0.5" val neotypeVersion = "0.7.0" @@ -26,6 +27,9 @@ object Dependencies: "dev.zio" %% "zio-test-sbt" % zioVersion % Test, ) + val zioJson: ModuleID = "dev.zio" %% "zio-json" % zioJsonVersion + val mimaCore: ModuleID = "com.typesafe" %% "mima-core" % "1.1.5" + val workflowLibraryDeps: Seq[ModuleID] = Seq( "dev.zio" %% "zio-blocks-schema" % zioBlocksVersion, "dev.zio" %% "zio-blocks-schema-yaml" % zioBlocksVersion, diff --git a/project/ZipxVersions.scala b/project/ZipxVersions.scala index 23c8b7a..61b9848 100644 --- a/project/ZipxVersions.scala +++ b/project/ZipxVersions.scala @@ -14,6 +14,8 @@ object ZipxVersions extends zipx.ZipxVersions: val zio: Lib = Lib("dev.zio", "zio", "2.1.26") val zioTest: Lib = zio.mod("zio-test").test val zioTestSbt: Lib = zio.mod("zio-test-sbt").test + val zioJson: Lib = Lib("dev.zio", "zio-json", "0.10.0") + val mimaCore: Lib = Lib("com.typesafe", "mima-core", "1.1.5") val zioBlocks: Lib = Lib("dev.zio", "zio-blocks-schema", "0.0.51") val zioBlocksYaml: Lib = zioBlocks.mod("zio-blocks-schema-yaml") val neotype: Lib = Lib("io.github.kitlangton", "neotype", "0.7.0") diff --git a/project/dogfood.sbt b/project/dogfood.sbt index 9879e88..713bb04 100644 --- a/project/dogfood.sbt +++ b/project/dogfood.sbt @@ -35,7 +35,7 @@ lazy val metaCore = project name := "meta-zipx-core", publish / skip := true, scalacOptions ++= Dependencies.commonScalacOptions, - libraryDependencies ++= Dependencies.zioDeps, + libraryDependencies ++= Dependencies.zioDeps :+ Dependencies.zioJson, ) .settings(Dogfood.mirrorMainScala("core")) @@ -91,6 +91,7 @@ lazy val metaPlugin = project name := "meta-sbt-zipx", publish / skip := true, scalacOptions ++= Dependencies.commonScalacOptions, + libraryDependencies += Dependencies.mimaCore, addSbtPlugin(Dependencies.remoteCachePlugin), ) .settings(Dogfood.mirrorMainScala("sbt-plugin"))