Skip to content

Commit 9d2c66c

Browse files
committed
Changelog generation & validation
1 parent dd9c051 commit 9d2c66c

File tree

5 files changed

+200
-167
lines changed

5 files changed

+200
-167
lines changed

scripts/Config.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import Dependencies.Version
2+
import Dependencies.VersionString
23
object Config:
34
val organization = "org.scala-lang"
45
val name = "toolkit"
56
val crossVersions = List("3", "2.13")
6-
val releaseVersion = "0.1.7"
7-
val developmentVersion = "0.1.8"
7+
private val releaseVersionString = "0.2.0"
8+
private val developmentVersionString = "0.3.0"
9+
10+
val releaseVersion = Version.parse(releaseVersionString).getOrElse(throw IllegalArgumentException("Invalid release version"))
11+
val developmentVersion = Version.parse(developmentVersionString).getOrElse(throw IllegalArgumentException("Invalid development version"))

scripts/Dependencies.scala

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import scala.util.matching.Regex
33
import upickle.default.*
44
import coursier.graph.DependencyTree
55
import Dependencies.*
6+
import coursier.*
67

78
object Dependencies:
89
case class Version(major: Int, minor: Int, patch: Int, suffix: Option[String] = None) extends Ordered[Version] derives ReadWriter:
@@ -33,7 +34,13 @@ object Dependencies:
3334
case Version(_, 0, 0, _) => MajorUpdate
3435
case Version(_, _, 0, _) => MinorUpdate
3536
case _ => PatchUpdate
36-
else throw new IllegalArgumentException("Versions are the same")
37+
else throw new IllegalArgumentException("Versions are the same: " + oldVersion + " -> " + newVersion)
38+
39+
def parse(s: String): Option[Version] =
40+
val regex = """(\d+)\.(\d+)\.(\d+)(-[a-zA-Z\d\.]+)?""".r
41+
s match
42+
case regex(major, minor, patch, suffix) => Some(Version(major.toInt, minor.toInt, patch.toInt, Option(suffix).map(_.drop(1))))
43+
case _ => None
3744

3845

3946
sealed abstract class VersionDiff(val order: Int) extends Ordered[VersionDiff]:
@@ -43,25 +50,28 @@ object Dependencies:
4350
case object MajorUpdate extends VersionDiff(2)
4451

4552
object VersionString:
46-
def unapply(s: String): Option[Version] =
47-
val regex = """(\d+)\.(\d+)\.(\d+)(-[a-zA-Z\d\.]+)?""".r
48-
s match
49-
case regex(major, minor, patch, suffix) => Some(Version(major.toInt, minor.toInt, patch.toInt, Option(suffix).map(_.drop(1))))
50-
case _ => None
53+
def unapply(s: String): Option[Version] = Version.parse(s)
5154

5255
case class Dep(id: String, version: Version, deps: List[Dep]) derives ReadWriter:
5356
override def toString: String = s"$id:$version"
5457

58+
object Dep:
59+
def resolve(org: String, module: String, crossVersion: String, version: Version): Dep =
60+
val dep = Dependency(Module(Organization(org), ModuleName(module + "_" + crossVersion)), version.toString)
61+
val resolution = Resolve()
62+
.addDependencies(dep)
63+
.run()
64+
65+
val head = DependencyTree(resolution).head
66+
makeDepTree(head)
5567

56-
def makeDepTree(tree: DependencyTree): Dep =
57-
val dep = tree.dependency
58-
val depId = s"${dep.module.organization.value}:${dep.module.name.value}"
59-
val versionParsed = VersionString.unapply(dep.version)
60-
versionParsed match
61-
case Some(version) => Dep(dep.module.toString(), version, tree.children.map(makeDepTree).toList)
62-
case None => throw new Exception(s"Could not parse version from $depId:${dep.version}")
63-
64-
68+
def makeDepTree(tree: DependencyTree): Dep =
69+
val dep = tree.dependency
70+
val depId = s"${dep.module.organization.value}:${dep.module.name.value}"
71+
val versionParsed = Version.parse(dep.version)
72+
versionParsed match
73+
case Some(version) => Dep(dep.module.toString(), version, tree.children.map(makeDepTree).toList)
74+
case None => throw new Exception(s"Could not parse version from $depId:${dep.version}")
6575
object Utility:
6676
def requireCmd(cmd: String): Unit =
6777
if Try(os.proc("which", cmd).call()).isFailure then

scripts/changelog.sc

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
#!/usr/bin/env -S scala-cli shebang
2+
//> using scala 3.3
3+
//> using toolkit 0.1.7
4+
//> using dep io.get-coursier:coursier_2.13:2.1.4
5+
//> using file Dependencies.scala
6+
//> using file Config.scala
7+
8+
import scala.util.Try
9+
import coursier.*
10+
import coursier.given
11+
import coursier.graph.DependencyTree
12+
import scala.util.matching.Regex
13+
import upickle.default.*
14+
import scala.io.StdIn.readLine
15+
import Dependencies.*
16+
17+
Utility.requireCmd("scala-cli")
18+
19+
case class DiffSummary(updateType: VersionDiff, diffs: List[Diff], illegalDiffs: List[IllegalDiff])
20+
sealed trait Diff derives ReadWriter:
21+
def under: Option[Dep]
22+
case class Added(newDep: Dep, under: Option[Dep]) extends Diff:
23+
override def toString: String = s"Added `$newDep` ${under.fold("")(u => s"under `$u`")}"
24+
case class Removed(oldDep: Dep, under: Option[Dep]) extends Diff:
25+
override def toString: String = s"Removed `$oldDep` ${under.fold("")(u => s"under `$u`")}"
26+
case class DepUpdated(oldDep: Dep, newDep: Dep, under: Option[Dep]) extends Diff:
27+
override def toString: String =
28+
s"Updated `$oldDep` from `${oldDep.version}` to `${newDep.version}` ${under.fold("")(u => s"under `$u`")}"
29+
30+
object Diff:
31+
def getOldDep(diff: Diff) = diff match
32+
case Added(newDep, _) => None
33+
case Removed(oldDep, _) => Some(oldDep)
34+
case DepUpdated(oldDep, _, _) => Some(oldDep)
35+
36+
case class IllegalDiff(diff: Diff, leastOrderLegalUpdate: VersionDiff):
37+
override def toString: String = s"$diff (required at least: $leastOrderLegalUpdate)"
38+
39+
case class Params(file: String, overwrite: Boolean)
40+
41+
val params = args match
42+
case Array(file) => Params(file, false)
43+
case Array(file, "--overwrite") => Params(file, true)
44+
case _ => throw new Exception("Usage: ./scripts/changelog.sc <file> [overwrite]")
45+
46+
println("Publishing locally to validate the dependency tree...")
47+
os.proc("scala-cli", "--power", "publish", "local", "--cross", "--organization", Config.organization, "--version", Config.developmentVersion.toString, params.file).call()
48+
49+
Config.crossVersions.foreach(checkTree)
50+
51+
def checkTree(crossVersion: String) =
52+
53+
val previousSnapshot = Dep.resolve(Config.organization, Config.name, crossVersion, Config.releaseVersion)
54+
val currentSnapshot = Dep.resolve(Config.organization, Config.name, crossVersion, Config.developmentVersion)
55+
56+
val summary = SnapshotDiffValidator.summarize(previousSnapshot, currentSnapshot)
57+
if summary.illegalDiffs.nonEmpty then
58+
println("Found diffs illegal to introduce on this update type: " + summary.updateType)
59+
println(s"Illegal diffs: \n ${summary.illegalDiffs.mkString(" - ", "\n - ", "\n")}")
60+
if params.overwrite then
61+
println("Could not generate changelog due to illegal diffs.")
62+
println("Exiting with failure status (1)")
63+
sys.exit(1)
64+
65+
val diffsFound = summary.diffs.nonEmpty
66+
67+
if diffsFound then
68+
println("Found diffs in development version: \n" + summary.diffs.mkString(" - ", "\n - ", "\n"))
69+
else
70+
println("No diffs found")
71+
72+
val generatedChangelog = Changelog.generate(summary.diffs)
73+
val changelogJson = os.pwd / "changelog" / "json" / s"${Config.developmentVersion}_changelog_${crossVersion}.json"
74+
if !os.exists(changelogJson) then
75+
println("No changelog found, comparing with empty changelog.")
76+
os.makeDir.all(os.pwd / "changelog" / "json")
77+
val parsedChangelog = if(os.exists(changelogJson)) then read[Changelog](os.read(changelogJson)) else Changelog(Set.empty, Set.empty)
78+
def changelogsDiff = generatedChangelog.diff(parsedChangelog)
79+
80+
if changelogsDiff.nonEmpty then
81+
println("Found diffs in changelog: \n" + changelogsDiff.get)
82+
if !params.overwrite then
83+
println("Exiting with failure status (1). Please run with --overwrite to overwrite the expected changelog.")
84+
sys.exit(1)
85+
else
86+
val input = readLine("Overwrite symbols? (y/N)")
87+
println(s"Input: $input")
88+
if input == "y" || input == "Y" then
89+
os.write.over(changelogJson, write(generatedChangelog))
90+
val changelogReadable = generatedChangelog.toMd
91+
val changelogMd = os.pwd / "changelog" / s"${Config.developmentVersion}_changelog_${crossVersion}.md"
92+
os.write.over(changelogMd, changelogReadable)
93+
println("Overwritten")
94+
else
95+
println("Changes rejected")
96+
sys.exit(1)
97+
else
98+
println("No diffs found in changelog")
99+
100+
101+
object SnapshotDiffValidator:
102+
103+
def summarize(previous: Dep, current: Dep): DiffSummary =
104+
val versionDiff = Version.compareVersions(previous.version, current.version)
105+
106+
def traverseDep(oldDep: Option[Dep], newDep: Option[Dep], enteredFrom: Option[Dep] = None): List[Diff] =
107+
def traverseDepTupled(enteredFrom: Dep)(pair: (Option[Dep], Option[Dep])) = traverseDep(pair._1, pair._2, Some(enteredFrom))
108+
(oldDep, newDep) match
109+
case (Some(oldDep), Some(newDep)) =>
110+
if oldDep.version == newDep.version then Nil
111+
else
112+
val diff = DepUpdated(oldDep, newDep, enteredFrom)
113+
val pairs = oldDep.deps.map(dep => (Some(dep), newDep.deps.find(_.id == dep.id)))
114+
val newDeps = newDep.deps.filterNot(dep => oldDep.deps.exists(_.id == dep.id)).map(dep => (None, Some(dep)))
115+
val diffs = pairs.flatMap(traverseDepTupled(newDep)) ++ newDeps.flatMap(traverseDepTupled(newDep))
116+
diff :: diffs
117+
case (Some(oldDep), None) => Removed(oldDep, enteredFrom) :: oldDep.deps.flatMap(dep => traverseDep(Some(dep), None, Some(oldDep))).toList
118+
case (None, Some(newDep)) => Added(newDep, enteredFrom) :: newDep.deps.flatMap(dep => traverseDep(None, Some(dep), Some(newDep))).toList
119+
case _ => Nil
120+
121+
val diffs: List[Diff] = traverseDep(Some(previous), Some(current))
122+
val illegalDiffs: List[IllegalDiff] = diffs
123+
.map(diff => IllegalDiff(diff, getLeastOrderLegalUpdate(versionDiff, diff)))
124+
.filter(_.leastOrderLegalUpdate > versionDiff)
125+
DiffSummary(versionDiff, diffs, illegalDiffs)
126+
127+
128+
def getLeastOrderLegalUpdate(versionDiff: VersionDiff, diff: Diff): VersionDiff =
129+
diff match
130+
case _: Removed => MajorUpdate
131+
case _: Added => MinorUpdate
132+
case DepUpdated(oldDep, newDep, _) =>
133+
Version.compareVersions(oldDep.version, newDep.version)
134+
135+
136+
case class Changelog(directChanges: Set[Diff], indirectChanges: Set[Diff]) derives ReadWriter:
137+
138+
def diff(other: Changelog): Option[String] =
139+
if other == this then
140+
None
141+
else
142+
val directAdded = directChanges.filterNot(other.directChanges.contains)
143+
val directRemoved = other.directChanges.filterNot(directChanges.contains)
144+
val indirectAdded = indirectChanges.filterNot(other.indirectChanges.contains)
145+
val indirectRemoved = other.indirectChanges.filterNot(indirectChanges.contains)
146+
val directAddedString = if directAdded.isEmpty then "" else s" - Direct changes added:\n${directAdded.mkString(" - ", "\n - ", "")}\n"
147+
val directRemovedString = if directRemoved.isEmpty then "" else s" - Direct changes removed:\n${directRemoved.mkString(" - ", "\n - ", "")}\n"
148+
val indirectAddedString = if indirectAdded.isEmpty then "" else s" - Indirect changes added:\n${indirectAdded.mkString(" - ", "\n - ", "")}\n"
149+
val indirectRemovedString = if indirectRemoved.isEmpty then "" else s" - Indirect changes removed:\n${indirectRemoved.mkString(" - ", "\n - ", "")}\n"
150+
Some(directAddedString + directRemovedString + indirectAddedString + indirectRemovedString)
151+
152+
def toMd: String =
153+
val directChangesSection = if directChanges.isEmpty then "" else
154+
val directChangesHeader = "## Changes to direct dependencies"
155+
val directChangesList = directChanges.map(diff => s" - ${diff.toString}").toList.sorted
156+
(directChangesHeader :: directChangesList).mkString("\n")
157+
val indirectChangesSection = if indirectChanges.isEmpty then "" else
158+
val indirectChangesHeader = "## Changes to transitive dependencies"
159+
val indirectChangesList = indirectChanges.map(diff => s" - ${diff.toString}").toList.sorted
160+
(indirectChangesHeader :: indirectChangesList).mkString("\n")
161+
val header = s"# Changelog for ${Config.name} ${Config.developmentVersion}"
162+
header + "\n" + directChangesSection + "\n" + indirectChangesSection
163+
164+
object Changelog:
165+
def generate(diffs: List[Diff]): Changelog =
166+
val parentDiff = diffs.find(_.under.isEmpty).getOrElse(throw new Exception("No parent diff found"))
167+
val parentId = Diff.getOldDep(parentDiff).getOrElse(throw new Exception(s"Illegal parent diff: ${parentDiff}")).id
168+
val (directChanges, indirectChanges) = diffs.partition(_.under.exists(_.id == parentId))
169+
Changelog(directChanges.toSet, indirectChanges.toSet - parentDiff)

scripts/deptree.sc

Lines changed: 0 additions & 112 deletions
This file was deleted.

0 commit comments

Comments
 (0)