Repository: scala-steward-org/scala-steward
Pull request #2758 (2022-11-07 05:10:59)

vcs: add gitea
still WIP

Feature-wise
- [x] do-not-fork
- [x] fork
- [x] label

fthomas: @yaroot I would welcome Gitea/Forgejo support in Scala Steward.

Note that it is not required to support forking and labels for this support to be merged. The Bitbucket Server integration for example also [does not support forking and labels](https://github.com/scala-steward-org/scala-steward/blob/c1aa49d38b3d5fa6adf63f59de240680d7f9135b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala#L51-L55) (yet).
yaroot: @fthomas 
Oh I wasn't aware of that, let me see if I can find some time this weekend.

Patch:
diff --git a/docs/help.md b/docs/help.md
index 543f60de..c98155f6 100644
--- a/docs/help.md
+++ b/docs/help.md
@@ -27,7 +27,7 @@ Options and flags:
     --sign-commits
         Whether to sign commits; default: false
     --forge-type <forge-type>
-        One of azure-repos, bitbucket, bitbucket-server, github, gitlab; default: github
+        One of azure-repos, bitbucket, bitbucket-server, github, gitlab, gitea; default: github
     --vcs-type <forge-type>
         deprecated in favor of --forge-type
     --forge-api-host <uri>
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 f5be851b..2ced3f8a 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
@@ -92,6 +92,7 @@ final case class Config(
       case ForgeType.BitbucketServer => bitbucketServerCfg
       case ForgeType.GitHub          => GitHubCfg()
       case ForgeType.GitLab          => gitLabCfg
+      case ForgeType.Gitea           => GiteaCfg()
     }
 }
 
@@ -160,6 +161,9 @@ object Config {
       requiredReviewers: Option[Int]
   ) extends ForgeSpecificCfg
 
+  final case class GiteaCfg(
+  ) extends ForgeSpecificCfg
+
   sealed trait StewardUsage
   object StewardUsage {
     final case class Regular(config: Config) extends StewardUsage
diff --git a/modules/core/src/main/scala/org/scalasteward/core/forge/ForgeSelection.scala b/modules/core/src/main/scala/org/scalasteward/core/forge/ForgeSelection.scala
index fdff6bce..0a3d0b5e 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/forge/ForgeSelection.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/forge/ForgeSelection.scala
@@ -27,6 +27,7 @@ import org.scalasteward.core.forge.azurerepos.AzureReposApiAlg
 import org.scalasteward.core.forge.bitbucket.BitbucketApiAlg
 import org.scalasteward.core.forge.bitbucketserver.BitbucketServerApiAlg
 import org.scalasteward.core.forge.data.AuthenticatedUser
+import org.scalasteward.core.forge.gitea.GiteaApiAlg
 import org.scalasteward.core.forge.github.GitHubApiAlg
 import org.scalasteward.core.forge.gitlab.GitLabApiAlg
 import org.scalasteward.core.util.HttpJsonClient
@@ -55,6 +56,8 @@ object ForgeSelection {
         new GitHubApiAlg(forgeCfg.apiHost, auth)
       case specificCfg: Config.GitLabCfg =>
         new GitLabApiAlg(forgeCfg, specificCfg, auth)
+      case _: Config.GiteaCfg =>
+        new GiteaApiAlg(forgeCfg, auth)
     }
   }
 
@@ -68,6 +71,7 @@ object ForgeSelection {
       case BitbucketServer => _.putHeaders(basicAuth(user), xAtlassianToken).pure[F]
       case GitHub          => _.putHeaders(basicAuth(user)).pure[F]
       case GitLab          => _.putHeaders(Header.Raw(ci"Private-Token", user.accessToken)).pure[F]
+      case Gitea           => _.putHeaders(basicAuth(user)).pure[F]
     }
 
   private def basicAuth(user: AuthenticatedUser): Authorization =
diff --git a/modules/core/src/main/scala/org/scalasteward/core/forge/ForgeType.scala b/modules/core/src/main/scala/org/scalasteward/core/forge/ForgeType.scala
index 06639505..a86c7d2a 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/forge/ForgeType.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/forge/ForgeType.scala
@@ -33,6 +33,7 @@ sealed trait ForgeType extends Product with Serializable {
     case BitbucketServer => "bitbucket-server"
     case GitHub          => "github"
     case GitLab          => "gitlab"
+    case Gitea           => "gitea"
   }
 }
 
@@ -64,7 +65,11 @@ object ForgeType {
     val publicApiBaseUrl = uri"https://gitlab.com/api/v4"
   }
 
-  val all: List[ForgeType] = List(AzureRepos, Bitbucket, BitbucketServer, GitHub, GitLab)
+  case object Gitea extends ForgeType {
+    override val publicWebHost: Option[String] = None
+  }
+
+  val all: List[ForgeType] = List(AzureRepos, Bitbucket, BitbucketServer, GitHub, GitLab, Gitea)
 
   def allNot(f: ForgeType => Boolean): String =
     ForgeType.all.filterNot(f).map(_.asString).mkString(", ")
diff --git a/modules/core/src/main/scala/org/scalasteward/core/forge/gitea/GiteaApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/forge/gitea/GiteaApiAlg.scala
new file mode 100644
index 00000000..9c7b904b
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/forge/gitea/GiteaApiAlg.scala
@@ -0,0 +1,305 @@
+/*
+ * 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.forge.gitea
+
+import cats._
+import cats.implicits._
+import org.scalasteward.core.git.Branch
+import org.scalasteward.core.git.Sha1
+import org.scalasteward.core.util.HttpJsonClient
+import org.http4s.{Request, Uri}
+import org.scalasteward.core.application.Config.ForgeCfg
+import org.scalasteward.core.data.Repo
+import org.scalasteward.core.forge.ForgeApiAlg
+import org.scalasteward.core.forge.data._
+
+// docs
+// - https://docs.gitea.io/en-us/api-usage/
+// - https://try.gitea.io/api/swagger
+// - https://codeberg.org/api/swagger
+object GiteaApiAlg {
+  import io.circe._
+  import io.circe.generic.semiauto.deriveCodec
+  import org.scalasteward.core.util.uri._
+  implicit val uriEncoder: Encoder[Uri] = Encoder[String].contramap[Uri](_.renderString)
+
+  val DefaultLabelColor = "#e01060"
+
+  case class CreateForkOption(
+      name: Option[String], // name of the forked repository
+      organization: Option[String] // organization name, if forking into an organization
+  )
+  implicit val createForkOptionCodec: Encoder[CreateForkOption] = deriveCodec
+
+  case class User(
+      login: String,
+      id: Long
+  )
+  implicit val userCodec: Codec[User] = deriveCodec
+
+  case class Repository(
+      fork: Boolean,
+      id: Long,
+      owner: User,
+      name: String,
+      archived: Boolean,
+      clone_url: Uri,
+      default_branch: String,
+      parent: Option[Repository]
+  )
+  implicit val repositoryCodec: Codec[Repository] = deriveCodec
+
+  case class PayloadCommit(id: Sha1)
+  implicit val payloadCommitCommit: Codec[PayloadCommit] = deriveCodec
+
+  case class BranchResp(commit: PayloadCommit)
+  implicit val branchRespCodec: Codec[BranchResp] = deriveCodec
+
+  case class PRBranchInfo(label: String, ref: String, sha: Sha1)
+  implicit val prBranchInfoCodec: Codec[PRBranchInfo] = deriveCodec
+
+  case class PullRequestResp(
+      html_url: Uri,
+      state: String, // open/closed/all
+      number: Int,
+      title: String,
+      base: PRBranchInfo,
+      head: PRBranchInfo
+  )
+  implicit val pullRequestRespCodec: Codec[PullRequestResp] = deriveCodec
+
+  case class EditPullRequestOption(state: String)
+  implicit val editPullRequestOptionCodec: Codec[EditPullRequestOption] = deriveCodec
+
+  case class CreatePullRequestOption(
+      assignee: Option[String],
+      assignees: Option[Vector[String]],
+      base: Option[String],
+      body: Option[String],
+      due_date: Option[String],
+      head: Option[String],
+      labels: Option[Vector[Int]],
+      milestone: Option[Int],
+      title: Option[String]
+  )
+  implicit val createPullRequestOptionCodec: Codec[CreatePullRequestOption] = deriveCodec
+
+  case class CreateIssueCommentOption(body: String)
+  implicit val createIssueCommentOptionCodec: Codec[CreateIssueCommentOption] = deriveCodec
+
+  case class CommentResp(
+      body: String,
+      id: Long
+  )
+  implicit val commentRespCodec: Codec[CommentResp] = deriveCodec
+
+  case class Label(id: Int, name: String)
+  implicit val labelCodec: Codec[Label] = deriveCodec
+
+  case class CreateLabelReq(name: String, color: String)
+  implicit val createLabelReqCodec: Codec[CreateLabelReq] = deriveCodec
+
+  case class AttachLabelReq(labels: Vector[Int])
+  implicit val attachLabelReqCodec: Codec[AttachLabelReq] = deriveCodec
+}
+
+final class GiteaApiAlg[F[_]: MonadThrow: HttpJsonClient](
+    vcs: ForgeCfg,
+    modify: Repo => Request[F] => F[Request[F]]
+) extends ForgeApiAlg[F] {
+  import GiteaApiAlg._
+
+  def client: HttpJsonClient[F] = implicitly
+  val url = new Url(vcs.apiHost)
+
+  val PULL_REQUEST_PAGE_SIZE: Int = 50 // default
+
+  def repoOut(r: Repository): RepoOut =
+    RepoOut(
+      name = r.name,
+      owner = UserOut(r.owner.login),
+      parent = r.parent.map(repoOut),
+      clone_url = r.clone_url,
+      default_branch = Branch(r.default_branch),
+      archived = r.archived
+    )
+
+  def pullRequestOut(x: PullRequestResp): PullRequestOut = {
+    val state = x.state match {
+      case "open" => PullRequestState.Open
+      case _      => PullRequestState.Closed
+    }
+    PullRequestOut(
+      html_url = x.html_url,
+      state = state,
+      number = PullRequestNumber(x.number),
+      title = x.title
+    )
+  }
+
+  override def createFork(repo: Repo): F[RepoOut] =
+    client
+      .postWithBody[Repository, CreateForkOption](
+        url.forks(repo),
+        CreateForkOption(name = none, organization = none),
+        modify(repo)
+      )
+      .map(repoOut(_))
+
+  override def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut] =
+    for {
+      labels <- getOrCreateLabel(repo, data.labels.toVector)
+      create = CreatePullRequestOption(
+        assignee = none,
+        assignees = none,
+        base = data.base.name.some,
+        body = data.body.some,
+        due_date = none,
+        head = data.head.some,
+        labels = labels.some,
+        milestone = none,
+        title = data.title.some
+      )
+      resp <- client
+        .postWithBody[PullRequestResp, CreatePullRequestOption](
+          url.pulls(repo),
+          create,
+          modify(repo)
+        )
+    } yield pullRequestOut(resp)
+
+  override def closePullRequest(repo: Repo, number: PullRequestNumber): F[PullRequestOut] = {
+    val edit = EditPullRequestOption(state = "closed")
+    client
+      .patchWithBody[PullRequestResp, EditPullRequestOption](
+        url.pull(repo, number),
+        edit,
+        modify(repo)
+      )
+      .map(pullRequestOut(_))
+  }
+
+  override def getBranch(repo: Repo, branch: Branch): F[BranchOut] =
+    client
+      .get[BranchResp](url.repoBranch(repo, branch), modify(repo))
+      .map { b =>
+        BranchOut(branch, CommitOut(b.commit.id))
+      }
+
+  override def getRepo(repo: Repo): F[RepoOut] =
+    client
+      .get[Repository](url.repos(repo), modify(repo))
+      .map(repoOut(_))
+
+  override def listPullRequests(
+      repo: Repo,
+      head: String,
+      base: Branch
+  ): F[List[PullRequestOut]] = {
+    def go(page: Int) =
+      client
+        .get[Vector[PullRequestResp]](
+          url
+            .pulls(repo)
+            .withQueryParam("page", page)
+            .withQueryParam("limit", PULL_REQUEST_PAGE_SIZE),
+          modify(repo)
+        )
+
+    // basically unfoldEval
+    def goLoop(page: Int, accu: Vector[PullRequestOut]): F[Vector[PullRequestOut]] =
+      go(page).flatMap {
+        case xs if xs.isEmpty => accu.pure[F]
+        case xs =>
+          val xs0 =
+            xs.filter(x => x.head.label == head && x.base.label == base.name)
+              .map(pullRequestOut)
+          goLoop(page + 1, accu ++ xs0)
+      }
+
+    goLoop(1, Vector.empty).map(_.toList)
+  }
+
+  override def commentPullRequest(
+      repo: Repo,
+      number: PullRequestNumber,
+      comment: String
+  ): F[Comment] = {
+    val create = CreateIssueCommentOption(body = comment)
+    client
+      .postWithBody[CommentResp, CreateIssueCommentOption](
+        url.comments(repo, number),
+        create,
+        modify(repo)
+      )
+      .map { x =>
+        Comment(x.body)
+      }
+  }
+
+  override def labelPullRequest(
+      repo: Repo,
+      number: PullRequestNumber,
+      labels: List[String]
+  ): F[Unit] = {
+    def attachLabels(labels: Vector[Int]): F[Unit] =
+      if (labels.nonEmpty)
+        client
+          .postWithBody[Vector[Label], AttachLabelReq](
+            url.pullRequestLabels(repo, number),
+            AttachLabelReq(labels),
+            modify(repo)
+          )
+          .void
+      else ().pure[F]
+
+    getOrCreateLabel(repo, labels.toVector) >>= attachLabels
+  }
+
+  def getOrCreateLabel(repo: Repo, labels: Vector[String]): F[Vector[Int]] =
+    listLabels(repo).flatMap { repoLabels =>
+      val existing = repoLabels.filter(label => labels.contains(label.name))
+      val creates =
+        labels
+          .filter(name => existing.exists(_.name == name))
+          .traverse(createLabel(repo, _))
+
+      creates.map(_ ++ existing).map(_.map(_.id))
+    }
+
+  def createLabel(repo: Repo, name: String): F[Label] =
+    client.postWithBody[Label, CreateLabelReq](
+      url.labels(repo),
+      CreateLabelReq(name, DefaultLabelColor),
+      modify(repo)
+    )
+
+  def listLabels(repo: Repo): F[Vector[Label]] = {
+    def paging(page: Int) =
+      client.get[Vector[Label]](
+        url.labels(repo).withQueryParam("page", page),
+        modify(repo)
+      )
+
+    def go(page: Int, accu: Vector[Label]): F[Vector[Label]] =
+      paging(page).flatMap {
+        case Vector() => accu.pure[F]
+        case labels   => go(page + 1, accu ++ labels)
+      }
+    go(1, Vector.empty)
+  }
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/forge/gitea/Url.scala b/modules/core/src/main/scala/org/scalasteward/core/forge/gitea/Url.scala
new file mode 100644
index 00000000..ed6c2283
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/forge/gitea/Url.scala
@@ -0,0 +1,49 @@
+/*
+ * 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.forge.gitea
+
+import org.http4s.Uri
+import org.scalasteward.core.git.Branch
+import org.scalasteward.core.data.Repo
+import org.scalasteward.core.forge.data.PullRequestNumber
+
+class Url(apiHost: Uri) {
+  def repoBranch(repo: Repo, branch: Branch): Uri =
+    repos(repo) / "branches" / branch.name
+
+  def forks(repo: Repo): Uri =
+    repos(repo) / "forks"
+
+  def pulls(repo: Repo): Uri =
+    repos(repo) / "pulls"
+
+  def pull(repo: Repo, number: PullRequestNumber): Uri =
+    repos(repo) / "pulls" / number.toString
+
+  def comments(repo: Repo, number: PullRequestNumber): Uri =
+    repos(repo) / "issues" / number.toString / "comments"
+
+  def labels(repo: Repo): Uri =
+    repos(repo) / "labels"
+
+  def pullRequestLabels(repo: Repo, number: PullRequestNumber): Uri =
+    repos(repo) / "issues" / number.toString / "labels"
+
+  def repos(repo: Repo): Uri =
+    apiHost / "repos" / repo.owner / repo.repo
+
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/forge/package.scala b/modules/core/src/main/scala/org/scalasteward/core/forge/package.scala
index 6d1a21fa..95c934aa 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/forge/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/forge/package.scala
@@ -30,7 +30,7 @@ package object forge {
       case GitHub =>
         s"${fork.owner}/${fork.repo}:${updateBranch.name}"
 
-      case GitLab | Bitbucket | BitbucketServer | AzureRepos =>
+      case GitLab | Bitbucket | BitbucketServer | AzureRepos | Gitea =>
         updateBranch.name
     }
 
@@ -42,7 +42,7 @@ package object forge {
       case GitHub =>
         s"${fork.owner}:${updateBranch.name}"
 
-      case GitLab | Bitbucket | BitbucketServer | AzureRepos =>
+      case GitLab | Bitbucket | BitbucketServer | AzureRepos | Gitea =>
         updateBranch.name
     }
 }
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
index 82745fd2..1ca3094a 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/nurture/UpdateInfoUrlFinder.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/UpdateInfoUrlFinder.scala
@@ -92,7 +92,7 @@ object UpdateInfoUrlFinder {
       nextVersion: Version
   ): List[VersionDiff] =
     forgeTypeFromRepoUrl(forgeType, forgeUrl, repoUrl).map {
-      case GitHub | GitLab =>
+      case GitHub | GitLab | Gitea =>
         possibleTags(currentVersion).zip(possibleTags(nextVersion)).map { case (from1, to1) =>
           VersionDiff(repoUrl / "compare" / s"$from1...$to1")
         }
@@ -129,6 +129,7 @@ object UpdateInfoUrlFinder {
         case GitHub | GitLab => List("blob", "master")
         case Bitbucket       => List("master")
         case BitbucketServer => List("browse")
+        case Gitea           => List("src", "branch", "master")
       }
 
       val repoFiles = maybeSegments.toList.flatMap { segments =>


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/forge/gitea/GiteaApiAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/forge/gitea/GiteaApiAlgTest.scala
new file mode 100644
index 00000000..85c51fd1
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/forge/gitea/GiteaApiAlgTest.scala
@@ -0,0 +1,1243 @@
+package org.scalasteward.core.forge.gitea
+
+import cats.syntax.semigroupk._
+import io.circe.literal._
+import munit.CatsEffectSuite
+import org.http4s.circe._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.headers.Authorization
+import org.http4s.implicits._
+import org.http4s.{BasicCredentials, HttpApp}
+import org.scalasteward.core.TestInstances.ioLogger
+import org.scalasteward.core.application.Config.GiteaCfg
+import org.scalasteward.core.data.Repo
+import org.scalasteward.core.forge.data._
+import org.scalasteward.core.forge.{ForgeSelection, ForgeType}
+import org.scalasteward.core.git.{Branch, Sha1}
+import org.scalasteward.core.mock.MockConfig.config
+import org.scalasteward.core.mock.MockContext.context.httpJsonClient
+import org.scalasteward.core.mock.{MockEff, MockState}
+
+class GiteaApiAlgTest extends CatsEffectSuite with Http4sDsl[MockEff] {
+  private val user = AuthenticatedUser("user", "pass")
+  private val repo = Repo("foo", "baz")
+
+  private val basicAuth = Authorization(BasicCredentials(user.login, user.accessToken))
+  private val auth = HttpApp[MockEff] { request =>
+    (request: @unchecked) match {
+      case _ if !request.headers.get[Authorization].contains(basicAuth) => Forbidden()
+    }
+  }
+
+  object PageQ extends QueryParamDecoderMatcher[Int]("page")
+
+  private val httpApp = HttpApp[MockEff] {
+    case GET -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo =>
+      Ok(getRepo)
+    case GET -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo / "branches" / "main" =>
+      Ok(getBranch)
+    case GET -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo / "pulls" :? PageQ(page) =>
+      if (page == 1) Ok(listPulls) else Ok(json"[]")
+    case PATCH -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo / "pulls" / "1" =>
+      Ok(closePull1)
+    case POST -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo / "issues" / "1" / "comments" =>
+      Created(commentPR1)
+    case POST -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo / "pulls" =>
+      Created(createPR2)
+    case GET -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo / "labels" :? PageQ(page) =>
+      if (page == 1) Ok(listLabels) else Ok(json"[]")
+    case POST -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo / "labels" =>
+      Created(createLabel)
+    case POST -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo / "issues" / "2" / "labels" =>
+      Ok(listLabels)
+    case POST -> Root / "api" / "v1" / "repos" / repo.owner / repo.repo / "forks" =>
+      Ok(forkRepo)
+    case _ => NotFound()
+  }
+
+  private val state = MockState.empty.copy(clientResponses = auth <+> httpApp)
+
+  private val forgeCfg = config.forgeCfg.copy(
+    tpe = ForgeType.Gitea,
+    apiHost = config.forgeCfg.apiHost / "api" / "v1"
+  )
+  private val giteaAlg = ForgeSelection.forgeApiAlg[MockEff](forgeCfg, GiteaCfg(), user)
+
+  test("getRepo") {
+    giteaAlg
+      .getRepo(repo)
+      .runA(state)
+      .map { repoOut =>
+        assertEquals(
+          repoOut,
+          RepoOut(
+            name = "baz",
+            owner = UserOut("foo"),
+            parent = None,
+            clone_url = uri"https://git.example.com/foo/baz.git",
+            default_branch = Branch("main")
+          )
+        )
+      }
+  }
+
+  test("getBranch") {
+    val branch = Branch("main")
+    val sha1 = Sha1
+      .from("6b5ec7e2b6eaf45ecb654a9187e1f5874210fca3")
+      .getOrElse(throw new RuntimeException("impossible"))
+    giteaAlg
+      .getBranch(repo, branch)
+      .runA(state)
+      .map { branchOut =>
+        assertEquals(branchOut, BranchOut(name = Branch("main"), commit = CommitOut(sha1)))
+      }
+  }
+
+  test("listPulls") {
+    giteaAlg
+      .listPullRequests(repo, "qux", Branch("main"))
+      .runA(state)
+      .map { pulls =>
+        assertEquals(
+          pulls,
+          List(
+            PullRequestOut(
+              html_url = uri"https://git.example.com/foo/baz/pulls/1",
+              state = PullRequestState.Open,
+              number = PullRequestNumber(1),
+              title = "update README"
+            )
+          )
+        )
+      }
+  }
+
+  test("closePullRequest") {
+    giteaAlg
+      .closePullRequest(repo, PullRequestNumber(1))
+      .runA(state)
+      .map { pull =>
+        val out = PullRequestOut(
+          html_url = uri"https://git.example.com/foo/baz/pulls/1",
+          state = PullRequestState.Closed,
+          number = PullRequestNumber(1),
+          title = "update README"
+        )
+        assertEquals(pull, out)
+      }
+  }
+
+  test("comment pull request") {
+    giteaAlg
+      .commentPullRequest(repo, PullRequestNumber(1), "hi")
+      .runA(state)
+      .map { comment =>
+        assertEquals(comment, Comment("hi"))
+      }
+  }
+
+  test("create pull request") {
+    giteaAlg
+      .createPullRequest(
+        repo,
+        NewPullRequestData(
+          title = "pr test",
+          body = "hi",
+          head = "pr2",
+          base = Branch("main"),
+          labels = Nil
+        )
+      )
+      .runA(state)
+      .map { prOut =>
+        assertEquals(
+          prOut,
+          PullRequestOut(
+            html_url = uri"https://git.example.com/foo/baz/pulls/2",
+            state = PullRequestState.Open,
+            number = PullRequestNumber(2),
+            title = "pr test"
+          )
+        )
+      }
+  }
+
+  test("list labels") {
+    giteaAlg
+      .asInstanceOf[GiteaApiAlg[MockEff]]
+      .listLabels(repo)
+      .runA(state)
+      .map { labels =>
+        assertEquals(
+          labels,
+          Vector(
+            GiteaApiAlg.Label(2, "label1")
+          )
+        )
+      }
+  }
+
+  test("create label") {
+    giteaAlg
+      .asInstanceOf[GiteaApiAlg[MockEff]]
+      .createLabel(repo, "label1")
+      .runA(state)
+      .map { label =>
+        assertEquals(
+          label,
+          GiteaApiAlg.Label(2, "label1")
+        )
+      }
+  }
+
+  test("label pr") {
+    giteaAlg
+      .labelPullRequest(repo, PullRequestNumber(2), List("label1"))
+      .runA(state)
+  }
+
+  test("create fork") {
+    giteaAlg
+      .createFork(repo)
+      .runA(state)
+      .map { repoOut =>
+        val parent = RepoOut(
+          name = "baz",
+          owner = UserOut("foo"),
+          parent = None,
+          clone_url = uri"https://git.example.com/foo/baz.git",
+          default_branch = Branch("main")
+        )
+        assertEquals(
+          repoOut,
+          RepoOut(
+            name = "baz",
+            owner = UserOut("bar"),
+            parent = Some(parent),
+            clone_url = uri"https://git.example.com/bar/baz.git",
+            default_branch = Branch("main")
+          )
+        )
+      }
+  }
+
+  def getRepo =
+    json""" {
+            "id": 5,
+            "owner": {
+              "id": 2,
+              "login": "foo",
+              "login_name": "",
+              "full_name": "",
+              "email": "foo@example.com",
+              "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+              "language": "",
+              "is_admin": false,
+              "last_login": "0001-01-01T00:00:00Z",
+              "created": "2023-01-25T21:48:28+09:00",
+              "restricted": false,
+              "active": false,
+              "prohibit_login": false,
+              "location": "",
+              "website": "",
+              "description": "",
+              "visibility": "private",
+              "followers_count": 0,
+              "following_count": 0,
+              "starred_repos_count": 0,
+              "username": "foo"
+            },
+            "name": "baz",
+            "full_name": "foo/baz",
+            "description": "",
+            "empty": false,
+            "private": false,
+            "fork": false,
+            "template": false,
+            "parent": null,
+            "mirror": false,
+            "size": 93,
+            "language": "",
+            "languages_url": "https://git.example.com/api/v1/repos/foo/baz/languages",
+            "html_url": "https://git.example.com/foo/baz",
+            "ssh_url": "forgejo@git.example.com:foo/baz.git",
+            "clone_url": "https://git.example.com/foo/baz.git",
+            "original_url": "",
+            "website": "",
+            "stars_count": 0,
+            "forks_count": 0,
+            "watchers_count": 1,
+            "open_issues_count": 0,
+            "open_pr_counter": 0,
+            "release_counter": 0,
+            "default_branch": "main",
+            "archived": false,
+            "created_at": "2023-02-07T16:52:32+09:00",
+            "updated_at": "2023-02-07T16:54:32+09:00",
+            "permissions": {
+              "admin": true,
+              "push": true,
+              "pull": true
+            },
+            "has_issues": true,
+            "internal_tracker": {
+              "enable_time_tracker": true,
+              "allow_only_contributors_to_track_time": true,
+              "enable_issue_dependencies": true
+            },
+            "has_wiki": true,
+            "has_pull_requests": true,
+            "has_projects": true,
+            "ignore_whitespace_conflicts": false,
+            "allow_merge_commits": true,
+            "allow_rebase": true,
+            "allow_rebase_explicit": true,
+            "allow_squash_merge": true,
+            "allow_rebase_update": true,
+            "default_delete_branch_after_merge": false,
+            "default_merge_style": "merge",
+            "avatar_url": "",
+            "internal": true,
+            "mirror_interval": "",
+            "mirror_updated": "0001-01-01T00:00:00Z",
+            "repo_transfer": null
+          } """
+
+  def getBranch =
+    json""" {
+            "name": "main",
+            "commit": {
+              "id": "6b5ec7e2b6eaf45ecb654a9187e1f5874210fca3",
+              "message": "add readme\n",
+              "url": "https://git.example.com/foo/baz/commit/6b5ec7e2b6eaf45ecb654a9187e1f5874210fca3",
+              "author": {
+                "name": "foo",
+                "email": "foo@example.com",
+                "username": ""
+              },
+              "committer": {
+                "name": "foo",
+                "email": "foo@example.com",
+                "username": ""
+              },
+              "verification": {
+                "verified": false,
+                "reason": "gpg.error.not_signed_commit",
+                "signature": "",
+                "signer": null,
+                "payload": ""
+              },
+              "timestamp": "2023-02-07T16:54:11+09:00",
+              "added": null,
+              "removed": null,
+              "modified": null
+            },
+            "protected": false,
+            "required_approvals": 0,
+            "enable_status_check": false,
+            "status_check_contexts": [],
+            "user_can_push": true,
+            "user_can_merge": true,
+            "effective_branch_protection_name": ""
+          } """
+
+  def listPulls =
+    json""" [
+            {
+              "id": 1,
+              "url": "https://git.example.com/foo/baz/pulls/1",
+              "number": 1,
+              "user": {
+                "id": 2,
+                "login": "foo",
+                "login_name": "",
+                "full_name": "",
+                "email": "foo@example.com",
+                "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+                "language": "",
+                "is_admin": false,
+                "last_login": "0001-01-01T00:00:00Z",
+                "created": "2023-01-25T21:48:28+09:00",
+                "restricted": false,
+                "active": false,
+                "prohibit_login": false,
+                "location": "",
+                "website": "",
+                "description": "",
+                "visibility": "private",
+                "followers_count": 0,
+                "following_count": 0,
+                "starred_repos_count": 0,
+                "username": "foo"
+              },
+              "title": "update README",
+              "body": "",
+              "labels": [],
+              "milestone": null,
+              "assignee": null,
+              "assignees": null,
+              "state": "open",
+              "is_locked": false,
+              "comments": 0,
+              "html_url": "https://git.example.com/foo/baz/pulls/1",
+              "diff_url": "https://git.example.com/foo/baz/pulls/1.diff",
+              "patch_url": "https://git.example.com/foo/baz/pulls/1.patch",
+              "mergeable": true,
+              "merged": false,
+              "merged_at": null,
+              "merge_commit_sha": null,
+              "merged_by": null,
+              "allow_maintainer_edit": false,
+              "base": {
+                "label": "main",
+                "ref": "main",
+                "sha": "6b5ec7e2b6eaf45ecb654a9187e1f5874210fca3",
+                "repo_id": 5,
+                "repo": {
+                  "id": 5,
+                  "owner": {
+                    "id": 2,
+                    "login": "foo",
+                    "login_name": "",
+                    "full_name": "",
+                    "email": "foo@example.com",
+                    "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+                    "language": "",
+                    "is_admin": false,
+                    "last_login": "0001-01-01T00:00:00Z",
+                    "created": "2023-01-25T21:48:28+09:00",
+                    "restricted": false,
+                    "active": false,
+                    "prohibit_login": false,
+                    "location": "",
+                    "website": "",
+                    "description": "",
+                    "visibility": "private",
+                    "followers_count": 0,
+                    "following_count": 0,
+                    "starred_repos_count": 0,
+                    "username": "foo"
+                  },
+                  "name": "baz",
+                  "full_name": "foo/baz",
+                  "description": "",
+                  "empty": false,
+                  "private": false,
+                  "fork": false,
+                  "template": false,
+                  "parent": null,
+                  "mirror": false,
+                  "size": 105,
+                  "language": "",
+                  "languages_url": "https://git.example.com/api/v1/repos/foo/baz/languages",
+                  "html_url": "https://git.example.com/foo/baz",
+                  "ssh_url": "forgejo@git.example.com:foo/baz.git",
+                  "clone_url": "https://git.example.com/foo/baz.git",
+                  "original_url": "",
+                  "website": "",
+                  "stars_count": 0,
+                  "forks_count": 0,
+                  "watchers_count": 1,
+                  "open_issues_count": 0,
+                  "open_pr_counter": 1,
+                  "release_counter": 0,
+                  "default_branch": "main",
+                  "archived": false,
+                  "created_at": "2023-02-07T16:52:32+09:00",
+                  "updated_at": "2023-02-07T16:58:43+09:00",
+                  "permissions": {
+                    "admin": true,
+                    "push": true,
+                    "pull": true
+                  },
+                  "has_issues": true,
+                  "internal_tracker": {
+                    "enable_time_tracker": true,
+                    "allow_only_contributors_to_track_time": true,
+                    "enable_issue_dependencies": true
+                  },
+                  "has_wiki": true,
+                  "has_pull_requests": true,
+                  "has_projects": true,
+                  "ignore_whitespace_conflicts": false,
+                  "allow_merge_commits": true,
+                  "allow_rebase": true,
+                  "allow_rebase_explicit": true,
+                  "allow_squash_merge": true,
+                  "allow_rebase_update": true,
+                  "default_delete_branch_after_merge": false,
+                  "default_merge_style": "merge",
+                  "avatar_url": "",
+                  "internal": true,
+                  "mirror_interval": "",
+                  "mirror_updated": "0001-01-01T00:00:00Z",
+                  "repo_transfer": null
+                }
+              },
+              "head": {
+                "label": "qux",
+                "ref": "qux",
+                "sha": "291355f22ea6e0179f85ee5ace4af67ab61e0adb",
+                "repo_id": 5,
+                "repo": {
+                  "id": 5,
+                  "owner": {
+                    "id": 2,
+                    "login": "foo",
+                    "login_name": "",
+                    "full_name": "",
+                    "email": "foo@example.com",
+                    "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+                    "language": "",
+                    "is_admin": false,
+                    "last_login": "0001-01-01T00:00:00Z",
+                    "created": "2023-01-25T21:48:28+09:00",
+                    "restricted": false,
+                    "active": false,
+                    "prohibit_login": false,
+                    "location": "",
+                    "website": "",
+                    "description": "",
+                    "visibility": "private",
+                    "followers_count": 0,
+                    "following_count": 0,
+                    "starred_repos_count": 0,
+                    "username": "foo"
+                  },
+                  "name": "baz",
+                  "full_name": "foo/baz",
+                  "description": "",
+                  "empty": false,
+                  "private": false,
+                  "fork": false,
+                  "template": false,
+                  "parent": null,
+                  "mirror": false,
+                  "size": 105,
+                  "language": "",
+                  "languages_url": "https://git.example.com/api/v1/repos/foo/baz/languages",
+                  "html_url": "https://git.example.com/foo/baz",
+                  "ssh_url": "forgejo@git.example.com:foo/baz.git",
+                  "clone_url": "https://git.example.com/foo/baz.git",
+                  "original_url": "",
+                  "website": "",
+                  "stars_count": 0,
+                  "forks_count": 0,
+                  "watchers_count": 1,
+                  "open_issues_count": 0,
+                  "open_pr_counter": 1,
+                  "release_counter": 0,
+                  "default_branch": "main",
+                  "archived": false,
+                  "created_at": "2023-02-07T16:52:32+09:00",
+                  "updated_at": "2023-02-07T16:58:43+09:00",
+                  "permissions": {
+                    "admin": true,
+                    "push": true,
+                    "pull": true
+                  },
+                  "has_issues": true,
+                  "internal_tracker": {
+                    "enable_time_tracker": true,
+                    "allow_only_contributors_to_track_time": true,
+                    "enable_issue_dependencies": true
+                  },
+                  "has_wiki": true,
+                  "has_pull_requests": true,
+                  "has_projects": true,
+                  "ignore_whitespace_conflicts": false,
+                  "allow_merge_commits": true,
+                  "allow_rebase": true,
+                  "allow_rebase_explicit": true,
+                  "allow_squash_merge": true,
+                  "allow_rebase_update": true,
+                  "default_delete_branch_after_merge": false,
+                  "default_merge_style": "merge",
+                  "avatar_url": "",
+                  "internal": true,
+                  "mirror_interval": "",
+                  "mirror_updated": "0001-01-01T00:00:00Z",
+                  "repo_transfer": null
+                }
+              },
+              "merge_base": "6b5ec7e2b6eaf45ecb654a9187e1f5874210fca3",
+              "due_date": null,
+              "created_at": "2023-02-07T16:59:26+09:00",
+              "updated_at": "2023-02-07T16:59:27+09:00",
+              "closed_at": null
+            }
+          ] """
+
+  def closePull1 =
+    json""" {
+            "id": 1,
+            "url": "https://git.example.com/foo/baz/pulls/1",
+            "number": 1,
+            "user": {
+              "id": 2,
+              "login": "foo",
+              "login_name": "",
+              "full_name": "",
+              "email": "foo@example.com",
+              "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+              "language": "",
+              "is_admin": false,
+              "last_login": "0001-01-01T00:00:00Z",
+              "created": "2023-01-25T21:48:28+09:00",
+              "restricted": false,
+              "active": false,
+              "prohibit_login": false,
+              "location": "",
+              "website": "",
+              "description": "",
+              "visibility": "private",
+              "followers_count": 0,
+              "following_count": 0,
+              "starred_repos_count": 0,
+              "username": "foo"
+            },
+            "title": "update README",
+            "body": "",
+            "labels": [],
+            "milestone": null,
+            "assignee": null,
+            "assignees": null,
+            "state": "closed",
+            "is_locked": false,
+            "comments": 0,
+            "html_url": "https://git.example.com/foo/baz/pulls/1",
+            "diff_url": "https://git.example.com/foo/baz/pulls/1.diff",
+            "patch_url": "https://git.example.com/foo/baz/pulls/1.patch",
+            "mergeable": true,
+            "merged": false,
+            "merged_at": null,
+            "merge_commit_sha": null,
+            "merged_by": null,
+            "allow_maintainer_edit": false,
+            "base": {
+              "label": "main",
+              "ref": "main",
+              "sha": "6b5ec7e2b6eaf45ecb654a9187e1f5874210fca3",
+              "repo_id": 5,
+              "repo": {
+                "id": 5,
+                "owner": {
+                  "id": 2,
+                  "login": "foo",
+                  "login_name": "",
+                  "full_name": "",
+                  "email": "foo@example.com",
+                  "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+                  "language": "",
+                  "is_admin": false,
+                  "last_login": "0001-01-01T00:00:00Z",
+                  "created": "2023-01-25T21:48:28+09:00",
+                  "restricted": false,
+                  "active": false,
+                  "prohibit_login": false,
+                  "location": "",
+                  "website": "",
+                  "description": "",
+                  "visibility": "private",
+                  "followers_count": 0,
+                  "following_count": 0,
+                  "starred_repos_count": 0,
+                  "username": "foo"
+                },
+                "name": "baz",
+                "full_name": "foo/baz",
+                "description": "",
+                "empty": false,
+                "private": false,
+                "fork": false,
+                "template": false,
+                "parent": null,
+                "mirror": false,
+                "size": 105,
+                "language": "",
+                "languages_url": "https://git.example.com/api/v1/repos/foo/baz/languages",
+                "html_url": "https://git.example.com/foo/baz",
+                "ssh_url": "forgejo@git.example.com:foo/baz.git",
+                "clone_url": "https://git.example.com/foo/baz.git",
+                "original_url": "",
+                "website": "",
+                "stars_count": 0,
+                "forks_count": 0,
+                "watchers_count": 1,
+                "open_issues_count": 0,
+                "open_pr_counter": 0,
+                "release_counter": 0,
+                "default_branch": "main",
+                "archived": false,
+                "created_at": "2023-02-07T16:52:32+09:00",
+                "updated_at": "2023-02-07T16:58:43+09:00",
+                "permissions": {
+                  "admin": true,
+                  "push": true,
+                  "pull": true
+                },
+                "has_issues": true,
+                "internal_tracker": {
+                  "enable_time_tracker": true,
+                  "allow_only_contributors_to_track_time": true,
+                  "enable_issue_dependencies": true
+                },
+                "has_wiki": true,
+                "has_pull_requests": true,
+                "has_projects": true,
+                "ignore_whitespace_conflicts": false,
+                "allow_merge_commits": true,
+                "allow_rebase": true,
+                "allow_rebase_explicit": true,
+                "allow_squash_merge": true,
+                "allow_rebase_update": true,
+                "default_delete_branch_after_merge": false,
+                "default_merge_style": "merge",
+                "avatar_url": "",
+                "internal": true,
+                "mirror_interval": "",
+                "mirror_updated": "0001-01-01T00:00:00Z",
+                "repo_transfer": null
+              }
+            },
+            "head": {
+              "label": "qux",
+              "ref": "qux",
+              "sha": "291355f22ea6e0179f85ee5ace4af67ab61e0adb",
+              "repo_id": 5,
+              "repo": {
+                "id": 5,
+                "owner": {
+                  "id": 2,
+                  "login": "foo",
+                  "login_name": "",
+                  "full_name": "",
+                  "email": "foo@example.com",
+                  "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+                  "language": "",
+                  "is_admin": false,
+                  "last_login": "0001-01-01T00:00:00Z",
+                  "created": "2023-01-25T21:48:28+09:00",
+                  "restricted": false,
+                  "active": false,
+                  "prohibit_login": false,
+                  "location": "",
+                  "website": "",
+                  "description": "",
+                  "visibility": "private",
+                  "followers_count": 0,
+                  "following_count": 0,
+                  "starred_repos_count": 0,
+                  "username": "foo"
+                },
+                "name": "baz",
+                "full_name": "foo/baz",
+                "description": "",
+                "empty": false,
+                "private": false,
+                "fork": false,
+                "template": false,
+                "parent": null,
+                "mirror": false,
+                "size": 105,
+                "language": "",
+                "languages_url": "https://git.example.com/api/v1/repos/foo/baz/languages",
+                "html_url": "https://git.example.com/foo/baz",
+                "ssh_url": "forgejo@git.example.com:foo/baz.git",
+                "clone_url": "https://git.example.com/foo/baz.git",
+                "original_url": "",
+                "website": "",
+                "stars_count": 0,
+                "forks_count": 0,
+                "watchers_count": 1,
+                "open_issues_count": 0,
+                "open_pr_counter": 0,
+                "release_counter": 0,
+                "default_branch": "main",
+                "archived": false,
+                "created_at": "2023-02-07T16:52:32+09:00",
+                "updated_at": "2023-02-07T16:58:43+09:00",
+                "permissions": {
+                  "admin": true,
+                  "push": true,
+                  "pull": true
+                },
+                "has_issues": true,
+                "internal_tracker": {
+                  "enable_time_tracker": true,
+                  "allow_only_contributors_to_track_time": true,
+                  "enable_issue_dependencies": true
+                },
+                "has_wiki": true,
+                "has_pull_requests": true,
+                "has_projects": true,
+                "ignore_whitespace_conflicts": false,
+                "allow_merge_commits": true,
+                "allow_rebase": true,
+                "allow_rebase_explicit": true,
+                "allow_squash_merge": true,
+                "allow_rebase_update": true,
+                "default_delete_branch_after_merge": false,
+                "default_merge_style": "merge",
+                "avatar_url": "",
+                "internal": true,
+                "mirror_interval": "",
+                "mirror_updated": "0001-01-01T00:00:00Z",
+                "repo_transfer": null
+              }
+            },
+            "merge_base": "6b5ec7e2b6eaf45ecb654a9187e1f5874210fca3",
+            "due_date": null,
+            "created_at": "2023-02-07T16:59:26+09:00",
+            "updated_at": "2023-02-07T17:11:22+09:00",
+            "closed_at": null
+          } """
+
+  def commentPR1 =
+    json""" {
+            "id": 138,
+            "html_url": "https://git.example.com/foo/baz/pulls/1#issuecomment-138",
+            "pull_request_url": "https://git.example.com/foo/baz/pulls/1",
+            "issue_url": "",
+            "user": {
+              "id": 2,
+              "login": "foo",
+              "login_name": "",
+              "full_name": "",
+              "email": "foo@example.com",
+              "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+              "language": "",
+              "is_admin": false,
+              "last_login": "0001-01-01T00:00:00Z",
+              "created": "2023-01-25T21:48:28+09:00",
+              "restricted": false,
+              "active": false,
+              "prohibit_login": false,
+              "location": "",
+              "website": "",
+              "description": "",
+              "visibility": "private",
+              "followers_count": 0,
+              "following_count": 0,
+              "starred_repos_count": 0,
+              "username": "foo"
+            },
+            "original_author": "",
+            "original_author_id": 0,
+            "body": "hi",
+            "created_at": "2023-02-07T17:20:57+09:00",
+            "updated_at": "2023-02-07T17:20:57+09:00"
+          } """
+
+  def createPR2 =
+    json""" {
+      "id": 2,
+      "url": "https://git.example.com/foo/baz/pulls/2",
+      "number": 2,
+      "user": {
+        "id": 2,
+        "login": "foo",
+        "login_name": "",
+        "full_name": "",
+        "email": "foo@example.com",
+        "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+        "language": "",
+        "is_admin": false,
+        "last_login": "0001-01-01T00:00:00Z",
+        "created": "2023-01-25T21:48:28+09:00",
+        "restricted": false,
+        "active": false,
+        "prohibit_login": false,
+        "location": "",
+        "website": "",
+        "description": "",
+        "visibility": "private",
+        "followers_count": 0,
+        "following_count": 0,
+        "starred_repos_count": 0,
+        "username": "foo"
+      },
+      "title": "pr test",
+      "body": "hi",
+      "labels": [],
+      "milestone": null,
+      "assignee": null,
+      "assignees": null,
+      "state": "open",
+      "is_locked": false,
+      "comments": 0,
+      "html_url": "https://git.example.com/foo/baz/pulls/2",
+      "diff_url": "https://git.example.com/foo/baz/pulls/2.diff",
+      "patch_url": "https://git.example.com/foo/baz/pulls/2.patch",
+      "mergeable": true,
+      "merged": false,
+      "merged_at": null,
+      "merge_commit_sha": null,
+      "merged_by": null,
+      "allow_maintainer_edit": false,
+      "base": {
+        "label": "main",
+        "ref": "main",
+        "sha": "6b5ec7e2b6eaf45ecb654a9187e1f5874210fca3",
+        "repo_id": 5,
+        "repo": {
+          "id": 5,
+          "owner": {
+            "id": 2,
+            "login": "foo",
+            "login_name": "",
+            "full_name": "",
+            "email": "foo@example.com",
+            "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+            "language": "",
+            "is_admin": false,
+            "last_login": "0001-01-01T00:00:00Z",
+            "created": "2023-01-25T21:48:28+09:00",
+            "restricted": false,
+            "active": false,
+            "prohibit_login": false,
+            "location": "",
+            "website": "",
+            "description": "",
+            "visibility": "private",
+            "followers_count": 0,
+            "following_count": 0,
+            "starred_repos_count": 0,
+            "username": "foo"
+          },
+          "name": "baz",
+          "full_name": "foo/baz",
+          "description": "",
+          "empty": false,
+          "private": false,
+          "fork": false,
+          "template": false,
+          "parent": null,
+          "mirror": false,
+          "size": 25,
+          "language": "",
+          "languages_url": "https://git.example.com/api/v1/repos/foo/baz/languages",
+          "html_url": "https://git.example.com/foo/baz",
+          "ssh_url": "forgejo@git.example.com:foo/baz.git",
+          "clone_url": "https://git.example.com/foo/baz.git",
+          "original_url": "",
+          "website": "",
+          "stars_count": 0,
+          "forks_count": 1,
+          "watchers_count": 1,
+          "open_issues_count": 0,
+          "open_pr_counter": 1,
+          "release_counter": 0,
+          "default_branch": "main",
+          "archived": false,
+          "created_at": "2023-02-07T16:52:32+09:00",
+          "updated_at": "2023-02-07T17:57:49+09:00",
+          "permissions": {
+            "admin": true,
+            "push": true,
+            "pull": true
+          },
+          "has_issues": true,
+          "internal_tracker": {
+            "enable_time_tracker": true,
+            "allow_only_contributors_to_track_time": true,
+            "enable_issue_dependencies": true
+          },
+          "has_wiki": true,
+          "has_pull_requests": true,
+          "has_projects": true,
+          "ignore_whitespace_conflicts": false,
+          "allow_merge_commits": true,
+          "allow_rebase": true,
+          "allow_rebase_explicit": true,
+          "allow_squash_merge": true,
+          "allow_rebase_update": true,
+          "default_delete_branch_after_merge": false,
+          "default_merge_style": "merge",
+          "avatar_url": "",
+          "internal": true,
+          "mirror_interval": "",
+          "mirror_updated": "0001-01-01T00:00:00Z",
+          "repo_transfer": null
+        }
+      },
+      "head": {
+        "label": "pr2",
+        "ref": "pr2",
+        "sha": "7301e317e8482ad612784f6204206a0204f94aa7",
+        "repo_id": 5,
+        "repo": {
+          "id": 5,
+          "owner": {
+            "id": 2,
+            "login": "foo",
+            "login_name": "",
+            "full_name": "",
+            "email": "foo@example.com",
+            "avatar_url": "https://secure.gravatar.com/avatar/b48def645758b95537d4424c84d1a9ff?d=identicon",
+            "language": "",
+            "is_admin": false,
+            "last_login": "0001-01-01T00:00:00Z",
+            "created": "2023-01-25T21:48:28+09:00",
+            "restricted": false,
+            "active": false,
+            "prohibit_login": false,
+            "location": "",
+            "website": "",
+            "description": "",
+            "visibility": "private",
+            "followers_count": 0,
+            "following_count": 0,
+            "starred_repos_count": 0,
+            "username": "foo"
+          },
+          "name": "baz",
+          "full_name": "foo/baz",
+          "description": "",
+          "empty": false,
+          "private": false,
+          "fork": false,
+          "template": false,
+          "parent": null,
+          "mirror": false,
+          "size": 25,
+          "language": "",
+          "languages_url": "https://git.example.com/api/v1/repos/foo/baz/languages",
+          "html_url": "https://git.example.com/foo/baz",
+          "ssh_url": "forgejo@git.example.com:foo/baz.git",
+          "clone_url": "https://git.example.com/foo/baz.git",
+          "original_url": "",
+          "website": "",
+          "stars_count": 0,
+          "forks_count": 1,
+          "watchers_count": 1,
+          "open_issues_count": 0,
+          "open_pr_counter": 1,
+          "release_counter": 0,
+          "default_branch": "main",
+          "archived": false,
+          "created_at": "2023-02-07T16:52:32+09:00",
+          "updated_at": "2023-02-07T17:57:49+09:00",
+          "permissions": {
+            "admin": true,
+            "push": true,
+            "pull": true
+          },
+          "has_issues": true,
+          "internal_tracker": {
+            "enable_time_tracker": true,
+            "allow_only_contributors_to_track_time": true,
+            "enable_issue_dependencies": true
+          },
+          "has_wiki": true,
+          "has_pull_requests": true,
+          "has_projects": true,
+          "ignore_whitespace_conflicts": false,
+          "allow_merge_commits": true,
+          "allow_rebase": true,
+          "allow_rebase_explicit": true,
+          "allow_squash_merge": true,
+          "allow_rebase_update": true,
+          "default_delete_branch_after_merge": false,
+          "default_merge_style": "merge",
+          "avatar_url": "",
+          "internal": true,
+          "mirror_interval": "",
+          "mirror_updated": "0001-01-01T00:00:00Z",
+          "repo_transfer": null
+        }
+      },
+      "merge_base": "6b5ec7e2b6eaf45ecb654a9187e1f5874210fca3",
+      "due_date": null,
+      "created_at": "2023-02-07T17:59:48+09:00",
+      "updated_at": "2023-02-07T17:59:49+09:00",
+      "closed_at": null
+    } """
+
+  def listLabels =
+    json"""
+       [
+        {
+          "id": 2,
+          "name": "label1",
+          "color": "e01060",
+          "description": "",
+          "url": "https://git.example.com/api/v1/repos/foo/baz/labels/2"
+        }
+      ]
+        """
+
+  def createLabel =
+    json"""
+        {
+          "id": 2,
+          "name": "label1",
+          "color": "e01060",
+          "description": "",
+          "url": "https://git.example.com/api/v1/repos/foo/baz/labels/2"
+        }
+        """
+
+  def forkRepo =
+    json"""
+          {
+      "id": 3,
+      "owner": {
+        "id": 3,
+        "login": "bar",
+        "login_name": "",
+        "full_name": "",
+        "email": "bar@example.com",
+        "avatar_url": "https://git.example.com/avatars/7b12520feeef7972fc17e137111da1e7",
+        "language": "",
+        "is_admin": false,
+        "last_login": "0001-01-01T00:00:00Z",
+        "created": "2023-02-08T02:19:35Z",
+        "restricted": false,
+        "active": false,
+        "prohibit_login": false,
+        "location": "",
+        "website": "",
+        "description": "",
+        "visibility": "public",
+        "followers_count": 0,
+        "following_count": 0,
+        "starred_repos_count": 0,
+        "username": "bar"
+      },
+      "name": "baz",
+      "full_name": "bar/baz",
+      "description": "",
+      "empty": false,
+      "private": false,
+      "fork": true,
+      "template": false,
+      "parent": {
+        "id": 1,
+        "owner": {
+          "id": 2,
+          "login": "foo",
+          "login_name": "",
+          "full_name": "",
+          "email": "foo@example.local",
+          "avatar_url": "https://git.example.com/avatars/dc366aecd29705111fcea5d94b393388",
+          "language": "",
+          "is_admin": false,
+          "last_login": "0001-01-01T00:00:00Z",
+          "created": "2023-02-08T02:19:26Z",
+          "restricted": false,
+          "active": false,
+          "prohibit_login": false,
+          "location": "",
+          "website": "",
+          "description": "",
+          "visibility": "public",
+          "followers_count": 0,
+          "following_count": 0,
+          "starred_repos_count": 0,
+          "username": "foo"
+        },
+        "name": "baz",
+        "full_name": "foo/baz",
+        "description": "",
+        "empty": false,
+        "private": false,
+        "fork": false,
+        "template": false,
+        "parent": null,
+        "mirror": false,
+        "size": 25,
+        "language": "",
+        "languages_url": "https://git.example.com/api/v1/repos/foo/baz/languages",
+        "html_url": "https://git.example.com/foo/baz",
+        "ssh_url": "gitea@git.example.com:foo/baz.git",
+        "clone_url": "https://git.example.com/foo/baz.git",
+        "original_url": "",
+        "website": "",
+        "stars_count": 0,
+        "forks_count": 1,
+        "watchers_count": 1,
+        "open_issues_count": 0,
+        "open_pr_counter": 0,
+        "release_counter": 0,
+        "default_branch": "main",
+        "archived": false,
+        "created_at": "2023-02-08T02:20:02Z",
+        "updated_at": "2023-02-08T02:22:05Z",
+        "permissions": {
+          "admin": true,
+          "push": true,
+          "pull": true
+        },
+        "has_issues": true,
+        "internal_tracker": {
+          "enable_time_tracker": true,
+          "allow_only_contributors_to_track_time": true,
+          "enable_issue_dependencies": true
+        },
+        "has_wiki": true,
+        "has_pull_requests": true,
+        "has_projects": true,
+        "ignore_whitespace_conflicts": false,
+        "allow_merge_commits": true,
+        "allow_rebase": true,
+        "allow_rebase_explicit": true,
+        "allow_squash_merge": true,
+        "allow_rebase_update": true,
+        "default_delete_branch_after_merge": false,
+        "default_merge_style": "merge",
+        "avatar_url": "",
+        "internal": false,
+        "mirror_interval": "",
+        "mirror_updated": "0001-01-01T00:00:00Z",
+        "repo_transfer": null
+      },
+      "mirror": false,
+      "size": 0,
+      "language": "",
+      "languages_url": "https://git.example.com/api/v1/repos/bar/baz/languages",
+      "html_url": "https://git.example.com/bar/baz",
+      "ssh_url": "gitea@git.example.com:bar/baz.git",
+      "clone_url": "https://git.example.com/bar/baz.git",
+      "original_url": "",
+      "website": "",
+      "stars_count": 0,
+      "forks_count": 0,
+      "watchers_count": 0,
+      "open_issues_count": 0,
+      "open_pr_counter": 0,
+      "release_counter": 0,
+      "default_branch": "main",
+      "archived": false,
+      "created_at": "2023-02-08T02:23:28Z",
+      "updated_at": "2023-02-08T02:23:28Z",
+      "permissions": {
+        "admin": true,
+        "push": true,
+        "pull": true
+      },
+      "has_issues": true,
+      "internal_tracker": {
+        "enable_time_tracker": true,
+        "allow_only_contributors_to_track_time": true,
+        "enable_issue_dependencies": true
+      },
+      "has_wiki": true,
+      "has_pull_requests": true,
+      "has_projects": true,
+      "ignore_whitespace_conflicts": false,
+      "allow_merge_commits": true,
+      "allow_rebase": true,
+      "allow_rebase_explicit": true,
+      "allow_squash_merge": true,
+      "allow_rebase_update": true,
+      "default_delete_branch_after_merge": false,
+      "default_merge_style": "merge",
+      "avatar_url": "",
+      "internal": false,
+      "mirror_interval": "",
+      "mirror_updated": "0001-01-01T00:00:00Z",
+      "repo_transfer": null
+    } """
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/forge/gitea/UrlTest.scala b/modules/core/src/test/scala/org/scalasteward/core/forge/gitea/UrlTest.scala
new file mode 100644
index 00000000..1cc3a79d
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/forge/gitea/UrlTest.scala
@@ -0,0 +1,49 @@
+package org.scalasteward.core.forge.gitea
+
+import munit.FunSuite
+import org.http4s.syntax.literals._
+import org.scalasteward.core.data.Repo
+import org.scalasteward.core.forge.data.PullRequestNumber
+import org.scalasteward.core.git.Branch
+
+class UrlTest extends FunSuite {
+  val url = new Url(uri"https://git.example.com/api/v1")
+
+  val repo = Repo("scala-steward-org", "scala-steward")
+  val branch = Branch("main")
+
+  test("repos") {
+    assertEquals(
+      url.repos(repo).toString(),
+      "https://git.example.com/api/v1/repos/scala-steward-org/scala-steward"
+    )
+  }
+
+  test("branch") {
+    assertEquals(
+      url.repoBranch(repo, branch).toString(),
+      "https://git.example.com/api/v1/repos/scala-steward-org/scala-steward/branches/main"
+    )
+  }
+
+  test("forks") {
+    assertEquals(
+      url.forks(repo).toString(),
+      "https://git.example.com/api/v1/repos/scala-steward-org/scala-steward/forks"
+    )
+  }
+
+  test("pulls") {
+    assertEquals(
+      url.pulls(repo).toString(),
+      "https://git.example.com/api/v1/repos/scala-steward-org/scala-steward/pulls"
+    )
+  }
+
+  test("pull") {
+    assertEquals(
+      url.pull(repo, PullRequestNumber(1)).toString(),
+      "https://git.example.com/api/v1/repos/scala-steward-org/scala-steward/pulls/1"
+    )
+  }
+}