diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala
index 6e1a764b1..a22caa290 100644
--- a/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala
+++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/PublisherStatus.scala
@@ -9,22 +9,23 @@ final class PublisherStatus private (
val deploymentId: String,
val deploymentName: String,
val deploymentState: sbt.internal.sona.DeploymentState,
- val purls: Vector[String]) extends Serializable {
+ val purls: Vector[String],
+ val errors: Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue]) extends Serializable {
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
- case x: PublisherStatus => (this.deploymentId == x.deploymentId) && (this.deploymentName == x.deploymentName) && (this.deploymentState == x.deploymentState) && (this.purls == x.purls)
+ case x: PublisherStatus => (this.deploymentId == x.deploymentId) && (this.deploymentName == x.deploymentName) && (this.deploymentState == x.deploymentState) && (this.purls == x.purls) && (this.errors == x.errors)
case _ => false
})
override def hashCode: Int = {
- 37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.sona.PublisherStatus".##) + deploymentId.##) + deploymentName.##) + deploymentState.##) + purls.##)
+ 37 * (37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.sona.PublisherStatus".##) + deploymentId.##) + deploymentName.##) + deploymentState.##) + purls.##) + errors.##)
}
override def toString: String = {
- "PublisherStatus(" + deploymentId + ", " + deploymentName + ", " + deploymentState + ", " + purls + ")"
+ "PublisherStatus(" + deploymentId + ", " + deploymentName + ", " + deploymentState + ", " + purls + ", " + errors + ")"
}
- private[this] def copy(deploymentId: String = deploymentId, deploymentName: String = deploymentName, deploymentState: sbt.internal.sona.DeploymentState = deploymentState, purls: Vector[String] = purls): PublisherStatus = {
- new PublisherStatus(deploymentId, deploymentName, deploymentState, purls)
+ private[this] def copy(deploymentId: String = deploymentId, deploymentName: String = deploymentName, deploymentState: sbt.internal.sona.DeploymentState = deploymentState, purls: Vector[String] = purls, errors: Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue] = errors): PublisherStatus = {
+ new PublisherStatus(deploymentId, deploymentName, deploymentState, purls, errors)
}
def withDeploymentId(deploymentId: String): PublisherStatus = {
copy(deploymentId = deploymentId)
@@ -38,8 +39,15 @@ final class PublisherStatus private (
def withPurls(purls: Vector[String]): PublisherStatus = {
copy(purls = purls)
}
+ def withErrors(errors: Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue]): PublisherStatus = {
+ copy(errors = errors)
+ }
+ def withErrors(errors: sjsonnew.shaded.scalajson.ast.unsafe.JValue): PublisherStatus = {
+ copy(errors = Option(errors))
+ }
}
object PublisherStatus {
- def apply(deploymentId: String, deploymentName: String, deploymentState: sbt.internal.sona.DeploymentState, purls: Vector[String]): PublisherStatus = new PublisherStatus(deploymentId, deploymentName, deploymentState, purls)
+ def apply(deploymentId: String, deploymentName: String, deploymentState: sbt.internal.sona.DeploymentState, purls: Vector[String], errors: Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue]): PublisherStatus = new PublisherStatus(deploymentId, deploymentName, deploymentState, purls, errors)
+ def apply(deploymentId: String, deploymentName: String, deploymentState: sbt.internal.sona.DeploymentState, purls: Vector[String], errors: sjsonnew.shaded.scalajson.ast.unsafe.JValue): PublisherStatus = new PublisherStatus(deploymentId, deploymentName, deploymentState, purls, Option(errors))
}
diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala
index 01f3409a1..64672519f 100644
--- a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala
+++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/JsonProtocol.scala
@@ -6,5 +6,6 @@
package sbt.internal.sona.codec
trait JsonProtocol extends sjsonnew.BasicJsonProtocol
with sbt.internal.sona.codec.DeploymentStateFormats
+ with sbt.internal.util.codec.JValueFormats
with sbt.internal.sona.codec.PublisherStatusFormats
object JsonProtocol extends JsonProtocol
\ No newline at end of file
diff --git a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala
index 523526990..ee86bfefb 100644
--- a/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala
+++ b/main-actions/src/main/contraband-scala/sbt/internal/sona/codec/PublisherStatusFormats.scala
@@ -5,7 +5,7 @@
// DO NOT EDIT MANUALLY
package sbt.internal.sona.codec
import _root_.sjsonnew.{ Unbuilder, Builder, JsonFormat, deserializationError }
-trait PublisherStatusFormats { self: sbt.internal.sona.codec.DeploymentStateFormats with sjsonnew.BasicJsonProtocol =>
+trait PublisherStatusFormats { self: sbt.internal.sona.codec.DeploymentStateFormats with sjsonnew.BasicJsonProtocol with sbt.internal.util.codec.JValueFormats =>
implicit lazy val PublisherStatusFormat: JsonFormat[sbt.internal.sona.PublisherStatus] = new JsonFormat[sbt.internal.sona.PublisherStatus] {
override def read[J](__jsOpt: Option[J], unbuilder: Unbuilder[J]): sbt.internal.sona.PublisherStatus = {
__jsOpt match {
@@ -15,8 +15,9 @@ implicit lazy val PublisherStatusFormat: JsonFormat[sbt.internal.sona.PublisherS
val deploymentName = unbuilder.readField[String]("deploymentName")
val deploymentState = unbuilder.readField[sbt.internal.sona.DeploymentState]("deploymentState")
val purls = unbuilder.readField[Vector[String]]("purls")
+ val errors = unbuilder.readField[Option[sjsonnew.shaded.scalajson.ast.unsafe.JValue]]("errors")
unbuilder.endObject()
- sbt.internal.sona.PublisherStatus(deploymentId, deploymentName, deploymentState, purls)
+ sbt.internal.sona.PublisherStatus(deploymentId, deploymentName, deploymentState, purls, errors)
case None =>
deserializationError("Expected JsObject but found None")
}
@@ -27,6 +28,7 @@ implicit lazy val PublisherStatusFormat: JsonFormat[sbt.internal.sona.PublisherS
builder.addField("deploymentName", obj.deploymentName)
builder.addField("deploymentState", obj.deploymentState)
builder.addField("purls", obj.purls)
+ builder.addField("errors", obj.errors)
builder.endObject()
}
}
diff --git a/main-actions/src/main/contraband/sona.contra b/main-actions/src/main/contraband/sona.contra
index 1e4bdd90c..03c7a3af6 100644
--- a/main-actions/src/main/contraband/sona.contra
+++ b/main-actions/src/main/contraband/sona.contra
@@ -18,4 +18,6 @@ type PublisherStatus {
deploymentName: String!
deploymentState: sbt.internal.sona.DeploymentState!
purls: [String]
-}
+ # Optional errors. The field has non-standard structure and thus we avoid automatic format generation
+ errors: sjsonnew.shaded.scalajson.ast.unsafe.JValue
+}
\ No newline at end of file
diff --git a/main-actions/src/main/scala/sbt/internal/sona/PackageDeploymentValidationError.scala b/main-actions/src/main/scala/sbt/internal/sona/PackageDeploymentValidationError.scala
new file mode 100644
index 000000000..065ba88bf
--- /dev/null
+++ b/main-actions/src/main/scala/sbt/internal/sona/PackageDeploymentValidationError.scala
@@ -0,0 +1,74 @@
+/*
+ * sbt
+ * Copyright 2023, Scala center
+ * Copyright 2011 - 2022, Lightbend, Inc.
+ * Copyright 2008 - 2010, Mark Harrah
+ * Licensed under Apache License 2.0 (see LICENSE)
+ */
+
+package sbt.internal.sona
+
+import sjsonnew.shaded.scalajson.ast.unsafe.*
+
+/**
+ * Represents validation errors for one of the deployed packages in case deployment to sonatype has failed
+ *
+ * @param packageDescriptor package descriptor
+ * (e.g. "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6")
+ * @param packageErrors list of validation errors for the package
+ * (e.g. ""Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists")
+ * @see https://central.sonatype.org/publish/publish-portal-api/#verify-status-of-the-deployment
+ */
+private case class PackageDeploymentValidationError(
+ packageDescriptor: String,
+ packageErrors: Seq[String]
+)
+
+private object PackageDeploymentValidationError {
+
+ /**
+ * Example: (it's not an array but an object which makes it hard to parse with the standard contraband means)
+ * {{{
+ * {
+ * ,
+ * "errors": {
+ * "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6": [
+ * "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists"
+ * ],
+ * "pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6": [
+ * "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' already exists"
+ * ]
+ * }
+ * }
+ * }}}
+ *
+ * @param errorsNode - the JSON node that contains the errors
+ * @return Some(errors) - if the JSON structure matches our expectations
+ * None - otherwise (Sonatype Central could change the format of the output)
+ */
+ def parse(errorsNode: JValue): Option[Seq[PackageDeploymentValidationError]] =
+ errorsNode match {
+ case JObject(fields) =>
+ val errors = fields.toSeq.flatMap {
+ case JField(packageInfo, JArray(packageErrors)) =>
+ val packageErrorsTexts = packageErrors.flatMap {
+ case JString(value) => Some(value)
+ case other => None
+ }
+ val noParsingIssues = packageErrors.length == packageErrorsTexts.length
+ if (noParsingIssues)
+ Some(PackageDeploymentValidationError(packageInfo, packageErrorsTexts))
+ else
+ None
+ case _ =>
+ None
+ }
+ val noParsingIssues = errors.size == fields.length
+ if (noParsingIssues)
+ Some(errors)
+ else
+ None
+ case _ =>
+ None
+ }
+}
diff --git a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala
index 2536176c1..a0bec01bf 100644
--- a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala
+++ b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala
@@ -12,10 +12,11 @@ package sona
import gigahorse.*
import gigahorse.support.apachehttp.Gigahorse
+import sbt.internal.sona.SonaClient.failedDeploymentErrorText
import sbt.util.Logger
import sjsonnew.JsonFormat
import sjsonnew.shaded.scalajson.ast.unsafe.JValue
-import sjsonnew.support.scalajson.unsafe.{ Converter, Parser }
+import sjsonnew.support.scalajson.unsafe.{ Converter, Parser, PrettyPrinter }
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@@ -107,7 +108,9 @@ class SonaClient(reqTransform: Request => Request, uploadRequestTimeout: FiniteD
if (attempt <= 3) List(5, 5, 10, 15)(attempt)
else 30
status.deploymentState match {
- case DeploymentState.FAILED => sys.error(s"deployment $deploymentId failed")
+ case DeploymentState.FAILED =>
+ val errorText = failedDeploymentErrorText(deploymentId, status.errors, log)
+ sys.error(errorText)
case DeploymentState.PENDING | DeploymentState.PUBLISHING | DeploymentState.VALIDATING =>
Thread.sleep(sleepSec * 1000L)
waitForDeploy(deploymentId, deploymentName, publishingType, attempt + 1, log)
@@ -204,6 +207,54 @@ object SonaClient {
uploadRequestTimeout: FiniteDuration
): SonaClient =
new SonaClient(OAuthClient(userName, userToken), uploadRequestTimeout)
+
+ /**
+ * @note non-private visibility only for the tests
+ */
+ private[sona] def failedDeploymentErrorText(
+ deploymentId: String,
+ errors: Option[JValue],
+ log: Logger
+ ): String = {
+ val errorsText = errors.map(presentDeploymentValidationErrors(_, log))
+ val errorsMessagePart = errorsText match {
+ case Some(value) =>
+ s" with validation errors:\n$value"
+ case None => ""
+ }
+ s"deployment $deploymentId failed$errorsMessagePart"
+ }
+
+ import sbt.internal.sona.SonaClient.PrettyPrint.*
+
+ private def presentDeploymentValidationErrors(errorsNode: JValue, log: Logger): String = {
+ PackageDeploymentValidationError.parse(errorsNode) match {
+ case Some(errors) =>
+ val errorsPresented: Seq[String] = errors.map {
+ case PackageDeploymentValidationError(packageDescriptor, packageErrors) =>
+ s"""$packageDescriptor
+ |${indent(asList(packageErrors), 2)}""".stripMargin
+ }
+ indent(asList(errorsPresented), 2)
+ case None =>
+ // Sonatype might change the format of the errors in the future.
+ // We shouldn't fail, and as a fallback we pretty print the JSON representation
+ log.warn(
+ "Sonatype deployment validation errors JSON format has changed. Please update to the latest sbt version or report the issue to the sbt project"
+ )
+ PrettyPrinter(errorsNode)
+ }
+ }
+
+ private object PrettyPrint {
+ def asList(lines: Seq[String]): String =
+ lines.map("- " + _).mkString("\n")
+
+ def indent(text: String, indentSize: Int): String = {
+ val indent = " " * indentSize
+ text.linesIterator.map(indent + _).mkString("\n")
+ }
+ }
}
private case class OAuthClient(userName: String, userToken: String)
diff --git a/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala b/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala
new file mode 100644
index 000000000..f45339a06
--- /dev/null
+++ b/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala
@@ -0,0 +1,125 @@
+/*
+ * sbt
+ * Copyright 2023, Scala center
+ * Copyright 2011 - 2022, Lightbend, Inc.
+ * Copyright 2008 - 2010, Mark Harrah
+ * Licensed under Apache License 2.0 (see LICENSE)
+ */
+
+package sbt.internal.sona
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper
+import sbt.internal.sona.SonaClientTest.RecordingLogger
+import sbt.internal.util.BasicLogger
+import sbt.util.*
+import sjsonnew.support.scalajson.unsafe.Parser
+
+import scala.collection.immutable
+
+class SonaClientTest extends AnyFlatSpec {
+
+ private def doTest(
+ errorsJsonText: Option[String],
+ expectedErrorMessage: String,
+ expectedLogText: String = ""
+ ): Unit = {
+ val logger = new RecordingLogger()
+ val errorsNode = errorsJsonText.map(Parser.parseUnsafe)
+ val result = SonaClient.failedDeploymentErrorText(
+ deploymentId = "12345",
+ errors = errorsNode,
+ log = logger
+ )
+ result shouldBe expectedErrorMessage
+
+ val actualLogText = logger.getLogMessages.mkString("\n")
+ actualLogText shouldBe expectedLogText
+
+ () //to avoid the "discarded non-Unit" value warning
+ }
+
+ it should "construct a failed deployment error message without errors" in doTest(
+ None,
+ """deployment 12345 failed""".stripMargin
+ )
+
+ it should "construct a failed deployment error message with validation errors" in doTest(
+ Some(
+ """{
+ | "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6": [
+ | "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists"
+ | ],
+ | "pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6": [
+ | "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 1",
+ | "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 2"
+ | ]
+ |}""".stripMargin
+ ),
+ """deployment 12345 failed with validation errors:
+ | - pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6
+ | - Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists
+ | - pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6
+ | - Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 1
+ | - Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 2""".stripMargin
+ )
+
+ it should "construct a failed deployment error message with validation errors in an unknown format" in doTest(
+ Some(
+ """[
+ | {
+ | "package" : "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6",
+ | "errors" : [
+ | "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists"
+ | ]
+ | },
+ | {
+ | "package" : "pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6",
+ | "errors" : [
+ | "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 1",
+ | "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 2"
+ | ]
+ | }
+ |]""".stripMargin
+ ),
+ """deployment 12345 failed with validation errors:
+ |[{
+ | "package": "pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6",
+ | "errors": ["Component with package url: 'pkg:maven/org.example.company/sbt-plugin-core_2.12_1.0@0.0.6' already exists"]
+ |}, {
+ | "package": "pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6",
+ | "errors": ["Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 1", "Component with package url: 'pkg:maven/org.example.company/sbt-plugin-extra_2.12_1.0@0.0.6' some reason 2"]
+ |}]""".stripMargin,
+ expectedLogText =
+ "[warn] Sonatype deployment validation errors JSON format has changed. Please update to the latest sbt version or report the issue to the sbt project"
+ )
+}
+
+object SonaClientTest {
+
+ implicit class RecordingLoggerOps(private val value: RecordingLogger) extends AnyVal {
+ def getLogMessages: immutable.Seq[String] =
+ value.getEvents.collect { case l: Log => s"[${l.level}] ${l.msg}" }
+ }
+
+ /**
+ * Records logging events for later retrieval.
+ *
+ * @note This is a copy of a logger from the "util-logging" module tests.
+ * Instead of copying we could depend on the module test directly or extract it into some test-utilities module.
+ */
+ final class RecordingLogger extends BasicLogger {
+ private var events: List[LogEvent] = Nil
+
+ def getEvents = events.reverse
+
+ override def ansiCodesSupported = true
+ def trace(t: => Throwable): Unit = { events ::= new Trace(t) }
+ def log(level: Level.Value, message: => String): Unit = { events ::= new Log(level, message) }
+ def success(message: => String): Unit = { events ::= new Success(message) }
+ def logAll(es: Seq[LogEvent]): Unit = { events :::= es.toList }
+
+ def control(event: ControlEvent.Value, message: => String): Unit =
+ events ::= new ControlEvent(event, message)
+ }
+}