Repository: scala-steward-org/scala-steward
Pull request #2891 (2023-01-06 16:57:17)

Rename VCSExtraAlg to UpdateInfoUrlFinder
This is a purely stylistic change. No behaviour has been changed.

It
 - renames `VCSExtraAlg` to `UpdateInfoUrlFinder`
 - renames `ReleaseRelatedUrl` to `UpdateInfoUrl`
 - moves `UpdateInfoUrlFinder` and `UpdateInfoUrl` to the nurture package since it is mainly used there
 - moves the functions from the `vcs` package object that created `ReleaseRelatedUrl`s into the `UpdateUrlFinder` companion since these functions are more related to the `UpdateUrlFinder` than to the different VCS integrations. For example, it would be perfectly fine to have a VCS intgeration without updating the `UpdateInfoUrlFinder` or to update it for VCSs for which we don't have integrations.

I hope the new names and the reorganization makes it clearer what `UpdateInfoUrlFinder` is about.

codecov[bot]: # [Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) Report
Base: **88.95**% // Head: **88.87**% // Decreases project coverage by **`-0.08%`** :warning:
> Coverage data is based on head [(`54f12b6`)](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) compared to base [(`5bb5b72`)](https://codecov.io/gh/scala-steward-org/scala-steward/commit/5bb5b72cf00bd4197700f4f99706db065e311a34?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).
> Patch coverage: 90.38% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2891      +/-   ##
==========================================
- Coverage   88.95%   88.87%   -0.09%     
==========================================
  Files         157      157              
  Lines        3024     3019       -5     
  Branches      211      211              
==========================================
- Hits         2690     2683       -7     
- Misses        334      336       +2     
```


| [Impacted Files](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) | Coverage Δ | |
|---|---|---|
| [...main/scala/org/scalasteward/core/vcs/package.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvcGFja2FnZS5zY2FsYQ==) | `100.00% <ø> (+13.63%)` | :arrow_up: |
| [...calasteward/core/nurture/UpdateInfoUrlFinder.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9udXJ0dXJlL1VwZGF0ZUluZm9VcmxGaW5kZXIuc2NhbGE=) | `88.09% <88.09%> (ø)` | |
| [...la/org/scalasteward/core/application/Context.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9hcHBsaWNhdGlvbi9Db250ZXh0LnNjYWxh) | `68.42% <100.00%> (ø)` | |
| [...ala/org/scalasteward/core/nurture/NurtureAlg.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9udXJ0dXJlL051cnR1cmVBbGcuc2NhbGE=) | `17.00% <100.00%> (ø)` | |
| [...calasteward/core/vcs/data/NewPullRequestData.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvZGF0YS9OZXdQdWxsUmVxdWVzdERhdGEuc2NhbGE=) | `100.00% <100.00%> (ø)` | |
| [...scalasteward/core/client/ClientConfiguration.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9jbGllbnQvQ2xpZW50Q29uZmlndXJhdGlvbi5zY2FsYQ==) | `81.25% <0.00%> (-18.75%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2891?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).


Patch:
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 6bec48fb..5e397faa 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
@@ -17,14 +17,15 @@
 package org.scalasteward.core.application
 
 import better.files.File
+import cats.MonadThrow
 import cats.effect._
 import cats.effect.implicits._
-import cats.MonadThrow
 import cats.syntax.all._
 import eu.timepit.refined.auto._
 import org.http4s.Uri
 import org.http4s.client.Client
 import org.http4s.headers.`User-Agent`
+import org.scalasteward.core.application.Config.StewardUsage
 import org.scalasteward.core.buildtool.BuildToolDispatcher
 import org.scalasteward.core.buildtool.maven.MavenAlg
 import org.scalasteward.core.buildtool.mill.MillAlg
@@ -34,12 +35,13 @@ import org.scalasteward.core.coursier.{CoursierAlg, VersionsCache}
 import org.scalasteward.core.edit.EditAlg
 import org.scalasteward.core.edit.hooks.HookExecutor
 import org.scalasteward.core.edit.scalafix._
+import org.scalasteward.core.edit.update.ScannerAlg
 import org.scalasteward.core.git.{GenGitAlg, GitAlg}
 import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
-import org.scalasteward.core.nurture.{NurtureAlg, PullRequestRepository}
+import org.scalasteward.core.nurture.{NurtureAlg, PullRequestRepository, UpdateInfoUrlFinder}
 import org.scalasteward.core.persistence.{CachingKeyValueStore, JsonKeyValueStore}
 import org.scalasteward.core.repocache._
-import org.scalasteward.core.repoconfig.{RepoConfigAlg, RepoConfigLoader}
+import org.scalasteward.core.repoconfig.{RepoConfigAlg, RepoConfigLoader, ValidateRepoConfigAlg}
 import org.scalasteward.core.scalafmt.ScalafmtAlg
 import org.scalasteward.core.update.artifact.{ArtifactMigrationsFinder, ArtifactMigrationsLoader}
 import org.scalasteward.core.update.{FilterAlg, PruningAlg, UpdateAlg}
@@ -47,12 +49,9 @@ import org.scalasteward.core.util._
 import org.scalasteward.core.util.uri._
 import org.scalasteward.core.vcs.data.Repo
 import org.scalasteward.core.vcs.github.{GitHubAppApiAlg, GitHubAuthAlg}
-import org.scalasteward.core.vcs.{VCSApiAlg, VCSExtraAlg, VCSRepoAlg, VCSSelection}
+import org.scalasteward.core.vcs.{VCSApiAlg, VCSRepoAlg, VCSSelection}
 import org.typelevel.log4cats.Logger
 import org.typelevel.log4cats.slf4j.Slf4jLogger
-import org.scalasteward.core.application.Config.StewardUsage
-import org.scalasteward.core.edit.update.ScannerAlg
-import org.scalasteward.core.repoconfig.ValidateRepoConfigAlg
 
 final class Context[F[_]](implicit
     val buildToolDispatcher: BuildToolDispatcher[F],
@@ -79,8 +78,8 @@ final class Context[F[_]](implicit
     val scalafmtAlg: ScalafmtAlg[F],
     val stewardAlg: StewardAlg[F],
     val updateAlg: UpdateAlg[F],
+    val updateInfoUrlFinder: UpdateInfoUrlFinder[F],
     val urlChecker: UrlChecker[F],
-    val vcsExtraAlg: VCSExtraAlg[F],
     val vcsRepoAlg: VCSRepoAlg[F],
     val workspaceAlg: WorkspaceAlg[F]
 )
@@ -207,7 +206,8 @@ object Context {
         new RepoCacheRepository[F](repoCacheStore)
       implicit val vcsApiAlg: VCSApiAlg[F] = new VCSSelection[F](config, vcsUser).vcsApiAlg
       implicit val vcsRepoAlg: VCSRepoAlg[F] = new VCSRepoAlg[F](config)
-      implicit val vcsExtraAlg: VCSExtraAlg[F] = VCSExtraAlg.create[F](config.vcsCfg)
+      implicit val updateInfoUrlFinder: UpdateInfoUrlFinder[F] =
+        new UpdateInfoUrlFinder[F](config.vcsCfg)
       implicit val pullRequestRepository: PullRequestRepository[F] =
         new PullRequestRepository[F](pullRequestsStore)
       implicit val scalafixCli: ScalafixCli[F] = new ScalafixCli[F]
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 b3543954..1145cba4 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,9 +16,9 @@
 
 package org.scalasteward.core.nurture
 
-import cats.{Applicative, Id}
 import cats.effect.Concurrent
 import cats.implicits._
+import cats.{Applicative, Id}
 import org.scalasteward.core.application.Config.VCSCfg
 import org.scalasteward.core.coursier.CoursierAlg
 import org.scalasteward.core.data.ProcessResult.{Created, Ignored, Updated}
@@ -29,7 +29,7 @@ import org.scalasteward.core.repoconfig.PullRequestUpdateStrategy
 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.vcs.{VCSApiAlg, VCSRepoAlg}
 import org.scalasteward.core.{git, util, vcs}
 import org.typelevel.log4cats.Logger
 
@@ -39,10 +39,10 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
     gitAlg: GitAlg[F],
     logger: Logger[F],
     pullRequestRepository: PullRequestRepository[F],
+    updateInfoUrlFinder: UpdateInfoUrlFinder[F],
+    urlChecker: UrlChecker[F],
     vcsApiAlg: VCSApiAlg[F],
-    vcsExtraAlg: VCSExtraAlg[F],
     vcsRepoAlg: VCSRepoAlg[F],
-    urlChecker: UrlChecker[F],
     F: Concurrent[F]
 ) {
   def nurture(data: RepoData, fork: RepoOut, updates: Nel[Update.ForArtifactId]): F[Unit] =
@@ -212,11 +212,11 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       artifactIdToUrl = dependencyToMetadata.toList.mapFilter { case (dependency, metadata) =>
         metadata.repoUrl.tupleLeft(dependency.artifactId.name)
       }.toMap
-      releaseRelatedUrls <- dependenciesWithNextVersion.flatTraverse {
+      artifactIdToUpdateInfoUrls <- dependenciesWithNextVersion.flatTraverse {
         case (currentVersion, dependency) =>
           dependencyToMetadata.get(dependency).toList.traverse { metadata =>
-            vcsExtraAlg
-              .getReleaseRelatedUrls(metadata, currentVersion, dependency.version)
+            updateInfoUrlFinder
+              .findUpdateInfoUrls(metadata, currentVersion, dependency.version)
               .tupleLeft(dependency.artifactId.name)
           }
       }
@@ -231,7 +231,7 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
         branchName,
         edits,
         artifactIdToUrl,
-        releaseRelatedUrls.toMap,
+        artifactIdToUpdateInfoUrls.toMap,
         filesWithOldVersion,
         data.repoData.config.pullRequests.includeMatchedLabels
       )
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/ReleaseRelatedUrl.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/UpdateInfoUrl.scala
similarity index 60%
rename from modules/core/src/main/scala/org/scalasteward/core/data/ReleaseRelatedUrl.scala
rename to modules/core/src/main/scala/org/scalasteward/core/nurture/UpdateInfoUrl.scala
index 825429a6..1be68855 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/ReleaseRelatedUrl.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/UpdateInfoUrl.scala
@@ -14,17 +14,18 @@
  * limitations under the License.
  */
 
-package org.scalasteward.core.data
+package org.scalasteward.core.nurture
 
 import org.http4s.Uri
 
-sealed trait ReleaseRelatedUrl {
+/** A URL of a resource that provides additional information for an update. */
+sealed trait UpdateInfoUrl {
   def url: Uri
 }
 
-object ReleaseRelatedUrl {
-  final case class CustomChangelog(url: Uri) extends ReleaseRelatedUrl
-  final case class CustomReleaseNotes(url: Uri) extends ReleaseRelatedUrl
-  final case class GitHubReleaseNotes(url: Uri) extends ReleaseRelatedUrl
-  final case class VersionDiff(url: Uri) extends ReleaseRelatedUrl
+object UpdateInfoUrl {
+  final case class CustomChangelog(url: Uri) extends UpdateInfoUrl
+  final case class CustomReleaseNotes(url: Uri) extends UpdateInfoUrl
+  final case class GitHubReleaseNotes(url: Uri) extends UpdateInfoUrl
+  final case class VersionDiff(url: Uri) extends UpdateInfoUrl
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/nurture/UpdateInfoUrlFinder.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/UpdateInfoUrlFinder.scala
new file mode 100644
index 00000000..2db45b6c
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/UpdateInfoUrlFinder.scala
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2018-2023 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.nurture
+
+import cats.Monad
+import cats.syntax.all._
+import org.http4s.Uri
+import org.scalasteward.core.application.Config.VCSCfg
+import org.scalasteward.core.coursier.DependencyMetadata
+import org.scalasteward.core.data.Version
+import org.scalasteward.core.nurture.UpdateInfoUrl._
+import org.scalasteward.core.nurture.UpdateInfoUrlFinder.possibleUpdateInfoUrls
+import org.scalasteward.core.util.UrlChecker
+import org.scalasteward.core.vcs.VCSType
+import org.scalasteward.core.vcs.VCSType._
+
+final class UpdateInfoUrlFinder[F[_]](config: VCSCfg)(implicit
+    urlChecker: UrlChecker[F],
+    F: Monad[F]
+) {
+  def findUpdateInfoUrls(
+      metadata: DependencyMetadata,
+      currentVersion: Version,
+      nextVersion: Version
+  ): F[List[UpdateInfoUrl]] = {
+    val updateInfoUrls =
+      metadata.releaseNotesUrl.toList.map(CustomReleaseNotes.apply) ++
+        metadata.repoUrl.toList.flatMap { repoUrl =>
+          possibleUpdateInfoUrls(config.tpe, config.apiHost, repoUrl, currentVersion, nextVersion)
+        }
+
+    updateInfoUrls.distinctBy(_.url).filterA(updateInfoUrl => urlChecker.exists(updateInfoUrl.url))
+  }
+}
+
+object UpdateInfoUrlFinder {
+  private def possibleTags(version: Version): List[String] =
+    List(s"v$version", version.value, s"release-$version")
+
+  private[nurture] val possibleChangelogFilenames: List[String] = {
+    val baseNames = List(
+      "CHANGELOG",
+      "Changelog",
+      "changelog",
+      "CHANGES"
+    )
+    possibleFilenames(baseNames)
+  }
+
+  private[nurture] val possibleReleaseNotesFilenames: List[String] = {
+    val baseNames = List(
+      "ReleaseNotes",
+      "RELEASES",
+      "Releases",
+      "releases"
+    )
+    possibleFilenames(baseNames)
+  }
+
+  private def extractRepoVCSType(vcsType: VCSType, vcsUri: Uri, repoUrl: Uri): Option[VCSType] =
+    repoUrl.host.flatMap { repoHost =>
+      if (vcsUri.host.contains(repoHost)) Some(vcsType)
+      else VCSType.fromPublicWebHost(repoHost.value)
+    }
+
+  private[nurture] def possibleVersionDiffs(
+      vcsType: VCSType,
+      vcsUri: Uri,
+      repoUrl: Uri,
+      currentVersion: Version,
+      nextVersion: Version
+  ): List[VersionDiff] =
+    extractRepoVCSType(vcsType, vcsUri, repoUrl).map {
+      case GitHub | GitLab =>
+        possibleTags(currentVersion).zip(possibleTags(nextVersion)).map { case (from1, to1) =>
+          VersionDiff(repoUrl / "compare" / s"$from1...$to1")
+        }
+      case Bitbucket | BitbucketServer =>
+        possibleTags(currentVersion).zip(possibleTags(nextVersion)).map { case (from1, to1) =>
+          VersionDiff((repoUrl / "compare" / s"$to1..$from1").withFragment("diff"))
+        }
+      case AzureRepos =>
+        possibleTags(currentVersion).zip(possibleTags(nextVersion)).map { case (from1, to1) =>
+          VersionDiff(
+            (repoUrl / "branchCompare")
+              .withQueryParams(Map("baseVersion" -> from1, "targetVersion" -> to1))
+          )
+        }
+    }.orEmpty
+
+  private[nurture] def possibleUpdateInfoUrls(
+      vcsType: VCSType,
+      vcsUrl: Uri,
+      repoUrl: Uri,
+      currentVersion: Version,
+      nextVersion: Version
+  ): List[UpdateInfoUrl] = {
+    val repoVCSType = extractRepoVCSType(vcsType, vcsUrl, repoUrl)
+
+    val githubReleaseNotes = repoVCSType
+      .collect { case GitHub =>
+        possibleTags(nextVersion).map(tag => GitHubReleaseNotes(repoUrl / "releases" / "tag" / tag))
+      }
+      .getOrElse(List.empty)
+
+    def files(fileNames: List[String]): List[Uri] = {
+      val maybeSegments = repoVCSType.collect {
+        case GitHub | GitLab => List("blob", "master")
+        case Bitbucket       => List("master")
+        case BitbucketServer => List("browse")
+      }
+
+      val repoFiles = maybeSegments.toList.flatMap { segments =>
+        val base = segments.foldLeft(repoUrl)(_ / _)
+        fileNames.map(name => base / name)
+      }
+
+      val azureRepoFiles = repoVCSType
+        .collect { case AzureRepos => fileNames.map(name => repoUrl.withQueryParam("path", name)) }
+        .toList
+        .flatten
+
+      repoFiles ++ azureRepoFiles
+    }
+
+    val customChangelog = files(possibleChangelogFilenames).map(CustomChangelog)
+    val customReleaseNotes = files(possibleReleaseNotesFilenames).map(CustomReleaseNotes)
+
+    githubReleaseNotes ++ customReleaseNotes ++ customChangelog ++
+      possibleVersionDiffs(vcsType, vcsUrl, repoUrl, currentVersion, nextVersion)
+  }
+
+  private def possibleFilenames(baseNames: List[String]): List[String] = {
+    val extensions = List("md", "markdown", "rst")
+    (baseNames, extensions).mapN { case (base, ext) => s"$base.$ext" }
+  }
+}
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
deleted file mode 100644
index 90daab11..00000000
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSExtraAlg.scala
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright 2018-2023 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.vcs
-
-import cats.Monad
-import cats.syntax.all._
-import org.scalasteward.core.application.Config.VCSCfg
-import org.scalasteward.core.coursier.DependencyMetadata
-import org.scalasteward.core.data.{ReleaseRelatedUrl, Version}
-import org.scalasteward.core.util.UrlChecker
-import org.scalasteward.core.vcs
-
-trait VCSExtraAlg[F[_]] {
-  def getReleaseRelatedUrls(
-      metadata: DependencyMetadata,
-      currentVersion: Version,
-      nextVersion: Version
-  ): F[List[ReleaseRelatedUrl]]
-}
-
-object VCSExtraAlg {
-  def create[F[_]](config: VCSCfg)(implicit
-      urlChecker: UrlChecker[F],
-      F: Monad[F]
-  ): VCSExtraAlg[F] =
-    new VCSExtraAlg[F] {
-      override def getReleaseRelatedUrls(
-          metadata: DependencyMetadata,
-          currentVersion: Version,
-          nextVersion: Version
-      ): F[List[ReleaseRelatedUrl]] = {
-        val releaseRelatedUrls =
-          metadata.releaseNotesUrl.toList.map(ReleaseRelatedUrl.CustomReleaseNotes.apply) ++
-            metadata.repoUrl.toList.flatMap(repoUrl =>
-              vcs.possibleReleaseRelatedUrls(
-                config.tpe,
-                config.apiHost,
-                repoUrl,
-                currentVersion,
-                nextVersion
-              )
-            )
-
-        releaseRelatedUrls
-          .distinctBy(_.url)
-          .filterA(releaseRelatedUrl => urlChecker.exists(releaseRelatedUrl.url))
-      }
-    }
-}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala
index 7ba8e4f7..ab687721 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala
@@ -24,9 +24,10 @@ import org.scalasteward.core.data._
 import org.scalasteward.core.edit.EditAttempt
 import org.scalasteward.core.edit.EditAttempt.ScalafixEdit
 import org.scalasteward.core.git.{Branch, CommitMsg}
+import org.scalasteward.core.nurture.UpdateInfoUrl
+import org.scalasteward.core.nurture.UpdateInfoUrl._
 import org.scalasteward.core.repoconfig.{GroupRepoConfig, RepoConfigAlg}
 import org.scalasteward.core.util.{Details, Nel}
-
 import scala.util.matching.Regex
 
 final case class NewPullRequestData(
@@ -46,7 +47,7 @@ object NewPullRequestData {
       update: Update,
       edits: List[EditAttempt],
       artifactIdToUrl: Map[String, Uri],
-      releaseRelatedUrls: Map[String, List[ReleaseRelatedUrl]],
+      artifactIdToUpdateInfoUrls: Map[String, List[UpdateInfoUrl]],
       filesWithOldVersion: List[String],
       configParsingError: Option[String],
       labels: List[String]
@@ -65,17 +66,17 @@ object NewPullRequestData {
       update = u => {
         val artifacts = artifactsWithOptionalUrl(u, artifactIdToUrl)
 
-        val relatedUrls = releaseRelatedUrls.get(u.mainArtifactId).getOrElse(Nil)
+        val updateInfoUrls = artifactIdToUpdateInfoUrls.getOrElse(u.mainArtifactId, Nil)
 
         s"""|Updates $artifacts ${fromTo(u)}.
-            |${releaseNote(relatedUrls).getOrElse("")}""".stripMargin.trim
+            |${renderUpdateInfoUrls(updateInfoUrls).getOrElse("")}""".stripMargin.trim
       },
       grouped = g => {
         val artifacts = g.updates
-          .fproduct(u => releaseRelatedUrls.get(u.mainArtifactId).orEmpty)
-          .map { case (u, relatedUrls) =>
+          .fproduct(u => artifactIdToUpdateInfoUrls.get(u.mainArtifactId).orEmpty)
+          .map { case (u, updateInfoUrls) =>
             s"* ${artifactsWithOptionalUrl(u, artifactIdToUrl)} ${fromTo(u)}" +
-              releaseNote(relatedUrls).map(urls => s"\n  + $urls").getOrElse("")
+              renderUpdateInfoUrls(updateInfoUrls).map(urls => s"\n  + $urls").getOrElse("")
           }
           .mkString_("\n", "\n", "\n")
 
@@ -106,18 +107,14 @@ object NewPullRequestData {
         |""".stripMargin.trim
   }
 
-  def releaseNote(releaseRelatedUrls: List[ReleaseRelatedUrl]): Option[String] =
-    Option.when(releaseRelatedUrls.nonEmpty) {
-      releaseRelatedUrls
+  def renderUpdateInfoUrls(updateInfoUrls: List[UpdateInfoUrl]): Option[String] =
+    Option.when(updateInfoUrls.nonEmpty) {
+      updateInfoUrls
         .map {
-          case ReleaseRelatedUrl.CustomChangelog(url) =>
-            s"[Changelog](${url.renderString})"
-          case ReleaseRelatedUrl.CustomReleaseNotes(url) =>
-            s"[Release Notes](${url.renderString})"
-          case ReleaseRelatedUrl.GitHubReleaseNotes(url) =>
-            s"[GitHub Release Notes](${url.renderString})"
-          case ReleaseRelatedUrl.VersionDiff(url) =>
-            s"[Version Diff](${url.renderString})"
+          case CustomChangelog(url)    => s"[Changelog](${url.renderString})"
+          case CustomReleaseNotes(url) => s"[Release Notes](${url.renderString})"
+          case GitHubReleaseNotes(url) => s"[GitHub Release Notes](${url.renderString})"
+          case VersionDiff(url)        => s"[Version Diff](${url.renderString})"
         }
         .mkString(" - ")
     }
@@ -226,7 +223,7 @@ object NewPullRequestData {
       branchName: String,
       edits: List[EditAttempt] = List.empty,
       artifactIdToUrl: Map[String, Uri] = Map.empty,
-      releaseRelatedUrls: Map[String, List[ReleaseRelatedUrl]] = Map.empty,
+      artifactIdToUpdateInfoUrls: Map[String, List[UpdateInfoUrl]] = Map.empty,
       filesWithOldVersion: List[String] = List.empty,
       includeMatchedLabels: Option[Regex] = None
   ): NewPullRequestData = {
@@ -242,7 +239,7 @@ object NewPullRequestData {
         data.update,
         edits,
         artifactIdToUrl,
-        releaseRelatedUrls,
+        artifactIdToUpdateInfoUrls,
         filesWithOldVersion,
         data.repoData.cache.maybeRepoConfigParsingError,
         labels
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/package.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/package.scala
index c41b2d91..c297daac 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/package.scala
@@ -16,12 +16,8 @@
 
 package org.scalasteward.core
 
-import cats.syntax.all._
-import org.http4s.Uri
-import org.scalasteward.core.data.ReleaseRelatedUrl.VersionDiff
-import org.scalasteward.core.data.{ReleaseRelatedUrl, Version}
 import org.scalasteward.core.git.Branch
-import org.scalasteward.core.vcs.VCSType.{AzureRepos, Bitbucket, BitbucketServer, GitHub, GitLab}
+import org.scalasteward.core.vcs.VCSType._
 import org.scalasteward.core.vcs.data.Repo
 
 package object vcs {
@@ -49,114 +45,4 @@ package object vcs {
       case GitLab | Bitbucket | BitbucketServer | AzureRepos =>
         updateBranch.name
     }
-
-  def possibleTags(version: Version): List[String] =
-    List(s"v$version", version.value, s"release-$version")
-
-  val possibleChangelogFilenames: List[String] = {
-    val baseNames = List(
-      "CHANGELOG",
-      "Changelog",
-      "changelog",
-      "CHANGES"
-    )
-    possibleFilenames(baseNames)
-  }
-
-  val possibleReleaseNotesFilenames: List[String] = {
-    val baseNames = List(
-      "ReleaseNotes",
-      "RELEASES",
-      "Releases",
-      "releases"
-    )
-    possibleFilenames(baseNames)
-  }
-
-  private[this] def extractRepoVCSType(
-      vcsType: VCSType,
-      vcsUri: Uri,
-      repoUrl: Uri
-  ): Option[VCSType] =
-    repoUrl.host.flatMap { repoHost =>
-      if (vcsUri.host.contains(repoHost)) Some(vcsType)
-      else VCSType.fromPublicWebHost(repoHost.value)
-    }
-
-  def possibleCompareUrls(
-      vcsType: VCSType,
-      vcsUri: Uri,
-      repoUrl: Uri,
-      currentVersion: Version,
-      nextVersion: Version
-  ): List[VersionDiff] =
-    extractRepoVCSType(vcsType, vcsUri, repoUrl).map {
-      case GitHub | GitLab =>
-        possibleTags(currentVersion).zip(possibleTags(nextVersion)).map { case (from1, to1) =>
-          VersionDiff(repoUrl / "compare" / s"$from1...$to1")
-        }
-      case Bitbucket | BitbucketServer =>
-        possibleTags(currentVersion).zip(possibleTags(nextVersion)).map { case (from1, to1) =>
-          VersionDiff((repoUrl / "compare" / s"$to1..$from1").withFragment("diff"))
-        }
-      case AzureRepos =>
-        possibleTags(currentVersion).zip(possibleTags(nextVersion)).map { case (from1, to1) =>
-          VersionDiff(
-            (repoUrl / "branchCompare")
-              .withQueryParams(Map("baseVersion" -> from1, "targetVersion" -> to1))
-          )
-        }
-    }.orEmpty
-
-  def possibleReleaseRelatedUrls(
-      vcsType: VCSType,
-      vcsUri: Uri,
-      repoUrl: Uri,
-      currentVersion: Version,
-      nextVersion: Version
-  ): List[ReleaseRelatedUrl] = {
-    val repoVCSType = extractRepoVCSType(vcsType, vcsUri, repoUrl)
-
-    val github = repoVCSType
-      .collect { case GitHub =>
-        possibleTags(nextVersion).map(tag =>
-          ReleaseRelatedUrl.GitHubReleaseNotes(repoUrl / "releases" / "tag" / tag)
-        )
-      }
-      .getOrElse(List.empty)
-
-    def files(fileNames: List[String]): List[Uri] = {
-      val maybeSegments = repoVCSType.collect {
-        case GitHub | GitLab => List("blob", "master")
-        case Bitbucket       => List("master")
-        case BitbucketServer => List("browse")
-      }
-
-      val repoFiles = maybeSegments.toList.flatMap { segments =>
-        val base = segments.foldLeft(repoUrl)(_ / _)
-        fileNames.map(name => base / name)
-      }
-
-      val azureRepoFiles = repoVCSType
-        .collect { case AzureRepos =>
-          fileNames.map(name => repoUrl.withQueryParam("path", name))
-        }
-        .toList
-        .flatten
-
-      repoFiles ++ azureRepoFiles
-    }
-
-    val customChangelog = files(possibleChangelogFilenames).map(ReleaseRelatedUrl.CustomChangelog)
-    val customReleaseNotes =
-      files(possibleReleaseNotesFilenames).map(ReleaseRelatedUrl.CustomReleaseNotes)
-
-    github ++ customReleaseNotes ++ customChangelog ++
-      possibleCompareUrls(vcsType, vcsUri, repoUrl, currentVersion, nextVersion)
-  }
-
-  private def possibleFilenames(baseNames: List[String]): List[String] = {
-    val extensions = List("md", "markdown", "rst")
-    (baseNames, extensions).mapN { case (base, ext) => s"$base.$ext" }
-  }
 }


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/nurture/UpdateInfoUrlFinderTest.scala b/modules/core/src/test/scala/org/scalasteward/core/nurture/UpdateInfoUrlFinderTest.scala
new file mode 100644
index 00000000..391fcf7b
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/nurture/UpdateInfoUrlFinderTest.scala
@@ -0,0 +1,295 @@
+package org.scalasteward.core.nurture
+
+import cats.syntax.all._
+import munit.CatsEffectSuite
+import org.http4s.HttpApp
+import org.http4s.dsl.Http4sDsl
+import org.http4s.implicits._
+import org.scalasteward.core.application.Config.VCSCfg
+import org.scalasteward.core.coursier.DependencyMetadata
+import org.scalasteward.core.data.Version
+import org.scalasteward.core.mock.MockContext.context._
+import org.scalasteward.core.mock.{MockEff, MockState}
+import org.scalasteward.core.nurture.UpdateInfoUrl._
+import org.scalasteward.core.nurture.UpdateInfoUrlFinder._
+import org.scalasteward.core.vcs.VCSType
+import org.scalasteward.core.vcs.VCSType._
+
+class UpdateInfoUrlFinderTest extends CatsEffectSuite with Http4sDsl[MockEff] {
+  private val state = MockState.empty.copy(clientResponses = HttpApp {
+    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")
+  private val v2 = Version("0.2.0")
+
+  test("findUpdateInfoUrls: repoUrl not found") {
+    val metadata = DependencyMetadata.empty.copy(scmUrl = uri"https://github.com/foo/foo".some)
+    val obtained = updateInfoUrlFinder.findUpdateInfoUrls(metadata, v1, v2).runA(state)
+    assertIO(obtained, List.empty)
+  }
+
+  test("findUpdateInfoUrls: repoUrl ok") {
+    val metadata = DependencyMetadata.empty.copy(scmUrl = uri"https://github.com/foo/bar".some)
+    val obtained = updateInfoUrlFinder.findUpdateInfoUrls(metadata, 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("findUpdateInfoUrls: repoUrl and releaseNotesUrl ok") {
+    val metadata = DependencyMetadata.empty.copy(
+      scmUrl = uri"https://github.com/foo/bar".some,
+      releaseNotesUrl = uri"https://github.com/foo/bar/README.md#changelog".some
+    )
+    val obtained = updateInfoUrlFinder.findUpdateInfoUrls(metadata, v1, v2).runA(state)
+    val expected = List(
+      CustomReleaseNotes(metadata.releaseNotesUrl.get),
+      VersionDiff(uri"https://github.com/foo/bar/compare/v0.1.0...v0.2.0")
+    )
+    assertIO(obtained, expected)
+  }
+
+  test("findUpdateInfoUrls: releaseNotesUrl is in possibleReleaseRelatedUrls") {
+    val metadata = DependencyMetadata.empty.copy(
+      scmUrl = uri"https://github.com/foo/bar1".some,
+      releaseNotesUrl = uri"https://github.com/foo/bar1/blob/master/RELEASES.md".some
+    )
+    val obtained = updateInfoUrlFinder.findUpdateInfoUrls(metadata, v1, v2).runA(state)
+    val expected = List(CustomReleaseNotes(metadata.releaseNotesUrl.get))
+    assertIO(obtained, expected)
+  }
+
+  test("findUpdateInfoUrls: repoUrl permanent redirect") {
+    val metadata = DependencyMetadata.empty.copy(scmUrl = uri"https://github.com/foo/buz".some)
+    val obtained = updateInfoUrlFinder.findUpdateInfoUrls(metadata, v1, v2).runA(state)
+    assertIO(obtained, List.empty)
+  }
+
+  private val config = VCSCfg(
+    VCSType.GitHub,
+    uri"https://github.on-prem.com/",
+    "",
+    doNotFork = false,
+    addLabels = false
+  )
+  private val githubOnPremVcsExtraAlg = new UpdateInfoUrlFinder[MockEff](config)
+
+  test("findUpdateInfoUrls: on-prem, repoUrl not found") {
+    val metadata =
+      DependencyMetadata.empty.copy(scmUrl = uri"https://github.on-prem.com/foo/foo".some)
+    val obtained = githubOnPremVcsExtraAlg.findUpdateInfoUrls(metadata, v1, v2).runA(state)
+    assertIO(obtained, List.empty)
+  }
+
+  test("findUpdateInfoUrls: on-prem, repoUrl ok") {
+    val metadata =
+      DependencyMetadata.empty.copy(scmUrl = uri"https://github.on-prem.com/foo/bar".some)
+    val obtained = githubOnPremVcsExtraAlg.findUpdateInfoUrls(metadata, v1, v2).runA(state)
+    val expected =
+      List(VersionDiff(uri"https://github.on-prem.com/foo/bar/compare/v0.1.0...v0.2.0"))
+    assertIO(obtained, expected)
+  }
+
+  test("findUpdateInfoUrls: on-prem, repoUrl permanent redirect") {
+    val metadata =
+      DependencyMetadata.empty.copy(scmUrl = uri"https://github.on-prem.com/foo/buz".some)
+    val obtained = githubOnPremVcsExtraAlg.findUpdateInfoUrls(metadata, v1, v2).runA(state)
+    assertIO(obtained, List.empty)
+  }
+
+  test("possibleVersionDiffs") {
+    val onPremVCS = "https://github.onprem.io/"
+    val onPremVCSUri = uri"https://github.onprem.io/"
+
+    assertEquals(
+      possibleVersionDiffs(GitHub, onPremVCSUri, uri"https://github.com/foo/bar", v1, v2)
+        .map(_.url.renderString),
+      List(
+        s"https://github.com/foo/bar/compare/v$v1...v$v2",
+        s"https://github.com/foo/bar/compare/$v1...$v2",
+        s"https://github.com/foo/bar/compare/release-$v1...release-$v2"
+      )
+    )
+
+    // should canonicalize (drop last slash)
+    assertEquals(
+      possibleVersionDiffs(GitHub, onPremVCSUri, uri"https://github.com/foo/bar/", v1, v2)
+        .map(_.url.renderString),
+      List(
+        s"https://github.com/foo/bar/compare/v$v1...v$v2",
+        s"https://github.com/foo/bar/compare/$v1...$v2",
+        s"https://github.com/foo/bar/compare/release-$v1...release-$v2"
+      )
+    )
+
+    assertEquals(
+      possibleVersionDiffs(GitHub, onPremVCSUri, uri"https://gitlab.com/foo/bar", v1, v2)
+        .map(_.url.renderString),
+      List(
+        s"https://gitlab.com/foo/bar/compare/v$v1...v$v2",
+        s"https://gitlab.com/foo/bar/compare/$v1...$v2",
+        s"https://gitlab.com/foo/bar/compare/release-$v1...release-$v2"
+      )
+    )
+
+    assertEquals(
+      possibleVersionDiffs(GitHub, onPremVCSUri, uri"https://bitbucket.org/foo/bar", v1, v2)
+        .map(_.url.renderString),
+      List(
+        s"https://bitbucket.org/foo/bar/compare/v$v2..v$v1#diff",
+        s"https://bitbucket.org/foo/bar/compare/$v2..$v1#diff",
+        s"https://bitbucket.org/foo/bar/compare/release-$v2..release-$v1#diff"
+      )
+    )
+
+    assertEquals(
+      possibleVersionDiffs(
+        GitHub,
+        onPremVCSUri,
+        uri"https://scalacenter.github.io/scalafix/",
+        v1,
+        v2
+      ),
+      List.empty
+    )
+
+    assertEquals(
+      possibleVersionDiffs(GitHub, onPremVCSUri, onPremVCSUri.addPath("foo/bar"), v1, v2)
+        .map(_.url.renderString),
+      List(
+        s"${onPremVCS}foo/bar/compare/v$v1...v$v2",
+        s"${onPremVCS}foo/bar/compare/$v1...$v2",
+        s"${onPremVCS}foo/bar/compare/release-$v1...release-$v2"
+      )
+    )
+
+    assertEquals(
+      possibleVersionDiffs(AzureRepos, onPremVCSUri, onPremVCSUri.addPath("foo/bar"), v1, v2)
+        .map(_.url.renderString),
+      List(
+        s"${onPremVCS}foo/bar/branchCompare?baseVersion=v$v1&targetVersion=v$v2",
+        s"${onPremVCS}foo/bar/branchCompare?baseVersion=$v1&targetVersion=$v2",
+        s"${onPremVCS}foo/bar/branchCompare?baseVersion=release-$v1&targetVersion=release-$v2"
+      )
+    )
+  }
+
+  test("possibleUpdateInfoUrls: github.com") {
+    val obtained = possibleUpdateInfoUrls(
+      GitHub,
+      uri"https://github.com",
+      uri"https://github.com/foo/bar",
+      v1,
+      v2
+    ).map(_.url.renderString)
+    val expected = List(
+      s"https://github.com/foo/bar/releases/tag/v$v2",
+      s"https://github.com/foo/bar/releases/tag/$v2",
+      s"https://github.com/foo/bar/releases/tag/release-$v2",
+      "https://github.com/foo/bar/blob/master/ReleaseNotes.md",
+      "https://github.com/foo/bar/blob/master/ReleaseNotes.markdown",
+      "https://github.com/foo/bar/blob/master/ReleaseNotes.rst",
+      "https://github.com/foo/bar/blob/master/RELEASES.md",
+      "https://github.com/foo/bar/blob/master/RELEASES.markdown",
+      "https://github.com/foo/bar/blob/master/RELEASES.rst",
+      "https://github.com/foo/bar/blob/master/Releases.md",
+      "https://github.com/foo/bar/blob/master/Releases.markdown",
+      "https://github.com/foo/bar/blob/master/Releases.rst",
+      "https://github.com/foo/bar/blob/master/releases.md",
+      "https://github.com/foo/bar/blob/master/releases.markdown",
+      "https://github.com/foo/bar/blob/master/releases.rst",
+      "https://github.com/foo/bar/blob/master/CHANGELOG.md",
+      "https://github.com/foo/bar/blob/master/CHANGELOG.markdown",
+      "https://github.com/foo/bar/blob/master/CHANGELOG.rst",
+      "https://github.com/foo/bar/blob/master/Changelog.md",
+      "https://github.com/foo/bar/blob/master/Changelog.markdown",
+      "https://github.com/foo/bar/blob/master/Changelog.rst",
+      "https://github.com/foo/bar/blob/master/changelog.md",
+      "https://github.com/foo/bar/blob/master/changelog.markdown",
+      "https://github.com/foo/bar/blob/master/changelog.rst",
+      "https://github.com/foo/bar/blob/master/CHANGES.md",
+      "https://github.com/foo/bar/blob/master/CHANGES.markdown",
+      "https://github.com/foo/bar/blob/master/CHANGES.rst",
+      s"https://github.com/foo/bar/compare/v$v1...v$v2",
+      s"https://github.com/foo/bar/compare/$v1...$v2",
+      s"https://github.com/foo/bar/compare/release-$v1...release-$v2"
+    )
+    assertEquals(obtained, expected)
+  }
+
+  test("possibleUpdateInfoUrls: gitlab.com") {
+    val obtained = possibleUpdateInfoUrls(
+      GitHub,
+      uri"https://github.com",
+      uri"https://gitlab.com/foo/bar",
+      v1,
+      v2
+    ).map(_.url.renderString)
+    val expected =
+      possibleReleaseNotesFilenames.map(name => s"https://gitlab.com/foo/bar/blob/master/$name") ++
+        possibleChangelogFilenames.map(name => s"https://gitlab.com/foo/bar/blob/master/$name") ++
+        List(
+          s"https://gitlab.com/foo/bar/compare/v$v1...v$v2",
+          s"https://gitlab.com/foo/bar/compare/$v1...$v2",
+          s"https://gitlab.com/foo/bar/compare/release-$v1...release-$v2"
+        )
+    assertEquals(obtained, expected)
+  }
+
+  test("possibleUpdateInfoUrls: on-prem gitlab") {
+    val obtained = possibleUpdateInfoUrls(
+      GitLab,
+      uri"https://gitlab.on-prem.net",
+      uri"https://gitlab.on-prem.net/foo/bar",
+      v1,
+      v2
+    ).map(_.url.renderString)
+    val expected = possibleReleaseNotesFilenames.map(name =>
+      s"https://gitlab.on-prem.net/foo/bar/blob/master/$name"
+    ) ++ possibleChangelogFilenames.map(name =>
+      s"https://gitlab.on-prem.net/foo/bar/blob/master/$name"
+    ) ++ List(
+      s"https://gitlab.on-prem.net/foo/bar/compare/v$v1...v$v2",
+      s"https://gitlab.on-prem.net/foo/bar/compare/$v1...$v2",
+      s"https://gitlab.on-prem.net/foo/bar/compare/release-$v1...release-$v2"
+    )
+    assertEquals(obtained, expected)
+  }
+
+  test("possibleUpdateInfoUrls: bitbucket.org") {
+    val obtained = possibleUpdateInfoUrls(
+      GitHub,
+      uri"https://github.com",
+      uri"https://bitbucket.org/foo/bar",
+      v1,
+      v2
+    ).map(_.url.renderString)
+    val expected =
+      possibleReleaseNotesFilenames.map(name => s"https://bitbucket.org/foo/bar/master/$name") ++
+        possibleChangelogFilenames.map(name => s"https://bitbucket.org/foo/bar/master/$name") ++
+        List(
+          s"https://bitbucket.org/foo/bar/compare/v$v2..v$v1#diff",
+          s"https://bitbucket.org/foo/bar/compare/$v2..$v1#diff",
+          s"https://bitbucket.org/foo/bar/compare/release-$v2..release-$v1#diff"
+        )
+    assertEquals(obtained, expected)
+  }
+
+  test("possibleUpdateInfoUrls: on-prem Bitbucket Server") {
+    val vcsUrl = uri"https://bitbucket-server.on-prem.com"
+    val repoUrl = vcsUrl / "foo" / "bar"
+    val obtained = possibleUpdateInfoUrls(BitbucketServer, vcsUrl, repoUrl, v1, v2).map(_.url)
+    val expected = repoUrl / "browse" / "ReleaseNotes.md"
+    assert(clue(obtained).contains(expected))
+  }
+
+  test("possibleUpdateInfoUrls: repoUrl is a home page") {
+    val repoUrl = uri"https://scalacenter.github.io/scalafix/"
+    val obtained = possibleUpdateInfoUrls(GitHub, uri"https://github.com", repoUrl, v1, v2)
+    assertEquals(obtained, List.empty)
+  }
+}
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
deleted file mode 100644
index d49daacf..00000000
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSExtraAlgTest.scala
+++ /dev/null
@@ -1,100 +0,0 @@
-package org.scalasteward.core.vcs
-
-import cats.syntax.all._
-import munit.CatsEffectSuite
-import org.http4s.HttpApp
-import org.http4s.dsl.Http4sDsl
-import org.http4s.implicits._
-import org.scalasteward.core.application.Config.VCSCfg
-import org.scalasteward.core.coursier.DependencyMetadata
-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" / "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")
-  private val v2 = Version("0.2.0")
-
-  test("getReleaseRelatedUrls: repoUrl not found") {
-    val metadata = DependencyMetadata.empty.copy(scmUrl = uri"https://github.com/foo/foo".some)
-    val obtained = vcsExtraAlg.getReleaseRelatedUrls(metadata, v1, v2).runA(state)
-    assertIO(obtained, List.empty)
-  }
-
-  test("getReleaseRelatedUrls: repoUrl ok") {
-    val metadata = DependencyMetadata.empty.copy(scmUrl = uri"https://github.com/foo/bar".some)
-    val obtained = vcsExtraAlg.getReleaseRelatedUrls(metadata, 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 metadata = DependencyMetadata.empty.copy(
-      scmUrl = uri"https://github.com/foo/bar".some,
-      releaseNotesUrl = uri"https://github.com/foo/bar/README.md#changelog".some
-    )
-    val obtained = vcsExtraAlg.getReleaseRelatedUrls(metadata, v1, v2).runA(state)
-    val expected = List(
-      CustomReleaseNotes(metadata.releaseNotesUrl.get),
-      VersionDiff(uri"https://github.com/foo/bar/compare/v0.1.0...v0.2.0")
-    )
-    assertIO(obtained, expected)
-  }
-
-  test("getReleaseRelatedUrls: releaseNotesUrl is in possibleReleaseRelatedUrls") {
-    val metadata = DependencyMetadata.empty.copy(
-      scmUrl = uri"https://github.com/foo/bar1".some,
-      releaseNotesUrl = uri"https://github.com/foo/bar1/blob/master/RELEASES.md".some
-    )
-    val obtained = vcsExtraAlg.getReleaseRelatedUrls(metadata, v1, v2).runA(state)
-    val expected = List(CustomReleaseNotes(metadata.releaseNotesUrl.get))
-    assertIO(obtained, expected)
-  }
-
-  test("getReleaseRelatedUrls: repoUrl permanent redirect") {
-    val metadata = DependencyMetadata.empty.copy(scmUrl = uri"https://github.com/foo/buz".some)
-    val obtained = vcsExtraAlg.getReleaseRelatedUrls(metadata, v1, v2).runA(state)
-    assertIO(obtained, List.empty)
-  }
-
-  private val config = VCSCfg(
-    VCSType.GitHub,
-    uri"https://github.on-prem.com/",
-    "",
-    doNotFork = false,
-    addLabels = false
-  )
-  private val githubOnPremVcsExtraAlg = VCSExtraAlg.create[MockEff](config)
-
-  test("getReleaseRelatedUrls: on-prem, repoUrl not found") {
-    val metadata =
-      DependencyMetadata.empty.copy(scmUrl = uri"https://github.on-prem.com/foo/foo".some)
-    val obtained = githubOnPremVcsExtraAlg.getReleaseRelatedUrls(metadata, v1, v2).runA(state)
-    assertIO(obtained, List.empty)
-  }
-
-  test("getReleaseRelatedUrls: on-prem, repoUrl ok") {
-    val metadata =
-      DependencyMetadata.empty.copy(scmUrl = uri"https://github.on-prem.com/foo/bar".some)
-    val obtained = githubOnPremVcsExtraAlg.getReleaseRelatedUrls(metadata, v1, v2).runA(state)
-    val expected =
-      List(VersionDiff(uri"https://github.on-prem.com/foo/bar/compare/v0.1.0...v0.2.0"))
-    assertIO(obtained, expected)
-  }
-
-  test("getReleaseRelatedUrls: on-prem, repoUrl permanent redirect") {
-    val metadata =
-      DependencyMetadata.empty.copy(scmUrl = uri"https://github.on-prem.com/foo/buz".some)
-    val obtained = githubOnPremVcsExtraAlg.getReleaseRelatedUrls(metadata, v1, v2).runA(state)
-    assertIO(obtained, List.empty)
-  }
-}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala
index 9516003a..a81172bc 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala
@@ -1,11 +1,10 @@
 package org.scalasteward.core.vcs
 
 import munit.FunSuite
-import org.http4s.syntax.literals._
 import org.scalasteward.core.TestSyntax._
+import org.scalasteward.core.data.Update
 import org.scalasteward.core.git
 import org.scalasteward.core.vcs.VCSType.{GitHub, GitLab}
-import org.scalasteward.core.data.Update
 import org.scalasteward.core.vcs.data.Repo
 
 class VCSPackageTest extends FunSuite {
@@ -28,212 +27,6 @@ class VCSPackageTest extends FunSuite {
       assertEquals(createBranch(GitLab, repo, updateBranch), updateBranch.name)
     }
 
-    test("possibleCompareUrls") {
-      val onPremVCS = "https://github.onprem.io/"
-      val onPremVCSUri = uri"https://github.onprem.io/"
-
-      assertEquals(
-        possibleCompareUrls(
-          GitHub,
-          onPremVCSUri,
-          uri"https://github.com/foo/bar",
-          update.currentVersion,
-          update.nextVersion
-        ).map(_.url.renderString),
-        List(
-          "https://github.com/foo/bar/compare/v1.2.0...v1.2.3",
-          "https://github.com/foo/bar/compare/1.2.0...1.2.3",
-          "https://github.com/foo/bar/compare/release-1.2.0...release-1.2.3"
-        )
-      )
-
-      // should canonicalize (drop last slash)
-      assertEquals(
-        possibleCompareUrls(
-          GitHub,
-          onPremVCSUri,
-          uri"https://github.com/foo/bar/",
-          update.currentVersion,
-          update.nextVersion
-        ).map(_.url.renderString),
-        List(
-          "https://github.com/foo/bar/compare/v1.2.0...v1.2.3",
-          "https://github.com/foo/bar/compare/1.2.0...1.2.3",
-          "https://github.com/foo/bar/compare/release-1.2.0...release-1.2.3"
-        )
-      )
-
-      assertEquals(
-        possibleCompareUrls(
-          GitHub,
-          onPremVCSUri,
-          uri"https://gitlab.com/foo/bar",
-          update.currentVersion,
-          update.nextVersion
-        ).map(_.url.renderString),
-        List(
-          "https://gitlab.com/foo/bar/compare/v1.2.0...v1.2.3",
-          "https://gitlab.com/foo/bar/compare/1.2.0...1.2.3",
-          "https://gitlab.com/foo/bar/compare/release-1.2.0...release-1.2.3"
-        )
-      )
-
-      assertEquals(
-        possibleCompareUrls(
-          GitHub,
-          onPremVCSUri,
-          uri"https://bitbucket.org/foo/bar",
-          update.currentVersion,
-          update.nextVersion
-        ).map(_.url.renderString),
-        List(
-          "https://bitbucket.org/foo/bar/compare/v1.2.3..v1.2.0#diff",
-          "https://bitbucket.org/foo/bar/compare/1.2.3..1.2.0#diff",
-          "https://bitbucket.org/foo/bar/compare/release-1.2.3..release-1.2.0#diff"
-        )
-      )
-
-      assertEquals(
-        possibleCompareUrls(
-          GitHub,
-          onPremVCSUri,
-          uri"https://scalacenter.github.io/scalafix/",
-          update.currentVersion,
-          update.nextVersion
-        ),
-        List.empty
-      )
-
-      assertEquals(
-        possibleCompareUrls(
-          GitHub,
-          onPremVCSUri,
-          onPremVCSUri.addPath("foo/bar"),
-          update.currentVersion,
-          update.nextVersion
-        ).map(_.url.renderString),
-        List(
-          s"${onPremVCS}foo/bar/compare/v1.2.0...v1.2.3",
-          s"${onPremVCS}foo/bar/compare/1.2.0...1.2.3",
-          s"${onPremVCS}foo/bar/compare/release-1.2.0...release-1.2.3"
-        )
-      )
-    }
-
-    test("possibleChangelogUrls: github.com") {
-      val obtained = possibleReleaseRelatedUrls(
-        GitHub,
-        uri"https://github.com",
-        uri"https://github.com/foo/bar",
-        update.currentVersion,
-        update.nextVersion
-      ).map(_.url.renderString)
-      val expected = List(
-        "https://github.com/foo/bar/releases/tag/v1.2.3",
-        "https://github.com/foo/bar/releases/tag/1.2.3",
-        "https://github.com/foo/bar/releases/tag/release-1.2.3",
-        "https://github.com/foo/bar/blob/master/ReleaseNotes.md",
-        "https://github.com/foo/bar/blob/master/ReleaseNotes.markdown",
-        "https://github.com/foo/bar/blob/master/ReleaseNotes.rst",
-        "https://github.com/foo/bar/blob/master/RELEASES.md",
-        "https://github.com/foo/bar/blob/master/RELEASES.markdown",
-        "https://github.com/foo/bar/blob/master/RELEASES.rst",
-        "https://github.com/foo/bar/blob/master/Releases.md",
-        "https://github.com/foo/bar/blob/master/Releases.markdown",
-        "https://github.com/foo/bar/blob/master/Releases.rst",
-        "https://github.com/foo/bar/blob/master/releases.md",
-        "https://github.com/foo/bar/blob/master/releases.markdown",
-        "https://github.com/foo/bar/blob/master/releases.rst",
-        "https://github.com/foo/bar/blob/master/CHANGELOG.md",
-        "https://github.com/foo/bar/blob/master/CHANGELOG.markdown",
-        "https://github.com/foo/bar/blob/master/CHANGELOG.rst",
-        "https://github.com/foo/bar/blob/master/Changelog.md",
-        "https://github.com/foo/bar/blob/master/Changelog.markdown",
-        "https://github.com/foo/bar/blob/master/Changelog.rst",
-        "https://github.com/foo/bar/blob/master/changelog.md",
-        "https://github.com/foo/bar/blob/master/changelog.markdown",
-        "https://github.com/foo/bar/blob/master/changelog.rst",
-        "https://github.com/foo/bar/blob/master/CHANGES.md",
-        "https://github.com/foo/bar/blob/master/CHANGES.markdown",
-        "https://github.com/foo/bar/blob/master/CHANGES.rst",
-        "https://github.com/foo/bar/compare/v1.2.0...v1.2.3",
-        "https://github.com/foo/bar/compare/1.2.0...1.2.3",
-        "https://github.com/foo/bar/compare/release-1.2.0...release-1.2.3"
-      )
-      assertEquals(obtained, expected)
-    }
-
-    test("possibleChangelogUrls: gitlab.com") {
-      val obtained = possibleReleaseRelatedUrls(
-        GitHub,
-        uri"https://github.com",
-        uri"https://gitlab.com/foo/bar",
-        update.currentVersion,
-        update.nextVersion
-      ).map(_.url.renderString)
-      val expected =
-        possibleReleaseNotesFilenames.map(name =>
-          s"https://gitlab.com/foo/bar/blob/master/$name"
-        ) ++
-          possibleChangelogFilenames.map(name => s"https://gitlab.com/foo/bar/blob/master/$name") ++
-          List(
-            "https://gitlab.com/foo/bar/compare/v1.2.0...v1.2.3",
-            "https://gitlab.com/foo/bar/compare/1.2.0...1.2.3",
-            "https://gitlab.com/foo/bar/compare/release-1.2.0...release-1.2.3"
-          )
-      assertEquals(obtained, expected)
-    }
-
-    test("possibleChangelogUrls: on-prem gitlab") {
-      val obtained = possibleReleaseRelatedUrls(
-        GitLab,
-        uri"https://gitlab.on-prem.net",
-        uri"https://gitlab.on-prem.net/foo/bar",
-        update.currentVersion,
-        update.nextVersion
-      ).map(_.url.renderString)
-      val expected = possibleReleaseNotesFilenames.map(name =>
-        s"https://gitlab.on-prem.net/foo/bar/blob/master/$name"
-      ) ++ possibleChangelogFilenames.map(name =>
-        s"https://gitlab.on-prem.net/foo/bar/blob/master/$name"
-      ) ++ List(
-        "https://gitlab.on-prem.net/foo/bar/compare/v1.2.0...v1.2.3",
-        "https://gitlab.on-prem.net/foo/bar/compare/1.2.0...1.2.3",
-        "https://gitlab.on-prem.net/foo/bar/compare/release-1.2.0...release-1.2.3"
-      )
-      assertEquals(obtained, expected)
-    }
-
-    test("possibleChangelogUrls: bitbucket.org") {
-      val obtained = possibleReleaseRelatedUrls(
-        GitHub,
-        uri"https://github.com",
-        uri"https://bitbucket.org/foo/bar",
-        update.currentVersion,
-        update.nextVersion
-      ).map(_.url.renderString)
-      val expected =
-        possibleReleaseNotesFilenames.map(name => s"https://bitbucket.org/foo/bar/master/$name") ++
-          possibleChangelogFilenames.map(name => s"https://bitbucket.org/foo/bar/master/$name") ++
-          List(
-            "https://bitbucket.org/foo/bar/compare/v1.2.3..v1.2.0#diff",
-            "https://bitbucket.org/foo/bar/compare/1.2.3..1.2.0#diff",
-            "https://bitbucket.org/foo/bar/compare/release-1.2.3..release-1.2.0#diff"
-          )
-      assertEquals(obtained, expected)
-    }
-
-    test("possibleChangelogUrls: homepage") {
-      val obtained = possibleReleaseRelatedUrls(
-        GitHub,
-        uri"https://github.com",
-        uri"https://scalacenter.github.io/scalafix/",
-        update.currentVersion,
-        update.nextVersion
-      ).map(_.url.renderString)
-      assertEquals(obtained, List.empty)
-    }
-
   }
 
   // Grouped updates
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
index e2f30c58..eee1c5fe 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
@@ -7,10 +7,11 @@ import org.http4s.syntax.literals._
 import org.scalasteward.core.TestInstances._
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.buildtool.sbt.data.SbtVersion
-import org.scalasteward.core.data.{ReleaseRelatedUrl, RepoData, Update, UpdateData, Version}
+import org.scalasteward.core.data.{RepoData, Update, UpdateData, Version}
 import org.scalasteward.core.edit.EditAttempt.{ScalafixEdit, UpdateEdit}
 import org.scalasteward.core.edit.scalafix.ScalafixMigration
 import org.scalasteward.core.git.{Branch, Commit}
+import org.scalasteward.core.nurture.UpdateInfoUrl
 import org.scalasteward.core.repoconfig.RepoConfig
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.vcs.data.NewPullRequestData._
@@ -80,27 +81,25 @@ class NewPullRequestDataTest extends FunSuite {
   }
 
   test("links to release notes/changelog") {
-    assertEquals(releaseNote(List.empty), None)
+    assertEquals(renderUpdateInfoUrls(List.empty), None)
 
     assertEquals(
-      releaseNote(
-        List(ReleaseRelatedUrl.CustomChangelog(uri"https://github.com/foo/foo/CHANGELOG.rst"))
+      renderUpdateInfoUrls(
+        List(UpdateInfoUrl.CustomChangelog(uri"https://github.com/foo/foo/CHANGELOG.rst"))
       ),
       Some("[Changelog](https://github.com/foo/foo/CHANGELOG.rst)")
     )
 
     assertEquals(
-      releaseNote(
+      renderUpdateInfoUrls(
         List(
-          ReleaseRelatedUrl.CustomChangelog(uri"https://github.com/foo/foo/CHANGELOG.rst"),
-          ReleaseRelatedUrl.GitHubReleaseNotes(uri"https://github.com/foo/foo/releases/tag/v1.2.3"),
-          ReleaseRelatedUrl.CustomReleaseNotes(
+          UpdateInfoUrl.CustomChangelog(uri"https://github.com/foo/foo/CHANGELOG.rst"),
+          UpdateInfoUrl.GitHubReleaseNotes(uri"https://github.com/foo/foo/releases/tag/v1.2.3"),
+          UpdateInfoUrl.CustomReleaseNotes(
             uri"https://github.com/foo/bar/blob/master/ReleaseNotes.md"
           ),
-          ReleaseRelatedUrl.CustomReleaseNotes(
-            uri"https://github.com/foo/bar/blob/master/Releases.md"
-          ),
-          ReleaseRelatedUrl.VersionDiff(uri"https://github.com/foo/foo/compare/v1.2.0...v1.2.3")
+          UpdateInfoUrl.CustomReleaseNotes(uri"https://github.com/foo/bar/blob/master/Releases.md"),
+          UpdateInfoUrl.VersionDiff(uri"https://github.com/foo/foo/compare/v1.2.0...v1.2.3")
         )
       ),
       Some(