diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala index be764b2eee..10dfabff0a 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala @@ -66,6 +66,7 @@ final class Context[F[_]](implicit val logger: Logger[F], val mavenAlg: MavenAlg[F], val millAlg: MillAlg[F], + val nurtureAlg: NurtureAlg[F], val pruningAlg: PruningAlg[F], val pullRequestRepository: PullRequestRepository[F], val refreshErrorAlg: RefreshErrorAlg[F], diff --git a/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala index 21231901f6..2de887e5e5 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala @@ -16,15 +16,14 @@ package org.scalasteward.core.coursier +import cats.Parallel import cats.effect.Async import cats.implicits._ -import cats.{Applicative, Parallel} import coursier.cache.{CachePolicy, FileCache} import coursier.core.{Authentication, Project} -import coursier.{Fetch, Info, Module, ModuleName, Organization} -import org.http4s.Uri +import coursier.{Fetch, Module, ModuleName, Organization} import org.scalasteward.core.data.Resolver.Credentials -import org.scalasteward.core.data.{Dependency, Resolver, Scope, Version} +import org.scalasteward.core.data.{Dependency, Resolver, Version} import org.scalasteward.core.util.uri import org.typelevel.log4cats.Logger @@ -32,16 +31,9 @@ import org.typelevel.log4cats.Logger * metadata. */ trait CoursierAlg[F[_]] { - def getArtifactUrl(dependency: Scope.Dependency): F[Option[Uri]] + def getMetadata(dependency: Dependency, resolvers: List[Resolver]): F[DependencyMetadata] def getVersions(dependency: Dependency, resolver: Resolver): F[List[Version]] - - final def getArtifactIdUrlMapping(dependencies: Scope.Dependencies)(implicit - F: Applicative[F] - ): F[Map[String, Uri]] = - dependencies.sequence - .traverseFilter(dep => getArtifactUrl(dep).map(_.map(dep.value.artifactId.name -> _))) - .map(_.toMap) } object CoursierAlg { @@ -50,64 +42,68 @@ object CoursierAlg { parallel: Parallel[F], F: Async[F] ): CoursierAlg[F] = { - val fetch: Fetch[F] = Fetch[F](FileCache[F]()) + val fetch: Fetch[F] = + Fetch[F](FileCache[F]()) val cacheNoTtl: FileCache[F] = FileCache[F]().withTtl(None).withCachePolicies(List(CachePolicy.Update)) new CoursierAlg[F] { - override def getArtifactUrl(dependency: Scope.Dependency): F[Option[Uri]] = - convertToCoursierTypes(dependency).flatMap((getArtifactUrlImpl _).tupled) + override def getMetadata( + dependency: Dependency, + resolvers: List[Resolver] + ): F[DependencyMetadata] = + resolvers.traverseFilter(convertResolver(_).attempt.map(_.toOption)).flatMap { + repositories => + val csrDependency = toCoursierDependency(dependency) + getMetadataImpl(csrDependency, repositories, DependencyMetadata.empty) + } - private def getArtifactUrlImpl( + private def getMetadataImpl( dependency: coursier.Dependency, - repositories: List[coursier.Repository] - ): F[Option[Uri]] = { + repositories: List[coursier.Repository], + acc: DependencyMetadata + ): F[DependencyMetadata] = { val fetchArtifacts = fetch .withArtifactTypes(Set(coursier.Type.pom, coursier.Type.ivy)) .withDependencies(List(dependency)) .withRepositories(repositories) + fetchArtifacts.ioResult.attempt.flatMap { case Left(throwable) => - logger.debug(throwable)(s"Failed to fetch artifacts of $dependency").as(None) + logger.debug(throwable)(s"Failed to fetch artifacts of $dependency").as(acc) case Right(result) => val maybeProject = result.resolution.projectCache .get(dependency.moduleVersion) .map { case (_, project) => project } - maybeProject.traverseFilter { project => - getScmUrlOrHomePage(project.info) match { - case Some(url) => F.pure(Some(url)) - case None => - getParentDependency(project).traverseFilter(getArtifactUrlImpl(_, repositories)) + + maybeProject.fold(F.pure(acc)) { project => + val metadata = acc.enrichWith(metadataFrom(project)) + val recurse = Option.when(metadata.repoUrl.isEmpty)(()) + (recurse >> parentOf(project)).fold(F.pure(metadata)) { parent => + getMetadataImpl(parent, repositories, metadata) } } } } override def getVersions(dependency: Dependency, resolver: Resolver): F[List[Version]] = - toCoursierRepository(resolver) match { - case Left(message) => - logger.error(message) >> F.raiseError(new Throwable(message)) - case Right(repository) => - val module = toCoursierModule(dependency) - repository.versions(module, cacheNoTtl.fetch).run.flatMap { - case Left(message) => - logger.debug(message) >> F.raiseError(new Throwable(message)) - case Right((versions, _)) => F.pure(versions.available.map(Version.apply).sorted) - } + convertResolver(resolver).flatMap { repository => + val module = toCoursierModule(dependency) + repository.versions(module, cacheNoTtl.fetch).run.flatMap { + case Left(message) => + logger.debug(message) >> F.raiseError[List[Version]](new Throwable(message)) + case Right((versions, _)) => + F.pure(versions.available.map(Version.apply).sorted) + } } - private def convertToCoursierTypes( - dependency: Scope.Dependency - ): F[(coursier.Dependency, List[coursier.Repository])] = - dependency.resolvers.traverseFilter(convertResolver).map { repositories => - (toCoursierDependency(dependency.value), repositories) - } - - private def convertResolver(resolver: Resolver): F[Option[coursier.Repository]] = + private def convertResolver(resolver: Resolver): F[coursier.Repository] = toCoursierRepository(resolver) match { - case Right(repository) => F.pure(Some(repository)) - case Left(message) => logger.error(s"Failed to convert $resolver: $message").as(None) + case Right(repository) => F.pure(repository) + case Left(message) => + logger.error(s"Failed to convert $resolver: $message") >> + F.raiseError[coursier.Repository](new Throwable(message)) } } } @@ -127,40 +123,40 @@ object CoursierAlg { private def toCoursierRepository(resolver: Resolver): Either[String, coursier.Repository] = resolver match { case Resolver.MavenRepository(_, location, creds, headers) => - Right( - coursier.maven.MavenRepository - .apply(location, toCoursierAuthentication(creds, headers)) - ) + val authentication = toCoursierAuthentication(creds, headers) + Right(coursier.maven.MavenRepository.apply(location, authentication)) case Resolver.IvyRepository(_, pattern, creds, headers) => - coursier.ivy.IvyRepository - .parse(pattern, authentication = toCoursierAuthentication(creds, headers)) + val authentication = toCoursierAuthentication(creds, headers) + coursier.ivy.IvyRepository.parse(pattern, authentication = authentication) } private def toCoursierAuthentication( credentials: Option[Credentials], headers: List[Resolver.Header] ): Option[Authentication] = - if (credentials.isEmpty && headers.isEmpty) { - None - } else { - Some( - new Authentication( - credentials.fold("")(_.user), - credentials.map(_.pass), - headers.map(h => (h.key, h.value)), - optional = false, - None, - httpsOnly = true, - passOnRedirect = false - ) + Option.when(credentials.nonEmpty || headers.nonEmpty) { + new Authentication( + credentials.fold("")(_.user), + credentials.map(_.pass), + headers.map(h => (h.key, h.value)), + optional = false, + realmOpt = None, + httpsOnly = true, + passOnRedirect = false ) } - private def getParentDependency(project: Project): Option[coursier.Dependency] = + private def metadataFrom(project: Project): DependencyMetadata = + DependencyMetadata( + homePage = uri.fromStringWithScheme(project.info.homePage), + scmUrl = project.info.scm.flatMap(_.url).flatMap(uri.fromStringWithScheme), + releaseNotesUrl = project.properties + .collectFirst { case (key, value) if key.equalsIgnoreCase("info.releaseNotesUrl") => value } + .flatMap(uri.fromStringWithScheme) + ) + + private def parentOf(project: Project): Option[coursier.Dependency] = project.parent.map { case (module, version) => coursier.Dependency(module, version).withTransitive(false) } - - private def getScmUrlOrHomePage(info: Info): Option[Uri] = - uri.findBrowsableUrl(info.scm.flatMap(_.url).toList :+ info.homePage) } diff --git a/modules/core/src/main/scala/org/scalasteward/core/coursier/DependencyMetadata.scala b/modules/core/src/main/scala/org/scalasteward/core/coursier/DependencyMetadata.scala new file mode 100644 index 0000000000..7cba02f666 --- /dev/null +++ b/modules/core/src/main/scala/org/scalasteward/core/coursier/DependencyMetadata.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2018-2022 Scala Steward contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.scalasteward.core.coursier + +import cats.Monad +import cats.syntax.all._ +import org.http4s.Uri +import org.scalasteward.core.util.uri + +final case class DependencyMetadata( + homePage: Option[Uri], + scmUrl: Option[Uri], + releaseNotesUrl: Option[Uri] +) { + def enrichWith(other: DependencyMetadata): DependencyMetadata = + DependencyMetadata( + homePage = homePage.orElse(other.homePage), + scmUrl = scmUrl.orElse(other.scmUrl), + releaseNotesUrl = releaseNotesUrl.orElse(other.releaseNotesUrl) + ) + + def filterUrls[F[_]](f: Uri => F[Boolean])(implicit F: Monad[F]): F[DependencyMetadata] = + for { + homePage <- homePage.filterA(f) + scmUrl <- scmUrl.filterA(f) + releaseNotesUrl <- releaseNotesUrl.filterA(f) + } yield DependencyMetadata(homePage, scmUrl, releaseNotesUrl) + + def repoUrl: Option[Uri] = { + val urls = scmUrl.toList ++ homePage.toList + urls.find(_.scheme.exists(uri.httpSchemes)).orElse(urls.headOption) + } +} + +object DependencyMetadata { + val empty: DependencyMetadata = + DependencyMetadata(None, None, None) +} diff --git a/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala index acd7111689..27a018bd0a 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala @@ -16,7 +16,7 @@ package org.scalasteward.core.nurture -import cats.Id +import cats.{Applicative, Id} import cats.effect.Concurrent import cats.implicits._ import org.scalasteward.core.application.Config.VCSCfg @@ -26,13 +26,12 @@ import org.scalasteward.core.data._ import org.scalasteward.core.edit.{EditAlg, EditAttempt} import org.scalasteward.core.git.{Branch, Commit, GitAlg} import org.scalasteward.core.repoconfig.PullRequestUpdateStrategy -import org.scalasteward.core.util.{Nel, UrlChecker} import org.scalasteward.core.util.logger.LoggerOps +import org.scalasteward.core.util.{Nel, UrlChecker} import org.scalasteward.core.vcs.data._ import org.scalasteward.core.vcs.{VCSApiAlg, VCSExtraAlg, VCSRepoAlg} import org.scalasteward.core.{git, util, vcs} import org.typelevel.log4cats.Logger -import cats.Applicative final class NurtureAlg[F[_]](config: VCSCfg)(implicit coursierAlg: CoursierAlg[F], @@ -194,30 +193,39 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit _.updates.flatMap(dependenciesUpdatedWithNextAndCurrentVersion(_)) ) - private def createPullRequest(data: UpdateData, edits: List[EditAttempt]): F[ProcessResult] = + private[nurture] def preparePullRequest( + data: UpdateData, + edits: List[EditAttempt] + ): F[NewPullRequestData] = for { - _ <- logger.info(s"Create PR ${data.updateBranch.name}") + _ <- F.unit dependenciesWithNextVersion = dependenciesUpdatedWithNextAndCurrentVersion(data.update) resolvers = data.repoData.cache.dependencyInfos.flatMap(_.resolvers) - dependencyScope = Scope( - value = dependenciesWithNextVersion.map { case (_, dependency) => dependency }, - resolvers = resolvers - ) - artifactIdToUrl <- coursierAlg.getArtifactIdUrlMapping(dependencyScope) - existingArtifactUrlsList <- artifactIdToUrl.toList.filterA { case (_, uri) => - urlChecker.exists(uri) - } - existingArtifactUrlsMap = existingArtifactUrlsList.toMap + dependencyToMetadata <- dependenciesWithNextVersion + .traverse { case (_, dependency) => + coursierAlg + .getMetadata(dependency, resolvers) + .flatMap(_.filterUrls(urlChecker.exists)) + .tupleLeft(dependency) + } + .map(_.toMap) + artifactIdToUrl = dependencyToMetadata.toList.mapFilter { case (dependency, metadata) => + metadata.repoUrl.tupleLeft(dependency.artifactId.name) + }.toMap releaseRelatedUrls <- dependenciesWithNextVersion.flatTraverse { case (currentVersion, dependency) => - existingArtifactUrlsMap - .get(dependency.artifactId.name) - .toList - .traverse(uri => + dependencyToMetadata.get(dependency).toList.flatTraverse { metadata => + metadata.repoUrl.toList.traverse { uri => vcsExtraAlg - .getReleaseRelatedUrls(uri, currentVersion, dependency.version) + .getReleaseRelatedUrls( + uri, + metadata.releaseNotesUrl, + currentVersion, + dependency.version + ) .tupleLeft(dependency.artifactId.name) - ) + } + } } filesWithOldVersion <- data.update @@ -229,11 +237,17 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit data, branchName, edits, - existingArtifactUrlsMap, + artifactIdToUrl, releaseRelatedUrls.toMap, filesWithOldVersion, data.repoData.config.pullRequests.includeMatchedLabels ) + } yield requestData + + private def createPullRequest(data: UpdateData, edits: List[EditAttempt]): F[ProcessResult] = + for { + _ <- logger.info(s"Create PR ${data.updateBranch.name}") + requestData <- preparePullRequest(data, edits) pr <- vcsApiAlg.createPullRequest(data.repo, requestData) _ <- vcsApiAlg .labelPullRequest(data.repo, pr.number, requestData.labels) diff --git a/modules/core/src/main/scala/org/scalasteward/core/util/uri.scala b/modules/core/src/main/scala/org/scalasteward/core/util/uri.scala index 845a46d43c..2846c3206c 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/util/uri.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/util/uri.scala @@ -41,11 +41,9 @@ object uri { val withUserInfo: Optional[Uri, UserInfo] = authorityWithUserInfo.compose(withAuthority) - private val httpSchemes: Set[Scheme] = - Set(Scheme.https, Scheme.http) + def fromStringWithScheme(s: String): Option[Uri] = + Uri.fromString(s).toOption.filter(_.scheme.isDefined) - def findBrowsableUrl(xs: List[String]): Option[Uri] = { - val urls = xs.flatMap(Uri.fromString(_).toList).filter(_.scheme.isDefined) - urls.find(_.scheme.exists(httpSchemes)).orElse(urls.headOption) - } + val httpSchemes: Set[Scheme] = + Set(Scheme.https, Scheme.http) } diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSExtraAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSExtraAlg.scala index 842b957e5b..afdc391300 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSExtraAlg.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSExtraAlg.scala @@ -27,6 +27,7 @@ import org.scalasteward.core.vcs trait VCSExtraAlg[F[_]] { def getReleaseRelatedUrls( repoUrl: Uri, + releaseNotesUrl: Option[Uri], currentVersion: Version, nextVersion: Version ): F[List[ReleaseRelatedUrl]] @@ -40,17 +41,22 @@ object VCSExtraAlg { new VCSExtraAlg[F] { override def getReleaseRelatedUrls( repoUrl: Uri, + releaseNotesUrl: Option[Uri], currentVersion: Version, nextVersion: Version - ): F[List[ReleaseRelatedUrl]] = - vcs - .possibleReleaseRelatedUrls( - config.tpe, - config.apiHost, - repoUrl, - currentVersion, - nextVersion - ) + ): F[List[ReleaseRelatedUrl]] = { + val releaseRelatedUrls = + releaseNotesUrl.toList.map(ReleaseRelatedUrl.CustomReleaseNotes.apply) ++ + vcs.possibleReleaseRelatedUrls( + config.tpe, + config.apiHost, + repoUrl, + currentVersion, + nextVersion + ) + releaseRelatedUrls + .distinctBy(_.url) .filterA(releaseRelatedUrl => urlChecker.exists(releaseRelatedUrl.url)) + } } } diff --git a/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala b/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala index ad1fdb4bab..b0f884bf53 100644 --- a/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala +++ b/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala @@ -5,19 +5,14 @@ import org.scalasteward.core.data._ import org.scalasteward.core.util.Nel object TestSyntax { + val sbtPluginReleases: IvyRepository = { + val pattern = "https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/[defaultPattern]" + IvyRepository("sbt-plugin-releases", pattern, None) + } + implicit class GenericOps[A](val self: A) extends AnyVal { def withMavenCentral: Scope[A] = Scope(self, List(Resolver.mavenCentral)) - - def withSbtPluginReleases: Scope[A] = { - val sbtPluginReleases = IvyRepository( - "sbt-plugin-releases", - "https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/[defaultPattern]", - None, - Nil - ) - Scope(self, List(sbtPluginReleases)) - } } implicit class StringOps(private val self: String) extends AnyVal { diff --git a/modules/core/src/test/scala/org/scalasteward/core/coursier/CoursierAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/coursier/CoursierAlgTest.scala index 47ce72ae88..17105375a4 100644 --- a/modules/core/src/test/scala/org/scalasteward/core/coursier/CoursierAlgTest.scala +++ b/modules/core/src/test/scala/org/scalasteward/core/coursier/CoursierAlgTest.scala @@ -4,88 +4,107 @@ import munit.CatsEffectSuite import org.http4s.syntax.literals._ import org.scalasteward.core.TestSyntax._ import org.scalasteward.core.buildtool.sbt.data.{SbtVersion, ScalaVersion} +import org.scalasteward.core.data.Resolver import org.scalasteward.core.mock.MockContext.context.coursierAlg import org.scalasteward.core.mock.MockState class CoursierAlgTest extends CatsEffectSuite { - test("getArtifactUrl: library") { + private val resolvers = List(Resolver.mavenCentral) + + private val emptyMetadata = DependencyMetadata.empty + + test("getMetadata: with homePage and scmUrl") { val dep = "org.typelevel".g % ("cats-effect", "cats-effect_2.12").a % "1.0.0" - coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained => - assertEquals(obtained, Some(uri"https://github.com/typelevel/cats-effect")) - } + val obtained = coursierAlg.getMetadata(dep, resolvers).runA(MockState.empty) + val expected = emptyMetadata.copy( + homePage = Some(uri"https://typelevel.org/cats-effect/"), + scmUrl = Some(uri"https://github.com/typelevel/cats-effect") + ) + assertIO(obtained, expected) } - test("getArtifactUrl: defaults to homepage") { + test("getMetadata: homePage only") { val artifactId = ("play-ws-standalone-json", "play-ws-standalone-json_2.12").a val dep = "com.typesafe.play".g % artifactId % "2.1.0-M7" - coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained => - assertEquals(obtained, Some(uri"https://github.com/playframework/play-ws")) - } + val obtained = coursierAlg.getMetadata(dep, resolvers).runA(MockState.empty) + val expected = + emptyMetadata.copy(homePage = Some(uri"https://github.com/playframework/play-ws")) + assertIO(obtained, expected) } - test("getArtifactUrl: URL with no or invalid scheme 1") { + test("getMetadata: scmUrl without scheme") { val dep = "org.msgpack".g % "msgpack-core".a % "0.8.20" - coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained => - assertEquals(obtained, Some(uri"http://msgpack.org/")) - } + val obtained = coursierAlg.getMetadata(dep, resolvers).runA(MockState.empty) + val expected = emptyMetadata.copy(homePage = Some(uri"http://msgpack.org/")) + assertIO(obtained, expected) } - test("getArtifactUrl: URL with no or invalid scheme 2") { + test("getMetadata: scmUrl with git scheme") { val dep = "org.xhtmlrenderer".g % "flying-saucer-parent".a % "9.0.1" - coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained => - assertEquals(obtained, Some(uri"http://code.google.com/p/flying-saucer/")) - } + val obtained = coursierAlg.getMetadata(dep, resolvers).runA(MockState.empty) + val expected = emptyMetadata.copy( + homePage = Some(uri"http://code.google.com/p/flying-saucer/"), + scmUrl = Some(uri"git://github.com/flyingsaucerproject/flyingsaucer.git") + ) + assertIO(obtained, expected) } - test("getArtifactUrl: from parent") { + test("getMetadata: homePage from parent") { val dep = "net.bytebuddy".g % "byte-buddy".a % "1.10.5" - coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained => - assertEquals(obtained, Some(uri"https://bytebuddy.net")) - } + val obtained = coursierAlg.getMetadata(dep, resolvers).runA(MockState.empty) + val expected = emptyMetadata.copy(homePage = Some(uri"https://bytebuddy.net")) + assertIO(obtained, expected) } - test("getArtifactUrl: minimal pom") { + test("getMetadata: minimal POM") { val dep = "altrmi".g % "altrmi-common".a % "0.9.6" - coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained => - assertEquals(obtained, None) - } + val obtained = coursierAlg.getMetadata(dep, resolvers).runA(MockState.empty) + val expected = emptyMetadata + assertIO(obtained, expected) } - test("getArtifactUrl: sbt plugin on Maven Central") { + test("getMetadata: sbt plugin on Maven Central") { val dep = ("org.xerial.sbt".g % "sbt-sonatype".a % "3.8") .copy(sbtVersion = Some(SbtVersion("1.0")), scalaVersion = Some(ScalaVersion("2.12"))) - coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained => - assertEquals(obtained, Some(uri"https://github.com/xerial/sbt-sonatype")) - } + val obtained = coursierAlg.getMetadata(dep, resolvers).runA(MockState.empty) + val expected = emptyMetadata.copy( + homePage = Some(uri"https://github.com/xerial/sbt-sonatype"), + scmUrl = Some(uri"https://github.com/xerial/sbt-sonatype") + ) + assertIO(obtained, expected) } - test("getArtifactUrl: sbt plugin on sbt-plugin-releases") { + test("getMetadata: sbt plugin on sbt-plugin-releases") { val dep = ("com.github.gseitz".g % "sbt-release".a % "1.0.12") .copy(sbtVersion = Some(SbtVersion("1.0")), scalaVersion = Some(ScalaVersion("2.12"))) - coursierAlg.getArtifactUrl(dep.withSbtPluginReleases).runA(MockState.empty).map { obtained => - assertEquals(obtained, Some(uri"https://github.com/sbt/sbt-release")) - } + val obtained = coursierAlg.getMetadata(dep, List(sbtPluginReleases)).runA(MockState.empty) + val expected = emptyMetadata.copy(homePage = Some(uri"https://github.com/sbt/sbt-release")) + assertIO(obtained, expected) } - test("getArtifactUrl: invalid scm URL but valid homepage") { + test("getMetadata: scmUrl with github scheme") { val dep = "com.github.japgolly.scalajs-react".g % ("core", "core_sjs1_2.13").a % "2.0.0-RC5" - coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained => - assertEquals(obtained, Some(uri"https://github.com/japgolly/scalajs-react")) - } + val obtained = coursierAlg.getMetadata(dep, resolvers).runA(MockState.empty) + val expected = emptyMetadata.copy( + homePage = Some(uri"https://github.com/japgolly/scalajs-react"), + scmUrl = Some(uri"github.com:japgolly/scalajs-react.git") + ) + assertIO(obtained, expected) } - test("getArtifactIdUrlMapping") { - val deps = List( - "org.typelevel".g % ("cats-core", "cats-core_2.12").a % "1.6.0", - "org.typelevel".g % ("cats-effect", "cats-effect_2.12").a % "1.0.0" - ) - coursierAlg.getArtifactIdUrlMapping(deps.withMavenCentral).runA(MockState.empty).map { - obtained => - val expected = Map( - "cats-core" -> uri"https://github.com/typelevel/cats", - "cats-effect" -> uri"https://github.com/typelevel/cats-effect" - ) - assertEquals(obtained, expected) - } + test("getMetadata: no resolvers") { + val dep = "org.example".g % "foo".a % "1.0.0" + val obtained = coursierAlg.getMetadata(dep, List.empty).runA(MockState.empty) + val expected = emptyMetadata + assertIO(obtained, expected) + } + + test("getMetadata: resolver with headers") { + val dep = "org.typelevel".g % ("cats-effect", "cats-effect_2.12").a % "1.0.0" + val resolvers = + List(Resolver.mavenCentral.copy(headers = List(Resolver.Header("X-Foo", "bar")))) + val obtained = + coursierAlg.getMetadata(dep, resolvers).runA(MockState.empty).map(_.repoUrl.isDefined) + assertIOBoolean(obtained) } } diff --git a/modules/core/src/test/scala/org/scalasteward/core/coursier/DependencyMetadataTest.scala b/modules/core/src/test/scala/org/scalasteward/core/coursier/DependencyMetadataTest.scala new file mode 100644 index 0000000000..06ee49f09c --- /dev/null +++ b/modules/core/src/test/scala/org/scalasteward/core/coursier/DependencyMetadataTest.scala @@ -0,0 +1,28 @@ +package org.scalasteward.core.coursier + +import cats.Id +import cats.syntax.all._ +import munit.FunSuite +import org.http4s.implicits.http4sLiteralsSyntax + +class DependencyMetadataTest extends FunSuite { + test("filterUrls") { + val metadata = DependencyMetadata( + homePage = Some(uri"https://github.com/japgolly/scalajs-react"), + scmUrl = Some(uri"github.com:japgolly/scalajs-react.git"), + releaseNotesUrl = None + ) + val obtained = metadata.filterUrls(_.renderString.startsWith("http").pure[Id]) + assertEquals(obtained, metadata.copy(scmUrl = None)) + } + + test("repoUrl: scmUrl with non-http scheme") { + val homePage = Some(uri"https://github.com/japgolly/scalajs-react") + val metadata = DependencyMetadata( + homePage = homePage, + scmUrl = Some(uri"github.com:japgolly/scalajs-react.git"), + releaseNotesUrl = None + ) + assertEquals(metadata.repoUrl, homePage) + } +} 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 new file mode 100644 index 0000000000..19091a31e3 --- /dev/null +++ b/modules/core/src/test/scala/org/scalasteward/core/nurture/NurtureAlgTest.scala @@ -0,0 +1,76 @@ +package org.scalasteward.core.nurture + +import munit.CatsEffectSuite +import org.http4s.HttpApp +import org.http4s.dsl.Http4sDsl +import org.scalasteward.core.TestInstances._ +import org.scalasteward.core.TestSyntax._ +import org.scalasteward.core.data.{DependencyInfo, RepoData, UpdateData} +import org.scalasteward.core.edit.EditAttempt.UpdateEdit +import org.scalasteward.core.git.{Branch, Commit} +import org.scalasteward.core.mock.MockContext.context.nurtureAlg +import org.scalasteward.core.mock.{MockEff, MockState} +import org.scalasteward.core.repoconfig.RepoConfig +import org.scalasteward.core.vcs.data.{NewPullRequestData, Repo} + +class NurtureAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] { + test("preparePullRequest") { + val repo = Repo("scala-steward-org", "scala-steward") + val dependency = "org.typelevel".g % ("cats-effect", "cats-effect_2.13").a % "3.3.0" + val repoCache = dummyRepoCache.copy(dependencyInfos = + List(List(DependencyInfo(dependency, Nil)).withMavenCentral) + ) + val repoData = RepoData(repo, repoCache, RepoConfig.empty) + val fork = Repo("scala-steward", "scala-steward") + val update = (dependency %> "3.4.0").single + val baseBranch = Branch("main") + val updateBranch = Branch("update/cats-effect-3.4.0") + val updateData = UpdateData(repoData, fork, update, baseBranch, dummySha1, updateBranch) + val edits = List(UpdateEdit(update, Commit(dummySha1))) + val state = MockState.empty.copy(clientResponses = HttpApp { + case HEAD -> Root / "typelevel" / "cats-effect" => Ok() + case HEAD -> Root / "typelevel" / "cats-effect" / "releases" / "tag" / "v3.4.0" => Ok() + case HEAD -> Root / "typelevel" / "cats-effect" / "compare" / "v3.3.0...v3.4.0" => Ok() + case _ => NotFound() + }) + val obtained = nurtureAlg.preparePullRequest(updateData, edits).runA(state) + val expected = NewPullRequestData( + title = "Update cats-effect to 3.4.0", + body = + raw"""Updates [org.typelevel:cats-effect](https://github.com/typelevel/cats-effect) from 3.3.0 to 3.4.0. + |[GitHub Release Notes](https://github.com/typelevel/cats-effect/releases/tag/v3.4.0) - [Version Diff](https://github.com/typelevel/cats-effect/compare/v3.3.0...v3.4.0) + | + | + |I'll automatically update this PR to resolve conflicts as long as you don't change it yourself. + | + |If you'd like to skip this version, you can just close this PR. If you have any feedback, just mention me in the comments below. + | + |Configure Scala Steward for your repository with a [`.scala-steward.conf`](https://github.com/scala-steward-org/scala-steward/blob/${org.scalasteward.core.BuildInfo.gitHeadCommit}/docs/repo-specific-configuration.md) file. + | + |Have a fantastic day writing Scala! + | + |
+ |Adjust future updates + | + |Add this to your `.scala-steward.conf` file to ignore future updates of this dependency: + |``` + |updates.ignore = [ { groupId = "org.typelevel", artifactId = "cats-effect" } ] + |``` + |Or, add this to slow down future updates of this dependency: + |``` + |dependencyOverrides = [{ + | pullRequests = { frequency = "@monthly" }, + | dependency = { groupId = "org.typelevel", artifactId = "cats-effect" } + |}] + |``` + |
+ | + |labels: library-update, early-semver-minor, semver-spec-minor, commit-count:1 + |""".stripMargin.trim, + head = "scala-steward:update/cats-effect-3.4.0", + base = baseBranch, + labels = List("library-update", "early-semver-minor", "semver-spec-minor", "commit-count:1") + ) + assertIO(obtained, expected) + } +} diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSExtraAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSExtraAlgTest.scala index 280bb56024..82b1bb5e06 100644 --- a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSExtraAlgTest.scala +++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSExtraAlgTest.scala @@ -5,16 +5,18 @@ import org.http4s.HttpApp import org.http4s.dsl.Http4sDsl import org.http4s.implicits._ import org.scalasteward.core.application.Config.VCSCfg -import org.scalasteward.core.data.ReleaseRelatedUrl._ +import org.scalasteward.core.data.ReleaseRelatedUrl.{CustomReleaseNotes, VersionDiff} import org.scalasteward.core.data.Version import org.scalasteward.core.mock.MockContext.context._ import org.scalasteward.core.mock.{MockEff, MockState} class VCSExtraAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] { private val state = MockState.empty.copy(clientResponses = HttpApp { - case HEAD -> Root / "foo" / "bar" / "compare" / "v0.1.0...v0.2.0" => Ok("exist") - case HEAD -> Root / "foo" / "buz" / "compare" / "v0.1.0...v0.2.0" => PermanentRedirect() - case _ => NotFound() + case HEAD -> Root / "foo" / "bar" / "README.md" => Ok() + case HEAD -> Root / "foo" / "bar" / "compare" / "v0.1.0...v0.2.0" => Ok() + case HEAD -> Root / "foo" / "bar1" / "blob" / "master" / "RELEASES.md" => Ok() + case HEAD -> Root / "foo" / "buz" / "compare" / "v0.1.0...v0.2.0" => PermanentRedirect() + case _ => NotFound() }) private val v1 = Version("0.1.0") @@ -22,20 +24,41 @@ class VCSExtraAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] { test("getReleaseRelatedUrls: repoUrl not found") { val obtained = - vcsExtraAlg.getReleaseRelatedUrls(uri"https://github.com/foo/foo", v1, v2).runA(state) + vcsExtraAlg.getReleaseRelatedUrls(uri"https://github.com/foo/foo", None, v1, v2).runA(state) assertIO(obtained, List.empty) } test("getReleaseRelatedUrls: repoUrl ok") { val obtained = - vcsExtraAlg.getReleaseRelatedUrls(uri"https://github.com/foo/bar", v1, v2).runA(state) + vcsExtraAlg.getReleaseRelatedUrls(uri"https://github.com/foo/bar", None, v1, v2).runA(state) val expected = List(VersionDiff(uri"https://github.com/foo/bar/compare/v0.1.0...v0.2.0")) assertIO(obtained, expected) } + test("getReleaseRelatedUrls: repoUrl and releaseNotesUrl ok") { + val releaseNotesUrl = uri"https://github.com/foo/bar/README.md#changelog" + val obtained = vcsExtraAlg + .getReleaseRelatedUrls(uri"https://github.com/foo/bar", Some(releaseNotesUrl), v1, v2) + .runA(state) + val expected = List( + CustomReleaseNotes(releaseNotesUrl), + VersionDiff(uri"https://github.com/foo/bar/compare/v0.1.0...v0.2.0") + ) + assertIO(obtained, expected) + } + + test("getReleaseRelatedUrls: releaseNotesUrl is in possibleReleaseRelatedUrls") { + val releaseNotesUrl = uri"https://github.com/foo/bar1/blob/master/RELEASES.md" + val obtained = vcsExtraAlg + .getReleaseRelatedUrls(uri"https://github.com/foo/bar1", Some(releaseNotesUrl), v1, v2) + .runA(state) + val expected = List(CustomReleaseNotes(releaseNotesUrl)) + assertIO(obtained, expected) + } + test("getReleaseRelatedUrls: repoUrl permanent redirect") { val obtained = - vcsExtraAlg.getReleaseRelatedUrls(uri"https://github.com/foo/buz", v1, v2).runA(state) + vcsExtraAlg.getReleaseRelatedUrls(uri"https://github.com/foo/buz", None, v1, v2).runA(state) assertIO(obtained, List.empty) } @@ -50,14 +73,14 @@ class VCSExtraAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] { test("getReleaseRelatedUrls: on-prem, repoUrl not found") { val obtained = githubOnPremVcsExtraAlg - .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/foo", v1, v2) + .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/foo", None, v1, v2) .runA(state) assertIO(obtained, List.empty) } test("getReleaseRelatedUrls: on-prem, repoUrl ok") { val obtained = githubOnPremVcsExtraAlg - .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/bar", v1, v2) + .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/bar", None, v1, v2) .runA(state) val expected = List(VersionDiff(uri"https://github.on-prem.com/foo/bar/compare/v0.1.0...v0.2.0")) @@ -66,7 +89,7 @@ class VCSExtraAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] { test("getReleaseRelatedUrls: on-prem, repoUrl permanent redirect") { val obtained = githubOnPremVcsExtraAlg - .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/buz", v1, v2) + .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/buz", None, v1, v2) .runA(state) assertIO(obtained, List.empty) }