Repository: scala-steward-org/scala-steward
Pull request #2677 (2022-07-24 23:02:34)

Add support for Azure Repos
This PR adds support for Azure Repos as requested on #2659 

Azure APi Doce: [here](https://docs.microsoft.com/en-us/rest/api/azure/devops/git/?view=azure-devops-rest-7.1)

WIth this feature, scala-steward can be run on Azure Repos using docker as follows

1. Create and add an access token to `pass.sh` file:  [See how](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows)

```bash
echo "#!/bin/sh"  >> pass.sh
echo "echo '$AZURE_REPO_ACCESS_TOKEN'" >> pass.sh
```

2. Run steward
```bash
docker run -v $PWD:/opt/scala-steward \
    -it fthomas/scala-steward:latest \
    -DLOG_LEVEL=TRACE \
    --do-not-fork \
    --workspace "/opt/scala-steward/workspace" \
    --repos-file "/opt/scala-steward/repos.md" \
    --git-author-email "email@mycompany.com" \
    --vcs-type "azure-repos" \
    --vcs-api-host "https://dev.azure.com" \
    --vcs-login "email@mycompany.com" \
    --azure-repos-organization "mycompany" \
    --git-ask-pass "/opt/scala-steward/pass.sh"
```

codecov[bot]: # [Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) Report
> Merging [#2677](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) (8b309bd) into [main](https://codecov.io/gh/scala-steward-org/scala-steward/commit/086bf766d906cc3661f7c64547fe57738a828bc4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) (086bf76) will **increase** coverage by `0.17%`.
> The diff coverage is `86.36%`.

```diff
@@            Coverage Diff             @@
##             main    #2677      +/-   ##
==========================================
+ Coverage   81.32%   81.49%   +0.17%     
==========================================
  Files         146      150       +4     
  Lines        2592     2697     +105     
  Branches       43       49       +6     
==========================================
+ Hits         2108     2198      +90     
- Misses        484      499      +15     
```


| [Impacted Files](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) | Coverage Δ | |
|---|---|---|
| [...ala/org/scalasteward/core/application/Config.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9hcHBsaWNhdGlvbi9Db25maWcuc2NhbGE=) | `100.00% <ø> (ø)` | |
| [...scala/org/scalasteward/core/vcs/VCSSelection.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvVkNTU2VsZWN0aW9uLnNjYWxh) | `17.39% <0.00%> (-4.84%)` | :arrow_down: |
| [...lasteward/core/vcs/azurerepos/authentication.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvYXp1cmVyZXBvcy9hdXRoZW50aWNhdGlvbi5zY2FsYQ==) | `0.00% <0.00%> (ø)` | |
| [...main/scala/org/scalasteward/core/vcs/package.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvcGFja2FnZS5zY2FsYQ==) | `86.04% <53.84%> (-13.96%)` | :arrow_down: |
| [...steward/core/vcs/azurerepos/AzureReposApiAlg.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvYXp1cmVyZXBvcy9BenVyZVJlcG9zQXBpQWxnLnNjYWxh) | `92.00% <92.00%> (ø)` | |
| [...g/scalasteward/core/vcs/azurerepos/JsonCodec.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvYXp1cmVyZXBvcy9Kc29uQ29kZWMuc2NhbGE=) | `97.77% <97.77%> (ø)` | |
| [.../scala/org/scalasteward/core/application/Cli.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9hcHBsaWNhdGlvbi9DbGkuc2NhbGE=) | `100.00% <100.00%> (ø)` | |
| [...main/scala/org/scalasteward/core/vcs/VCSType.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvVkNTVHlwZS5zY2FsYQ==) | `100.00% <100.00%> (ø)` | |
| [...ala/org/scalasteward/core/vcs/azurerepos/Url.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS92Y3MvYXp1cmVyZXBvcy9Vcmwuc2NhbGE=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org). Last update [086bf76...8b309bd](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2677?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).


Patch:
diff --git a/README.md b/README.md
index d01d376a..de5fe0da 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,7 @@ Thanks goes to these wonderful people for contributing to Scala Steward:
 * [Renato Cavalcanti](https://github.com/renatocaval)
 * [Rikito Taniguchi](https://github.com/tanishiking)
 * [Robert Stoll](https://github.com/robstoll)
+* [Robin Raju](https://github.com/robinraju)
 * [Scott Rice](https://github.com/scottrice10)
 * [solar](https://github.com/solar)
 * [Stanislav Chetvertkov](https://github.com/stanislav-chetvertkov)
diff --git a/docs/help.md b/docs/help.md
index d9e264d2..2742e0e9 100644
--- a/docs/help.md
+++ b/docs/help.md
@@ -3,7 +3,7 @@
 All command line arguments for the `scala-steward` application.
 
 ```
-Usage: scala-steward --workspace <file> --repos-file <file> [--git-author-name <string>] --git-author-email <string> [--git-author-signing-key <string>] --git-ask-pass <file> [--sign-commits] [--vcs-type <vcs-type>] [--vcs-api-host <uri>] --vcs-login <string> [--do-not-fork] [--add-labels] [--ignore-opts-files] [--env-var <name=value>]... [--process-timeout <duration>] [--whitelist <string>]... [--read-only <string>]... [--enable-sandbox | --disable-sandbox] [--max-buffer-size <integer>] [--repo-config <uri>]... [--disable-default-repo-config] [--scalafix-migrations <uri>]... [--disable-default-scalafix-migrations] [--artifact-migrations <uri>]... [--disable-default-artifact-migrations] [--cache-ttl <duration>] [--bitbucket-server-use-default-reviewers] [--gitlab-merge-when-pipeline-succeeds] [--github-app-id <integer> --github-app-key-file <file>] [--url-checker-test-url <uri>] [--default-maven-repo <string>] [--refresh-backoff-period <duration>]
+Usage: scala-steward --workspace <file> --repos-file <file> [--git-author-name <string>] --git-author-email <string> [--git-author-signing-key <string>] --git-ask-pass <file> [--sign-commits] [--vcs-type <vcs-type>] [--vcs-api-host <uri>] --vcs-login <string> [--do-not-fork] [--add-labels] [--ignore-opts-files] [--env-var <name=value>]... [--process-timeout <duration>] [--whitelist <string>]... [--read-only <string>]... [--enable-sandbox | --disable-sandbox] [--max-buffer-size <integer>] [--repo-config <uri>]... [--disable-default-repo-config] [--scalafix-migrations <uri>]... [--disable-default-scalafix-migrations] [--artifact-migrations <uri>]... [--disable-default-artifact-migrations] [--cache-ttl <duration>] [--bitbucket-server-use-default-reviewers] [--gitlab-merge-when-pipeline-succeeds] [--azure-repos-organization <string>] [--github-app-id <integer> --github-app-key-file <file>] [--url-checker-test-url <uri>] [--default-maven-repo <string>] [--refresh-backoff-period <duration>]
 
 
 
@@ -25,7 +25,7 @@ Options and flags:
     --sign-commits
         Whether to sign commits; default: false
     --vcs-type <vcs-type>
-        One of bitbucket, bitbucket-server, github, gitlab; default: github
+        One of azure-repos, bitbucket, bitbucket-server, github, gitlab; default: github
     --vcs-api-host <uri>
         API URL of the git hoster; default: https://api.github.com
     --vcs-login <string>
@@ -68,6 +68,8 @@ Options and flags:
         Whether to assign the default reviewers to a bitbucket pull request; default: false
     --gitlab-merge-when-pipeline-succeeds
         Whether to merge a gitlab merge request when the pipeline succeeds
+    --azure-repos-organization <string>
+        The Azure organization (required when vcs type is azure-repos)
     --github-app-id <integer>
         GitHub application id
     --github-app-key-file <file>
diff --git a/docs/running.md b/docs/running.md
index 1622ef70..7dd90492 100644
--- a/docs/running.md
+++ b/docs/running.md
@@ -260,6 +260,32 @@ echo "${SCALA_STEWARD_TOKEN}"
 7. add the `repos.md` file 
 8. (*optional*) create a new schedule to trigger the pipeline on a daily/weekly basis
 
+#### Azure Repos
+
+* Create a file `repos.md` that will be injected into the container as a volume.
+* Create a file `run.sh` with this content:
+
+```
+echo "#!/bin/sh"                  >> pass.sh
+echo "echo '$AZURE_REPO_ACCESS_TOKEN'" >> pass.sh
+
+chmod +x pass.sh
+
+docker run -v $PWD:/opt/scala-steward \
+    -it fthomas/scala-steward:latest \
+    -DLOG_LEVEL=TRACE \
+    --do-not-fork \
+    --workspace "/opt/scala-steward/workspace" \
+    --repos-file "/opt/scala-steward/repos.md" \
+    --git-author-email "email@mycompany.com" \
+    --vcs-type "azure-repos" \
+    --vcs-api-host "https://dev.azure.com" \
+    --vcs-login "email@mycompany.com" \
+    --azure-repos-organization "mycompany" \
+    --git-ask-pass "/opt/scala-steward/pass.sh"
+```
+
+Note: `AZURE_REPO_ACCESS_TOKEN` is a personal access token created with Read, write, & manage permissions to your repositories.
 
 [scalafixmigrations]: https://github.com/scala-steward-org/scala-steward/blob/main/docs/scalafix-migrations.md
 [artifactmigrations]: https://github.com/scala-steward-org/scala-steward/blob/main/docs/artifact-migrations.md
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
index 6c2e8448..9a3f7880 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
@@ -238,6 +238,15 @@ object Cli {
   private val gitHubApp: Opts[Option[GitHubApp]] =
     (githubAppId, githubAppKeyFile).mapN(GitHubApp.apply).orNone
 
+  private val azureReposOrganization: Opts[Option[String]] =
+    option[String](
+      "azure-repos-organization",
+      "The Azure organization (required when vcs type is azure-repos)"
+    ).orNone
+
+  private val azureReposConfig: Opts[AzureReposConfig] =
+    azureReposOrganization.map(AzureReposConfig.apply)
+
   private val refreshBackoffPeriod: Opts[FiniteDuration] = {
     val default = 0.days
     val help = "Period of time a failed build won't be triggered again" +
@@ -270,6 +279,7 @@ object Cli {
     cacheTtl,
     bitbucketServerCfg,
     gitLabCfg,
+    azureReposConfig,
     gitHubApp,
     urlCheckerTestUrl,
     defaultMavenRepo,
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
index 5ab62a0c..1a76b771 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
@@ -65,6 +65,7 @@ final case class Config(
     cacheTtl: FiniteDuration,
     bitbucketServerCfg: BitbucketServerCfg,
     gitLabCfg: GitLabCfg,
+    azureReposConfig: AzureReposConfig,
     githubApp: Option[GitHubApp],
     urlCheckerTestUrl: Uri,
     defaultResolver: Resolver,
@@ -135,4 +136,9 @@ object Config {
   final case class GitLabCfg(
       mergeWhenPipelineSucceeds: Boolean
   )
+
+  final case class AzureReposConfig(
+      organization: Option[String]
+  )
+
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSSelection.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSSelection.scala
index 67c20bdb..269134fb 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSSelection.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSSelection.scala
@@ -20,7 +20,8 @@ import cats.MonadThrow
 import org.http4s.Header
 import org.scalasteward.core.application.Config
 import org.scalasteward.core.util.HttpJsonClient
-import org.scalasteward.core.vcs.VCSType.{Bitbucket, BitbucketServer, GitHub, GitLab}
+import org.scalasteward.core.vcs.VCSType.{AzureRepos, Bitbucket, BitbucketServer, GitHub, GitLab}
+import org.scalasteward.core.vcs.azurerepos.AzureReposApiAlg
 import org.scalasteward.core.vcs.bitbucket.BitbucketApiAlg
 import org.scalasteward.core.vcs.bitbucketserver.BitbucketServerApiAlg
 import org.scalasteward.core.vcs.data.AuthenticatedUser
@@ -63,11 +64,19 @@ final class VCSSelection[F[_]](config: Config, user: AuthenticatedUser)(implicit
     )
   }
 
+  private def azureReposApiAlg: AzureReposApiAlg[F] =
+    new AzureReposApiAlg[F](
+      config.vcsCfg.apiHost,
+      config.azureReposConfig,
+      _ => azurerepos.authentication.addCredentials(user)
+    )
+
   def vcsApiAlg: VCSApiAlg[F] =
     config.vcsCfg.tpe match {
       case GitHub          => gitHubApiAlg
       case GitLab          => gitLabApiAlg
       case Bitbucket       => bitbucketApiAlg
       case BitbucketServer => bitbucketServerApiAlg
+      case AzureRepos      => azureReposApiAlg
     }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala
index 26fb2e1f..5b846715 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala
@@ -26,6 +26,7 @@ sealed trait VCSType extends Product with Serializable {
   def publicWebHost: Option[String]
 
   val asString: String = this match {
+    case AzureRepos      => "azure-repos"
     case Bitbucket       => "bitbucket"
     case BitbucketServer => "bitbucket-server"
     case GitHub          => "github"
@@ -34,6 +35,10 @@ sealed trait VCSType extends Product with Serializable {
 }
 
 object VCSType {
+  case object AzureRepos extends VCSType {
+    override val publicWebHost: Option[String] = Some("dev.azure.com")
+  }
+
   case object Bitbucket extends VCSType {
     override val publicWebHost: Some[String] = Some("bitbucket.org")
     val publicApiBaseUrl = uri"https://api.bitbucket.org/2.0"
@@ -53,7 +58,7 @@ object VCSType {
     val publicApiBaseUrl = uri"https://gitlab.com/api/v4"
   }
 
-  val all = List(Bitbucket, BitbucketServer, GitHub, GitLab)
+  val all = List(AzureRepos, Bitbucket, BitbucketServer, GitHub, GitLab)
 
   def parse(s: String): Either[String, VCSType] =
     all.find(_.asString === s) match {
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/AzureReposApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/AzureReposApiAlg.scala
new file mode 100644
index 00000000..c67a52c3
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/AzureReposApiAlg.scala
@@ -0,0 +1,98 @@
+/*
+ * 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.vcs.azurerepos
+
+import cats.MonadThrow
+import cats.syntax.all._
+import io.circe.Json
+import io.circe.syntax.KeyOps
+import org.http4s.{Request, Uri}
+import org.scalasteward.core.application.Config.AzureReposConfig
+import org.scalasteward.core.git.Branch
+import org.scalasteward.core.util.HttpJsonClient
+import org.scalasteward.core.vcs.VCSApiAlg
+import org.scalasteward.core.vcs.data._
+import JsonCodec._
+
+final class AzureReposApiAlg[F[_]](
+    azureAPiHost: Uri,
+    config: AzureReposConfig,
+    modify: Repo => Request[F] => F[Request[F]]
+)(implicit client: HttpJsonClient[F], monadErrorF: MonadThrow[F])
+    extends VCSApiAlg[F] {
+
+  private val url = new Url(azureAPiHost, config.organization.getOrElse(""))
+
+  override def createFork(repo: Repo): F[RepoOut] =
+    monadErrorF.raiseError(new NotImplementedError(s"createFork($repo)"))
+
+  // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/create?view=azure-devops-rest-7.1
+  override def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut] =
+    client.postWithBody[PullRequestOut, PullRequestPayload](
+      url.pullRequests(repo),
+      PullRequestPayload.from(data),
+      modify(repo)
+    )
+
+  // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/update?view=azure-devops-rest-7.1
+  override def closePullRequest(repo: Repo, number: PullRequestNumber): F[PullRequestOut] =
+    client.patchWithBody[PullRequestOut, ClosePullRequestPayload](
+      url.closePullRequest(repo, number),
+      ClosePullRequestPayload("abandoned"),
+      modify(repo)
+    )
+
+  // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/stats/get?view=azure-devops-rest-7.1
+  override def getBranch(repo: Repo, branch: Branch): F[BranchOut] =
+    client.get[BranchOut](url.getBranch(repo, branch), modify(repo))
+
+  // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/repositories/get-repository-with-parent?view=azure-devops-rest-7.1
+  override def getRepo(repo: Repo): F[RepoOut] =
+    client.get[RepoOut](url.getRepo(repo), modify(repo))
+
+  // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests?view=azure-devops-rest-7.1
+  override def listPullRequests(repo: Repo, head: String, base: Branch): F[List[PullRequestOut]] =
+    client
+      .get[Paginated[PullRequestOut]](url.listPullRequests(repo, head, base), modify(repo))
+      .map(_.value)
+
+  // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-threads/create?view=azure-devops-rest-7.1
+  override def commentPullRequest(
+      repo: Repo,
+      number: PullRequestNumber,
+      comment: String
+  ): F[Comment] =
+    client.postWithBody[Comment, PullRequestCommentPayload](
+      url.commentPullRequest(repo, number),
+      PullRequestCommentPayload.createComment(comment),
+      modify(repo)
+    )
+
+  // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-labels/create?view=azure-devops-rest-7.1
+  override def labelPullRequest(
+      repo: Repo,
+      number: PullRequestNumber,
+      labels: List[String]
+  ): F[Unit] =
+    client
+      .postWithBody[Json, Json](
+        url.labelPullRequest(repo, number),
+        Json.obj("name" := labels.mkString("-")),
+        modify(repo)
+      )
+      .void
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/JsonCodec.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/JsonCodec.scala
new file mode 100644
index 00000000..d65febdb
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/JsonCodec.scala
@@ -0,0 +1,123 @@
+/*
+ * 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.vcs.azurerepos
+
+import io.circe.generic.semiauto._
+import io.circe.{Decoder, Encoder}
+import org.http4s.Uri
+import org.scalasteward.core.git.{Branch, Sha1}
+import org.scalasteward.core.util.unexpectedString
+import org.scalasteward.core.util.uri.uriDecoder
+import org.scalasteward.core.vcs.data._
+
+final private[azurerepos] case class PullRequestPayload(
+    sourceRefName: String,
+    targetRefName: String,
+    title: String,
+    description: String
+)
+
+private[azurerepos] object PullRequestPayload {
+  val withPrefix: String => String = name => s"refs/heads/$name"
+
+  def from(data: NewPullRequestData): PullRequestPayload =
+    PullRequestPayload(
+      sourceRefName = withPrefix(data.head),
+      targetRefName = data.base.name,
+      title = data.title,
+      description = data.body
+    )
+}
+
+final private[azurerepos] case class ClosePullRequestPayload(status: String)
+
+final private case class AzureComment(content: String)
+
+final private[azurerepos] case class PullRequestCommentPayload(
+    comments: List[AzureComment],
+    status: Int = 1
+)
+
+private[azurerepos] object PullRequestCommentPayload {
+
+  def createComment(content: String): PullRequestCommentPayload =
+    PullRequestCommentPayload(List(AzureComment(content)))
+}
+
+final private[azurerepos] case class Paginated[A](value: List[A])
+
+private[azurerepos] object Paginated {
+  implicit def pageDecoder[A: Decoder]: Decoder[Paginated[A]] =
+    Decoder.instance { c =>
+      c.downField("value").as[List[A]].map(Paginated(_))
+    }
+}
+private[azurerepos] object JsonCodec {
+
+  implicit val repoOutDecoder: Decoder[RepoOut] = Decoder.instance { c =>
+    for {
+      name <- c.downField("name").as[String]
+      owner <-
+        c.downField("project")
+          .downField("name")
+          .as[String]
+          .map(UserOut(_))
+      cloneUrl <- c.downField("remoteUrl").as[Uri]
+      parent <-
+        c.downField("parentRepository")
+          .as[Option[RepoOut]]
+      defaultBranch <-
+        c.downField("defaultBranch")
+          .as[Option[String]]
+          .map(_.getOrElse("master"))
+          .map(Branch(_))
+    } yield RepoOut(name, owner, parent, cloneUrl, defaultBranch)
+  }
+
+  implicit val branchOutDecoder: Decoder[BranchOut] = Decoder.instance { c =>
+    for {
+      branch <- c.downField("name").as[Branch]
+      commitHash <- c.downField("commit").downField("commitId").as[Sha1]
+    } yield BranchOut(branch, CommitOut(commitHash))
+  }
+
+  implicit val pullRequestStateDecoder: Decoder[PullRequestState] =
+    Decoder[String].emap {
+      case "active"                  => Right(PullRequestState.Open)
+      case "completed" | "abandoned" => Right(PullRequestState.Closed)
+      case s => unexpectedString(s, List("active", "completed", "abandoned"))
+    }
+
+  implicit val pullRequestOutDecoder: Decoder[PullRequestOut] = Decoder.instance { c =>
+    for {
+      title <- c.downField("title").as[String]
+      number <- c.downField("pullRequestId").as[PullRequestNumber]
+      state <- c.downField("status").as[PullRequestState]
+      url <- c.downField("url").as[Uri]
+    } yield PullRequestOut(url, state, number, title)
+  }
+
+  implicit val commentDecoder: Decoder[Comment] = Decoder.instance { c =>
+    c.downField("comments").downN(0).downField("content").as[String].map(Comment(_))
+  }
+
+  implicit val pullRequestPayloadEncoder: Encoder[PullRequestPayload] = deriveEncoder
+  implicit val closePullRequestPayloadEncoder: Encoder[ClosePullRequestPayload] = deriveEncoder
+  implicit val pullRequestCommentEncoder: Encoder[AzureComment] = deriveEncoder
+  implicit val pullRequestCommentPayloadEncoder: Encoder[PullRequestCommentPayload] = deriveEncoder
+
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/Url.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/Url.scala
new file mode 100644
index 00000000..c1d5d640
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/Url.scala
@@ -0,0 +1,54 @@
+/*
+ * 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.vcs.azurerepos
+
+import org.http4s.Uri
+import org.scalasteward.core.git.Branch
+import org.scalasteward.core.vcs.data.{PullRequestNumber, Repo}
+
+class Url(apiHost: Uri, organization: String) {
+  private val apiVersion = "7.1-preview.1"
+
+  private val withoutPrefix: String => String = name => name.split("refs/heads/").last
+
+  private def repos(repo: Repo): Uri =
+    (apiHost / organization / repo.owner / "_apis/git/repositories" / repo.repo)
+      .withQueryParam("api-version", apiVersion)
+
+  def getRepo(repo: Repo): Uri =
+    repos(repo).withQueryParam("includeParent", "true")
+
+  def getBranch(repo: Repo, branch: Branch): Uri =
+    (repos(repo) / "stats/branches").withQueryParam("name", withoutPrefix(branch.name))
+
+  def pullRequests(repo: Repo): Uri =
+    repos(repo) / "pullrequests"
+
+  def listPullRequests(repo: Repo, source: String, target: Branch): Uri =
+    pullRequests(repo).withQueryParams(
+      Map("searchCriteria.sourceRefName" -> source, "searchCriteria.targetRefName" -> target.name)
+    )
+
+  def closePullRequest(repo: Repo, number: PullRequestNumber): Uri =
+    pullRequests(repo) / number.value
+
+  def commentPullRequest(repo: Repo, number: PullRequestNumber): Uri =
+    pullRequests(repo) / number.value / "threads"
+
+  def labelPullRequest(repo: Repo, number: PullRequestNumber): Uri =
+    pullRequests(repo) / number.value / "labels"
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/authentication.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/authentication.scala
new file mode 100644
index 00000000..f935fc1e
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/azurerepos/authentication.scala
@@ -0,0 +1,29 @@
+/*
+ * 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.vcs.azurerepos
+
+import cats.Applicative
+import cats.syntax.all._
+import org.http4s.headers.Authorization
+import org.http4s.{BasicCredentials, Request}
+import org.scalasteward.core.vcs.data.AuthenticatedUser
+
+object authentication {
+  def addCredentials[F[_]: Applicative](user: AuthenticatedUser): Request[F] => F[Request[F]] =
+    _.putHeaders(Authorization(BasicCredentials(user.login, user.accessToken))).pure[F]
+
+}
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 a0763251..fe5ef95d 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
@@ -21,7 +21,7 @@ import org.http4s.Uri
 import org.scalasteward.core.data.ReleaseRelatedUrl.VersionDiff
 import org.scalasteward.core.data.{ReleaseRelatedUrl, Update, Version}
 import org.scalasteward.core.git.Branch
-import org.scalasteward.core.vcs.VCSType.{Bitbucket, BitbucketServer, GitHub, GitLab}
+import org.scalasteward.core.vcs.VCSType.{AzureRepos, Bitbucket, BitbucketServer, GitHub, GitLab}
 import org.scalasteward.core.vcs.data.Repo
 
 package object vcs {
@@ -34,7 +34,7 @@ package object vcs {
       case GitHub =>
         s"${fork.owner}/${fork.repo}:${updateBranch.name}"
 
-      case GitLab | Bitbucket | BitbucketServer =>
+      case GitLab | Bitbucket | BitbucketServer | AzureRepos =>
         updateBranch.name
     }
 
@@ -46,7 +46,7 @@ package object vcs {
       case GitHub =>
         s"${fork.owner}:${updateBranch.name}"
 
-      case GitLab | Bitbucket | BitbucketServer =>
+      case GitLab | Bitbucket | BitbucketServer | AzureRepos =>
         updateBranch.name
     }
 
@@ -102,6 +102,13 @@ package object vcs {
           possibleTags(from).zip(possibleTags(to)).map { case (from1, to1) =>
             VersionDiff((repoUrl / "compare" / s"$to1..$from1").withFragment("diff"))
           }
+        case AzureRepos =>
+          possibleTags(from).zip(possibleTags(to)).map { case (from1, to1) =>
+            VersionDiff(
+              (repoUrl / "branchCompare")
+                .withQueryParams(Map("baseVersion" -> from1, "targetVersion" -> to1))
+            )
+          }
       }
       .getOrElse(List.empty)
   }
@@ -123,15 +130,25 @@ package object vcs {
       .getOrElse(List.empty)
 
     def files(fileNames: List[String]): List[Uri] = {
-      val maybeSegments = repoVCSType.map {
-        case GitHub | GitLab             => List("blob", "master")
-        case Bitbucket | BitbucketServer => List("master")
+      val maybeSegments = repoVCSType.collect {
+        case GitHub | GitLab => List("blob", "master")
+        case Bitbucket       => List("master")
+        case BitbucketServer => List("browse")
       }
 
-      maybeSegments.toList.flatMap { segments =>
+      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)
diff --git a/modules/docs/mdoc/running.md b/modules/docs/mdoc/running.md
index 9c44fffc..70ce654a 100644
--- a/modules/docs/mdoc/running.md
+++ b/modules/docs/mdoc/running.md
@@ -260,6 +260,32 @@ echo "${SCALA_STEWARD_TOKEN}"
 7. add the `repos.md` file 
 8. (*optional*) create a new schedule to trigger the pipeline on a daily/weekly basis
 
+#### Azure Repos
+
+* Create a file `repos.md` that will be injected into the container as a volume.
+* Create a file `run.sh` with this content:
+
+```
+echo "#!/bin/sh"                  >> pass.sh
+echo "echo '$AZURE_REPO_ACCESS_TOKEN'" >> pass.sh
+
+chmod +x pass.sh
+
+docker run -v $PWD:/opt/scala-steward \
+    -it fthomas/scala-steward:latest \
+    -DLOG_LEVEL=TRACE \
+    --do-not-fork \
+    --workspace "/opt/scala-steward/workspace" \
+    --repos-file "/opt/scala-steward/repos.md" \
+    --git-author-email "email@mycompany.com" \
+    --vcs-type "azure-repos" \
+    --vcs-api-host "https://dev.azure.com" \
+    --vcs-login "email@mycompany.com" \
+    --azure-repos-organization "mycompany" \
+    --git-ask-pass "/opt/scala-steward/pass.sh"
+```
+
+Note: `AZURE_REPO_ACCESS_TOKEN` is a personal access token created with Read, write, & manage permissions to your repositories.
 
 [scalafixmigrations]: @GITHUB_URL@/blob/@MAIN_BRANCH@/docs/scalafix-migrations.md
 [artifactmigrations]: @GITHUB_URL@/blob/@MAIN_BRANCH@/docs/artifact-migrations.md


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/azurerepos/AzureReposApiAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/azurerepos/AzureReposApiAlgTest.scala
new file mode 100644
index 00000000..8f3a0b31
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/azurerepos/AzureReposApiAlgTest.scala
@@ -0,0 +1,269 @@
+package org.scalasteward.core.vcs.azurerepos
+
+import cats.effect.IO
+import cats.effect.unsafe.implicits.global
+import munit.FunSuite
+import org.http4s.client.Client
+import org.http4s.dsl.io._
+import org.http4s.implicits._
+import org.http4s.{HttpRoutes, Uri}
+import org.scalasteward.core.application.Config.AzureReposConfig
+import org.scalasteward.core.git.Sha1.HexString
+import org.scalasteward.core.git.{Branch, Sha1}
+import org.scalasteward.core.util.HttpJsonClient
+import org.scalasteward.core.vcs.data._
+
+class AzureReposApiAlgTest extends FunSuite {
+  private val repo = Repo("scala-steward-org", "scala-steward")
+  private val apiHost = uri"https://dev.azure.com"
+
+  object branchNameMatcher extends QueryParamDecoderMatcher[String]("name")
+  object sourceRefNameMatcher
+      extends QueryParamDecoderMatcher[String]("searchCriteria.sourceRefName")
+  object targetRefNameMatcher
+      extends QueryParamDecoderMatcher[String]("searchCriteria.targetRefName")
+
+  private val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
+    case GET -> Root / "azure-org" / repo.owner / "_apis/git/repositories" / repo.repo =>
+      Ok("""
+           |{
+           |    "id": "3846fbbd-71a0-402b-8352-6b1b9b2088aa",
+           |    "name": "scala-steward",
+           |    "url": "https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward",
+           |    "project": {
+           |        "id": "2a608025-b9aa-4918-a306-5aae0a8b7458",
+           |        "name": "scala-steward-org"
+           |    },
+           |    "defaultBranch": "refs/heads/main",
+           |    "size": 7786160,
+           |    "remoteUrl": "https://steward-user@dev.azure.com/azure-org/scala-steward-org/_git/scala-steward",
+           |    "isDisabled": false
+           |}""".stripMargin)
+
+    case GET -> Root / "azure-org" / repo.owner / "_apis/git/repositories" / repo.repo / "stats/branches"
+        :? branchNameMatcher("main") =>
+      Ok("""
+           |{
+           |    "commit": {
+           |        "commitId": "f55c9900528e548511fbba6874c873d44c5d714c"                          
+           |    },
+           |    "name": "main",
+           |    "aheadCount": 0,
+           |    "behindCount": 0,
+           |    "isBaseVersion": true
+           |}""".stripMargin)
+
+    case POST -> Root / "azure-org" / repo.owner / "_apis/git/repositories" / repo.repo / "pullrequests" =>
+      Created(
+        """
+          |{
+          |  "repository": {
+          |    "id": "3846fbbd-71a0-402b-8352-6b1b9b2088aa",
+          |    "name": "scala-steward",
+          |    "url": "https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward",
+          |    "project": {
+          |      "id": "a7573007-bbb3-4341-b726-0c4148a07853",
+          |      "name": "scala-steward-org"
+          |    },
+          |    "remoteUrl": "https://steward-user@dev.azure.com/azure-org/scala-steward-org/_git/scala-steward"
+          |  },
+          |  "pullRequestId": 22,
+          |  "status": "active",
+          |  "creationDate": "2016-11-01T16:30:31.6655471Z",
+          |  "title": "Update cats-effect to 3.3.14",
+          |  "description": "Updates org.typelevel:cats-effect  from 3.3.13 to 3.3.14.",
+          |  "sourceRefName": "refs/heads/update/cats-effect-3.3.14",
+          |  "targetRefName": "refs/heads/main",
+          |  "url": "https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward/pullRequests/22",
+          |  "supportsIterations": true
+          |}""".stripMargin
+      )
+
+    case GET -> Root / "azure-org" / repo.owner / "_apis/git/repositories" / repo.repo / "pullrequests" :?
+        sourceRefNameMatcher("refs/heads/update/cats-effect-3.3.14")
+        +& targetRefNameMatcher("refs/heads/main") =>
+      Ok("""
+           |{
+           |   "value":[
+           |      {
+           |         "pullRequestId":26,
+           |         "status":"active",
+           |         "creationDate":"2022-07-24T16:58:51.0719239Z",
+           |         "title":"Update cats-effect to 3.3.14",
+           |         "description":"Updates [org.typelevel:cats-effect]",
+           |         "sourceRefName":"refs/heads/update/cats-effect-3.3.14",
+           |         "targetRefName":"refs/heads/main",
+           |         "mergeStatus":"succeeded",
+           |         "isDraft":false,
+           |         "mergeId":"3ff8afa0-1147-4158-b215-74a0b5a2e162",
+           |         "reviewers":[
+           |            
+           |         ],
+           |         "url":"https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward/pullRequests/26",
+           |         "supportsIterations":true
+           |      }
+           |   ],
+           |   "count":1
+           |}""".stripMargin)
+
+    case PATCH -> Root / "azure-org" / repo.owner / "_apis/git/repositories" / repo.repo / "pullrequests" / "26" =>
+      Ok("""
+           |{
+           |  "repository": {
+           |    "id": "3846fbbd-71a0-402b-8352-6b1b9b2088aa",
+           |    "name": "scala-steward",
+           |    "url": "https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward",
+           |    "project": {
+           |      "id": "a7573007-bbb3-4341-b726-0c4148a07853",
+           |      "name": "scala-steward-org"
+           |    },
+           |    "remoteUrl": "https://steward-user@dev.azure.com/azure-org/scala-steward-org/_git/scala-steward"
+           |  },
+           |  "pullRequestId": 26,
+           |  "status": "abandoned",
+           |  "creationDate": "2016-11-01T16:30:31.6655471Z",
+           |  "title": "Update cats-effect to 3.3.14",
+           |  "description": "Updates org.typelevel:cats-effect  from 3.3.13 to 3.3.14.",
+           |  "sourceRefName": "refs/heads/update/cats-effect-3.3.14",
+           |  "targetRefName": "refs/heads/main",
+           |  "url": "https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward/pullRequests/26",
+           |  "supportsIterations": true
+           |}""".stripMargin)
+
+    case POST -> Root / "azure-org" / repo.owner / "_apis/git/repositories" / repo.repo / "pullrequests" / "26" / "threads" =>
+      Ok("""
+           |{
+           |   "id":17,
+           |   "publishedDate":"2022-07-24T22:06:00.067Z",
+           |   "lastUpdatedDate":"2022-07-24T22:06:00.067Z",
+           |   "comments":[
+           |      {
+           |         "id":1,
+           |         "parentCommentId":0,
+           |         "content":"Test comment",
+           |         "publishedDate":"2022-07-24T22:06:00.067Z",
+           |         "lastUpdatedDate":"2022-07-24T22:06:00.067Z",
+           |         "lastContentUpdatedDate":"2022-07-24T22:06:00.067Z"
+           |      }
+           |   ],
+           |   "status":"active"
+           |}""".stripMargin)
+
+    case POST -> Root / "azure-org" / repo.owner / "_apis/git/repositories" / repo.repo / "pullrequests" / "26" / "labels" =>
+      Ok("""
+           |{
+           |    "id": "921dbff4-9c00-49d6-9262-ab0d6e4a13f1",
+           |    "name": "dependency-updates",
+           |    "active": true,
+           |    "url": "https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward/pullRequests/26/labels/921dbff4-9c00-49d6-9262-ab0d6e4a13f1"
+           |}""".stripMargin)
+
+  }
+
+  implicit private val client: Client[IO] = Client.fromHttpApp(routes.orNotFound)
+  implicit private val httpJsonClient: HttpJsonClient[IO] = new HttpJsonClient[IO]
+  private val azureRepoCfg = AzureReposConfig(organization = Some("azure-org"))
+
+  private val azureReposApiAlg: AzureReposApiAlg[IO] =
+    new AzureReposApiAlg[IO](
+      apiHost,
+      azureRepoCfg,
+      _ => IO.pure
+    )
+
+  test("getRepo") {
+    val obtained = azureReposApiAlg.getRepo(repo).unsafeRunSync()
+    val expected = RepoOut(
+      "scala-steward",
+      UserOut("scala-steward-org"),
+      None,
+      Uri.unsafeFromString(
+        "https://steward-user@dev.azure.com/azure-org/scala-steward-org/_git/scala-steward"
+      ),
+      Branch("refs/heads/main")
+    )
+    assertEquals(obtained, expected)
+  }
+
+  test("getBranch") {
+    val obtained = azureReposApiAlg.getBranch(repo, Branch("refs/heads/main")).unsafeRunSync()
+    val expected = BranchOut(
+      Branch("main"),
+      CommitOut(Sha1(HexString.unsafeFrom("f55c9900528e548511fbba6874c873d44c5d714c")))
+    )
+    assertEquals(obtained, expected)
+  }
+
+  test("createPullRequest") {
+    val obtained = azureReposApiAlg
+      .createPullRequest(
+        repo,
+        NewPullRequestData(
+          title = "Update cats-effect to 3.3.14",
+          body = "Updates org.typelevel:cats-effect  from 3.3.13 to 3.3.14.",
+          head = "refs/heads/update/cats-effect-3.3.14",
+          base = Branch("refs/heads/main"),
+          labels = List.empty
+        )
+      )
+      .unsafeRunSync()
+
+    val expected = PullRequestOut(
+      uri"https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward/pullRequests/22",
+      PullRequestState.Open,
+      PullRequestNumber(22),
+      "Update cats-effect to 3.3.14"
+    )
+    assertEquals(obtained, expected)
+  }
+
+  test("listPullRequests") {
+    val obtained = azureReposApiAlg
+      .listPullRequests(
+        repo,
+        "refs/heads/update/cats-effect-3.3.14",
+        Branch("refs/heads/main")
+      )
+      .unsafeRunSync()
+
+    val expected = List(
+      PullRequestOut(
+        uri"https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward/pullRequests/26",
+        PullRequestState.Open,
+        PullRequestNumber(26),
+        "Update cats-effect to 3.3.14"
+      )
+    )
+    assertEquals(obtained, expected)
+  }
+
+  test("closePullRequest") {
+    val obtained = azureReposApiAlg.closePullRequest(repo, PullRequestNumber(26)).unsafeRunSync()
+    val expected = PullRequestOut(
+      uri"https://dev.azure.com/azure-org/scala-steward-org/_apis/git/repositories/scala-steward/pullRequests/26",
+      PullRequestState.Closed,
+      PullRequestNumber(26),
+      "Update cats-effect to 3.3.14"
+    )
+
+    assertEquals(obtained, expected)
+  }
+
+  test("commentPullRequest") {
+    val obtained = azureReposApiAlg
+      .commentPullRequest(repo, PullRequestNumber(26), "Test comment")
+      .unsafeRunSync()
+    val expected = Comment("Test comment")
+
+    assertEquals(obtained, expected)
+  }
+
+  test("labelPullRequest") {
+    val obtained =
+      azureReposApiAlg
+        .labelPullRequest(repo, PullRequestNumber(26), List("dependency-updates"))
+        .attempt
+        .unsafeRunSync()
+    assert(obtained.isRight)
+  }
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/azurerepos/UrlTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/azurerepos/UrlTest.scala
new file mode 100644
index 00000000..c89177b7
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/azurerepos/UrlTest.scala
@@ -0,0 +1,69 @@
+package org.scalasteward.core.vcs.azurerepos
+
+import org.http4s.syntax.literals._
+import munit.FunSuite
+import org.scalasteward.core.git.Branch
+import org.scalasteward.core.vcs.data.{PullRequestNumber, Repo}
+
+class UrlTest extends FunSuite {
+  private val url = new Url(uri"https://dev.azure.com", "my-azure-org")
+
+  private val repo = Repo("scala-steward-org", "scala-steward")
+  private val branch = Branch("/refs/heads/main")
+
+  test("getRepo") {
+    assertEquals(
+      url.getRepo(repo).toString,
+      "https://dev.azure.com/my-azure-org/scala-steward-org/" +
+        "_apis%2Fgit%2Frepositories/scala-steward?api-version=7.1-preview.1&includeParent=true"
+    )
+  }
+
+  test("pullRequests") {
+    assertEquals(
+      url.pullRequests(repo).toString,
+      "https://dev.azure.com/my-azure-org/scala-steward-org/" +
+        "_apis%2Fgit%2Frepositories/scala-steward/pullrequests?api-version=7.1-preview.1"
+    )
+  }
+
+  test("getBranch") {
+    assertEquals(
+      url.getBranch(repo, branch).toString,
+      "https://dev.azure.com/my-azure-org/scala-steward-org/" +
+        "_apis%2Fgit%2Frepositories/scala-steward/stats%2Fbranches?api-version=7.1-preview.1&name=main"
+    )
+  }
+
+  test("listPullRequests") {
+    assertEquals(
+      url.listPullRequests(repo, "update/sbt-1.7.1", Branch("main")).toString(),
+      "https://dev.azure.com/my-azure-org/scala-steward-org/_apis%2Fgit%2Frepositories/scala-steward/pullrequests" +
+        "?api-version=7.1-preview.1&searchCriteria.sourceRefName=update/sbt-1.7.1&searchCriteria.targetRefName=main"
+    )
+  }
+
+  test("closePullRequest") {
+    assertEquals(
+      url.closePullRequest(repo, PullRequestNumber(1221)).toString,
+      "https://dev.azure.com/my-azure-org/scala-steward-org/" +
+        "_apis%2Fgit%2Frepositories/scala-steward/pullrequests/1221?api-version=7.1-preview.1"
+    )
+  }
+
+  test("commentPullRequest") {
+    assertEquals(
+      url.commentPullRequest(repo, PullRequestNumber(42)).toString,
+      "https://dev.azure.com/my-azure-org/scala-steward-org/" +
+        "_apis%2Fgit%2Frepositories/scala-steward/pullrequests/42/threads?api-version=7.1-preview.1"
+    )
+  }
+
+  test("labelPullRequest") {
+    assertEquals(
+      url.labelPullRequest(repo, PullRequestNumber(42)).toString,
+      "https://dev.azure.com/my-azure-org/scala-steward-org/" +
+        "_apis%2Fgit%2Frepositories/scala-steward/pullrequests/42/labels?api-version=7.1-preview.1"
+    )
+  }
+}