Repository: scala-steward-org/scala-steward (scala)

Issue:
Include structured metadata in created PRs
I'm looking at building some tooling surrounding the PRs generated by Scala Steward. (For example, automerging PRs based on certain conditions.) For this purpose, it would be helpful to have some structured metadata included in the generated PRs. For example, the identifier and name of the package being updated, the new and old version, and the labels that are already available in a comment.

This data can be parsed out of the current PR text, but it would be nice to have something a bit more stable. [Probot: metadata](https://github.com/probot/metadata) has what seems like a reasonable approach of including JSON data in HTML comments such as the below:

```
<!-- probot = {"json": "here"} -->
```

Something like this would be much less brittle than parsing the current PR text. As a more concrete example, for [this PR](https://github.com/dataunitylab/jsonoid-discovery/pull/47), the metadata might look like this:

```
<!-- scala-steward = {"dependencies": [{"groupId": "com.google.protobuf", "artifactId": "protobuf-java", "oldVersion": "3.25.4", "newVersion": "4.28.1", "labels": ["library-update", "early-semver-major", "semver-spec-major", "commit-count:1"]} -->
```

Pull request description:
This solves #3465. This ended up being quite straightforward as I didn't realize there was already a well-defined JSON serialization for updates. I think the only downside of this is a slightly larger payload that gets sent to GitHub each time a PR is created, but it does enable some interesting use cases of taking automated actions on the resulting PRs.

Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/forge/data/NewPullRequestData.scala b/modules/core/src/main/scala/org/scalasteward/core/forge/data/NewPullRequestData.scala
index b5b186a55b..4e659ac824 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/forge/data/NewPullRequestData.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/forge/data/NewPullRequestData.scala
@@ -17,6 +17,8 @@
 package org.scalasteward.core.forge.data
 
 import cats.syntax.all._
+import io.circe.Json
+import io.circe.syntax._
 import org.http4s.Uri
 import org.scalasteward.core.data._
 import org.scalasteward.core.edit.EditAttempt
@@ -112,9 +114,18 @@ object NewPullRequestData {
         |<sup>
         |${labels.mkString("labels: ", ", ", "")}
         |</sup>
-        |""".stripMargin.trim
+        |
+        |<!-- scala-steward = ${metadataJson(update, labels)} -->""".stripMargin.trim
   }
 
+  def metadataJson(update: Update, labels: List[String]): String =
+    Json
+      .obj(
+        "Update" -> update.asJson,
+        "Labels" -> Json.fromValues(labels.map(_.asJson))
+      )
+      .toString
+
   def renderUpdateInfoUrls(updateInfoUrls: List[UpdateInfoUrl]): Option[String] =
     Option.when(updateInfoUrls.nonEmpty) {
       updateInfoUrls


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/forge/data/NewPullRequestDataTest.scala b/modules/core/src/test/scala/org/scalasteward/core/forge/data/NewPullRequestDataTest.scala
index ca72e2cb29..3ae0a0f50f 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/forge/data/NewPullRequestDataTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/forge/data/NewPullRequestDataTest.scala
@@ -60,7 +60,35 @@ class NewPullRequestDataTest extends FunSuite {
           |
           |<sup>
           |labels: library-update
-          |</sup>""".stripMargin
+          |</sup>
+          |
+          |<!-- scala-steward = {
+          |  "Update" : {
+          |    "ForArtifactId" : {
+          |      "crossDependency" : [
+          |        {
+          |          "groupId" : "ch.qos.logback",
+          |          "artifactId" : {
+          |            "name" : "logback-classic",
+          |            "maybeCrossName" : null
+          |          },
+          |          "version" : "1.2.0",
+          |          "sbtVersion" : null,
+          |          "scalaVersion" : null,
+          |          "configurations" : null
+          |        }
+          |      ],
+          |      "newerVersions" : [
+          |        "1.2.3"
+          |      ],
+          |      "newerGroupId" : null,
+          |      "newerArtifactId" : null
+          |    }
+          |  },
+          |  "Labels" : [
+          |    "library-update"
+          |  ]
+          |} -->""".stripMargin
 
     assertEquals(body, expected)
   }
@@ -127,7 +155,35 @@ class NewPullRequestDataTest extends FunSuite {
           |
           |<sup>
           |labels: library-update
-          |</sup>""".stripMargin
+          |</sup>
+          |
+          |<!-- scala-steward = {
+          |  "Update" : {
+          |    "ForArtifactId" : {
+          |      "crossDependency" : [
+          |        {
+          |          "groupId" : "ch.qos.logback",
+          |          "artifactId" : {
+          |            "name" : "logback-classic",
+          |            "maybeCrossName" : null
+          |          },
+          |          "version" : "1.2.0",
+          |          "sbtVersion" : null,
+          |          "scalaVersion" : null,
+          |          "configurations" : null
+          |        }
+          |      ],
+          |      "newerVersions" : [
+          |        "1.2.3"
+          |      ],
+          |      "newerGroupId" : null,
+          |      "newerArtifactId" : null
+          |    }
+          |  },
+          |  "Labels" : [
+          |    "library-update"
+          |  ]
+          |} -->""".stripMargin
 
     assertEquals(body, expected)
   }
@@ -191,7 +247,65 @@ class NewPullRequestDataTest extends FunSuite {
           |
           |<sup>
           |labels: library-update
-          |</sup>""".stripMargin
+          |</sup>
+          |
+          |<!-- scala-steward = {
+          |  "Update" : {
+          |    "Grouped" : {
+          |      "name" : "my-group",
+          |      "title" : "The PR title",
+          |      "updates" : [
+          |        {
+          |          "ForArtifactId" : {
+          |            "crossDependency" : [
+          |              {
+          |                "groupId" : "ch.qos.logback",
+          |                "artifactId" : {
+          |                  "name" : "logback-classic",
+          |                  "maybeCrossName" : null
+          |                },
+          |                "version" : "1.2.0",
+          |                "sbtVersion" : null,
+          |                "scalaVersion" : null,
+          |                "configurations" : null
+          |              }
+          |            ],
+          |            "newerVersions" : [
+          |              "1.2.3"
+          |            ],
+          |            "newerGroupId" : null,
+          |            "newerArtifactId" : null
+          |          }
+          |        },
+          |        {
+          |          "ForArtifactId" : {
+          |            "crossDependency" : [
+          |              {
+          |                "groupId" : "com.example",
+          |                "artifactId" : {
+          |                  "name" : "foo",
+          |                  "maybeCrossName" : null
+          |                },
+          |                "version" : "1.0.0",
+          |                "sbtVersion" : null,
+          |                "scalaVersion" : null,
+          |                "configurations" : null
+          |              }
+          |            ],
+          |            "newerVersions" : [
+          |              "2.0.0"
+          |            ],
+          |            "newerGroupId" : null,
+          |            "newerArtifactId" : null
+          |          }
+          |        }
+          |      ]
+          |    }
+          |  },
+          |  "Labels" : [
+          |    "library-update"
+          |  ]
+          |} -->""".stripMargin
 
     assertEquals(body, expected)
   }
@@ -248,7 +362,35 @@ class NewPullRequestDataTest extends FunSuite {
           |
           |<sup>
           |labels: library-update
-          |</sup>""".stripMargin
+          |</sup>
+          |
+          |<!-- scala-steward = {
+          |  "Update" : {
+          |    "ForArtifactId" : {
+          |      "crossDependency" : [
+          |        {
+          |          "groupId" : "ch.qos.logback",
+          |          "artifactId" : {
+          |            "name" : "logback-classic",
+          |            "maybeCrossName" : null
+          |          },
+          |          "version" : "1.2.0",
+          |          "sbtVersion" : null,
+          |          "scalaVersion" : null,
+          |          "configurations" : null
+          |        }
+          |      ],
+          |      "newerVersions" : [
+          |        "1.2.3"
+          |      ],
+          |      "newerGroupId" : null,
+          |      "newerArtifactId" : null
+          |    }
+          |  },
+          |  "Labels" : [
+          |    "library-update"
+          |  ]
+          |} -->""".stripMargin
 
     assertEquals(body, expected)
   }
@@ -642,7 +784,38 @@ class NewPullRequestDataTest extends FunSuite {
           |
           |<sup>
           |labels: library-update, early-semver-patch, semver-spec-patch, commit-count:0
-          |</sup>""".stripMargin
+          |</sup>
+          |
+          |<!-- scala-steward = {
+          |  "Update" : {
+          |    "ForArtifactId" : {
+          |      "crossDependency" : [
+          |        {
+          |          "groupId" : "ch.qos.logback",
+          |          "artifactId" : {
+          |            "name" : "logback-classic",
+          |            "maybeCrossName" : null
+          |          },
+          |          "version" : "1.2.0",
+          |          "sbtVersion" : null,
+          |          "scalaVersion" : null,
+          |          "configurations" : null
+          |        }
+          |      ],
+          |      "newerVersions" : [
+          |        "1.2.3"
+          |      ],
+          |      "newerGroupId" : null,
+          |      "newerArtifactId" : null
+          |    }
+          |  },
+          |  "Labels" : [
+          |    "library-update",
+          |    "early-semver-patch",
+          |    "semver-spec-patch",
+          |    "commit-count:0"
+          |  ]
+          |} -->""".stripMargin
 
     val expected = NewPullRequestData(
       title = "Update logback-classic to 1.2.3",
@@ -731,7 +904,70 @@ class NewPullRequestDataTest extends FunSuite {
           |
           |<sup>
           |labels: library-update, early-semver-patch, semver-spec-patch, early-semver-major, semver-spec-major, commit-count:0
-          |</sup>""".stripMargin
+          |</sup>
+          |
+          |<!-- scala-steward = {
+          |  "Update" : {
+          |    "Grouped" : {
+          |      "name" : "my-group",
+          |      "title" : null,
+          |      "updates" : [
+          |        {
+          |          "ForArtifactId" : {
+          |            "crossDependency" : [
+          |              {
+          |                "groupId" : "ch.qos.logback",
+          |                "artifactId" : {
+          |                  "name" : "logback-classic",
+          |                  "maybeCrossName" : null
+          |                },
+          |                "version" : "1.2.0",
+          |                "sbtVersion" : null,
+          |                "scalaVersion" : null,
+          |                "configurations" : null
+          |              }
+          |            ],
+          |            "newerVersions" : [
+          |              "1.2.3"
+          |            ],
+          |            "newerGroupId" : null,
+          |            "newerArtifactId" : null
+          |          }
+          |        },
+          |        {
+          |          "ForArtifactId" : {
+          |            "crossDependency" : [
+          |              {
+          |                "groupId" : "com.example",
+          |                "artifactId" : {
+          |                  "name" : "foo",
+          |                  "maybeCrossName" : null
+          |                },
+          |                "version" : "1.0.0",
+          |                "sbtVersion" : null,
+          |                "scalaVersion" : null,
+          |                "configurations" : null
+          |              }
+          |            ],
+          |            "newerVersions" : [
+          |              "2.0.0"
+          |            ],
+          |            "newerGroupId" : null,
+          |            "newerArtifactId" : null
+          |          }
+          |        }
+          |      ]
+          |    }
+          |  },
+          |  "Labels" : [
+          |    "library-update",
+          |    "early-semver-patch",
+          |    "semver-spec-patch",
+          |    "early-semver-major",
+          |    "semver-spec-major",
+          |    "commit-count:0"
+          |  ]
+          |} -->""".stripMargin
 
     val expected = NewPullRequestData(
       title = "Update for group my-group",
@@ -803,7 +1039,38 @@ class NewPullRequestDataTest extends FunSuite {
           |
           |<sup>
           |labels: library-update, early-semver-major, semver-spec-minor, commit-count:1
-          |</sup>""".stripMargin
+          |</sup>
+          |
+          |<!-- scala-steward = {
+          |  "Update" : {
+          |    "ForArtifactId" : {
+          |      "crossDependency" : [
+          |        {
+          |          "groupId" : "org.typelevel",
+          |          "artifactId" : {
+          |            "name" : "cats-effect",
+          |            "maybeCrossName" : null
+          |          },
+          |          "version" : "2.5.5",
+          |          "sbtVersion" : null,
+          |          "scalaVersion" : null,
+          |          "configurations" : null
+          |        }
+          |      ],
+          |      "newerVersions" : [
+          |        "3.4.2"
+          |      ],
+          |      "newerGroupId" : null,
+          |      "newerArtifactId" : null
+          |    }
+          |  },
+          |  "Labels" : [
+          |    "library-update",
+          |    "early-semver-major",
+          |    "semver-spec-minor",
+          |    "commit-count:1"
+          |  ]
+          |} -->""".stripMargin
 
     assertEquals(body, expected)
   }
@@ -857,7 +1124,38 @@ class NewPullRequestDataTest extends FunSuite {
           |
           |<sup>
           |labels: library-update, early-semver-major, semver-spec-minor, commit-count:1
-          |</sup>""".stripMargin
+          |</sup>
+          |
+          |<!-- scala-steward = {
+          |  "Update" : {
+          |    "ForArtifactId" : {
+          |      "crossDependency" : [
+          |        {
+          |          "groupId" : "com.lihaoyi",
+          |          "artifactId" : {
+          |            "name" : "os-lib",
+          |            "maybeCrossName" : null
+          |          },
+          |          "version" : "0.7.8",
+          |          "sbtVersion" : null,
+          |          "scalaVersion" : null,
+          |          "configurations" : null
+          |        }
+          |      ],
+          |      "newerVersions" : [
+          |        "0.9.1"
+          |      ],
+          |      "newerGroupId" : null,
+          |      "newerArtifactId" : null
+          |    }
+          |  },
+          |  "Labels" : [
+          |    "library-update",
+          |    "early-semver-major",
+          |    "semver-spec-minor",
+          |    "commit-count:1"
+          |  ]
+          |} -->""".stripMargin
 
     assertEquals(body, expected)
   }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/nurture/NurtureAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/nurture/NurtureAlgTest.scala
index c053214332..f03182c741 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/nurture/NurtureAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/nurture/NurtureAlgTest.scala
@@ -73,7 +73,39 @@ class NurtureAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] {
              |
              |<sup>
              |labels: library-update, early-semver-minor, semver-spec-minor, version-scheme:early-semver, commit-count:1
-             |</sup>""".stripMargin.trim,
+             |</sup>
+             |
+             |<!-- scala-steward = {
+             |  "Update" : {
+             |    "ForArtifactId" : {
+             |      "crossDependency" : [
+             |        {
+             |          "groupId" : "org.typelevel",
+             |          "artifactId" : {
+             |            "name" : "cats-effect",
+             |            "maybeCrossName" : "cats-effect_2.13"
+             |          },
+             |          "version" : "3.3.0",
+             |          "sbtVersion" : null,
+             |          "scalaVersion" : null,
+             |          "configurations" : null
+             |        }
+             |      ],
+             |      "newerVersions" : [
+             |        "3.4.0"
+             |      ],
+             |      "newerGroupId" : null,
+             |      "newerArtifactId" : null
+             |    }
+             |  },
+             |  "Labels" : [
+             |    "library-update",
+             |    "early-semver-minor",
+             |    "semver-spec-minor",
+             |    "version-scheme:early-semver",
+             |    "commit-count:1"
+             |  ]
+             |} -->""".stripMargin.trim,
       head = "scala-steward:update/cats-effect-3.4.0",
       base = baseBranch,
       labels = List(
@@ -90,7 +122,8 @@ class NurtureAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] {
   }
 
   test("preparePullRequest should not set labels if ForgeConfig.addLabels = false") {
-    def nextToLast(L: Array[String]) = L(L.size - 2)
+    def findLabelLine(L: Array[String]) =
+      L.zipWithIndex.find(_._1.contains("labels: ")).map(x => L(x._2)).getOrElse("")
 
     val config =
       MockConfig.config.copy(forgeCfg = MockConfig.config.forgeCfg.copy(addLabels = false))
@@ -116,9 +149,9 @@ class NurtureAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] {
     })
     nurtureAlg.preparePullRequest(updateData, edits).runA(state).map { obtained =>
       assert(obtained.labels.isEmpty)
-      val nextToLastLine = nextToLast(obtained.body.split("\n"))
+      val labelLine = findLabelLine(obtained.body.split("\n"))
       assertEquals(
-        nextToLastLine,
+        labelLine,
         "labels: library-update, early-semver-minor, semver-spec-minor, version-scheme:early-semver, commit-count:1"
       )
     }
@@ -127,7 +160,8 @@ class NurtureAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] {
   test(
     "preparePullRequest should set custom labels if PullRequestsConfig.customLabels is provided"
   ) {
-    def nextToLast(L: Array[String]) = L(L.size - 2)
+    def findLabelLine(L: Array[String]) =
+      L.zipWithIndex.find(_._1.contains("labels: ")).map(x => L(x._2)).getOrElse("")
 
     val nurtureAlg = context.nurtureAlg
     val dependency = "org.typelevel".g % ("cats-effect", "cats-effect_2.13").a % "3.3.0"
@@ -165,10 +199,10 @@ class NurtureAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] {
         )
       )
 
-      val nextToLastLine = nextToLast(obtained.body.split("\n"))
+      val labelLine = findLabelLine(obtained.body.split("\n"))
 
       assertEquals(
-        nextToLastLine,
+        labelLine,
         "labels: custom-label-1, custom-label-2, library-update, early-semver-minor, semver-spec-minor, commit-count:1"
       )
     }