Skip to content

Commit 25ec4e6

Browse files
authored
Merge pull request #2758 from yaroot/gitea
vcs: add gitea
2 parents b5147ab + f07f46c commit 25ec4e6

File tree

10 files changed

+1665
-5
lines changed

10 files changed

+1665
-5
lines changed

docs/help.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Options and flags:
2727
--sign-commits
2828
Whether to sign commits; default: false
2929
--forge-type <forge-type>
30-
One of azure-repos, bitbucket, bitbucket-server, github, gitlab; default: github
30+
One of azure-repos, bitbucket, bitbucket-server, github, gitlab, gitea; default: github
3131
--vcs-type <forge-type>
3232
deprecated in favor of --forge-type
3333
--forge-api-host <uri>

modules/core/src/main/scala/org/scalasteward/core/application/Config.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ final case class Config(
9292
case ForgeType.BitbucketServer => bitbucketServerCfg
9393
case ForgeType.GitHub => GitHubCfg()
9494
case ForgeType.GitLab => gitLabCfg
95+
case ForgeType.Gitea => GiteaCfg()
9596
}
9697
}
9798

@@ -160,6 +161,9 @@ object Config {
160161
requiredReviewers: Option[Int]
161162
) extends ForgeSpecificCfg
162163

164+
final case class GiteaCfg(
165+
) extends ForgeSpecificCfg
166+
163167
sealed trait StewardUsage
164168
object StewardUsage {
165169
final case class Regular(config: Config) extends StewardUsage

modules/core/src/main/scala/org/scalasteward/core/forge/ForgeSelection.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import org.scalasteward.core.forge.azurerepos.AzureReposApiAlg
2727
import org.scalasteward.core.forge.bitbucket.BitbucketApiAlg
2828
import org.scalasteward.core.forge.bitbucketserver.BitbucketServerApiAlg
2929
import org.scalasteward.core.forge.data.AuthenticatedUser
30+
import org.scalasteward.core.forge.gitea.GiteaApiAlg
3031
import org.scalasteward.core.forge.github.GitHubApiAlg
3132
import org.scalasteward.core.forge.gitlab.GitLabApiAlg
3233
import org.scalasteward.core.util.HttpJsonClient
@@ -55,6 +56,8 @@ object ForgeSelection {
5556
new GitHubApiAlg(forgeCfg.apiHost, auth)
5657
case specificCfg: Config.GitLabCfg =>
5758
new GitLabApiAlg(forgeCfg, specificCfg, auth)
59+
case _: Config.GiteaCfg =>
60+
new GiteaApiAlg(forgeCfg, auth)
5861
}
5962
}
6063

@@ -68,6 +71,7 @@ object ForgeSelection {
6871
case BitbucketServer => _.putHeaders(basicAuth(user), xAtlassianToken).pure[F]
6972
case GitHub => _.putHeaders(basicAuth(user)).pure[F]
7073
case GitLab => _.putHeaders(Header.Raw(ci"Private-Token", user.accessToken)).pure[F]
74+
case Gitea => _.putHeaders(basicAuth(user)).pure[F]
7175
}
7276

7377
private def basicAuth(user: AuthenticatedUser): Authorization =

modules/core/src/main/scala/org/scalasteward/core/forge/ForgeType.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ sealed trait ForgeType extends Product with Serializable {
3333
case BitbucketServer => "bitbucket-server"
3434
case GitHub => "github"
3535
case GitLab => "gitlab"
36+
case Gitea => "gitea"
3637
}
3738
}
3839

@@ -64,7 +65,11 @@ object ForgeType {
6465
val publicApiBaseUrl = uri"https://gitlab.com/api/v4"
6566
}
6667

67-
val all: List[ForgeType] = List(AzureRepos, Bitbucket, BitbucketServer, GitHub, GitLab)
68+
case object Gitea extends ForgeType {
69+
override val publicWebHost: Option[String] = None
70+
}
71+
72+
val all: List[ForgeType] = List(AzureRepos, Bitbucket, BitbucketServer, GitHub, GitLab, Gitea)
6873

6974
def allNot(f: ForgeType => Boolean): String =
7075
ForgeType.all.filterNot(f).map(_.asString).mkString(", ")
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
/*
2+
* Copyright 2018-2023 Scala Steward contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.scalasteward.core.forge.gitea
18+
19+
import cats._
20+
import cats.implicits._
21+
import org.scalasteward.core.git.Branch
22+
import org.scalasteward.core.git.Sha1
23+
import org.scalasteward.core.util.HttpJsonClient
24+
import org.http4s.{Request, Uri}
25+
import org.scalasteward.core.application.Config.ForgeCfg
26+
import org.scalasteward.core.data.Repo
27+
import org.scalasteward.core.forge.ForgeApiAlg
28+
import org.scalasteward.core.forge.data._
29+
30+
// docs
31+
// - https://docs.gitea.io/en-us/api-usage/
32+
// - https://try.gitea.io/api/swagger
33+
// - https://codeberg.org/api/swagger
34+
object GiteaApiAlg {
35+
import io.circe._
36+
import io.circe.generic.semiauto.deriveCodec
37+
import org.scalasteward.core.util.uri._
38+
implicit val uriEncoder: Encoder[Uri] = Encoder[String].contramap[Uri](_.renderString)
39+
40+
val DefaultLabelColor = "#e01060"
41+
42+
case class CreateForkOption(
43+
name: Option[String], // name of the forked repository
44+
organization: Option[String] // organization name, if forking into an organization
45+
)
46+
implicit val createForkOptionCodec: Encoder[CreateForkOption] = deriveCodec
47+
48+
case class User(
49+
login: String,
50+
id: Long
51+
)
52+
implicit val userCodec: Codec[User] = deriveCodec
53+
54+
case class Repository(
55+
fork: Boolean,
56+
id: Long,
57+
owner: User,
58+
name: String,
59+
archived: Boolean,
60+
clone_url: Uri,
61+
default_branch: String,
62+
parent: Option[Repository]
63+
)
64+
implicit val repositoryCodec: Codec[Repository] = deriveCodec
65+
66+
case class PayloadCommit(id: Sha1)
67+
implicit val payloadCommitCommit: Codec[PayloadCommit] = deriveCodec
68+
69+
case class BranchResp(commit: PayloadCommit)
70+
implicit val branchRespCodec: Codec[BranchResp] = deriveCodec
71+
72+
case class PRBranchInfo(label: String, ref: String, sha: Sha1)
73+
implicit val prBranchInfoCodec: Codec[PRBranchInfo] = deriveCodec
74+
75+
case class PullRequestResp(
76+
html_url: Uri,
77+
state: String, // open/closed/all
78+
number: Int,
79+
title: String,
80+
base: PRBranchInfo,
81+
head: PRBranchInfo
82+
)
83+
implicit val pullRequestRespCodec: Codec[PullRequestResp] = deriveCodec
84+
85+
case class EditPullRequestOption(state: String)
86+
implicit val editPullRequestOptionCodec: Codec[EditPullRequestOption] = deriveCodec
87+
88+
case class CreatePullRequestOption(
89+
assignee: Option[String],
90+
assignees: Option[Vector[String]],
91+
base: Option[String],
92+
body: Option[String],
93+
due_date: Option[String],
94+
head: Option[String],
95+
labels: Option[Vector[Int]],
96+
milestone: Option[Int],
97+
title: Option[String]
98+
)
99+
implicit val createPullRequestOptionCodec: Codec[CreatePullRequestOption] = deriveCodec
100+
101+
case class CreateIssueCommentOption(body: String)
102+
implicit val createIssueCommentOptionCodec: Codec[CreateIssueCommentOption] = deriveCodec
103+
104+
case class CommentResp(
105+
body: String,
106+
id: Long
107+
)
108+
implicit val commentRespCodec: Codec[CommentResp] = deriveCodec
109+
110+
case class Label(id: Int, name: String)
111+
implicit val labelCodec: Codec[Label] = deriveCodec
112+
113+
case class CreateLabelReq(name: String, color: String)
114+
implicit val createLabelReqCodec: Codec[CreateLabelReq] = deriveCodec
115+
116+
case class AttachLabelReq(labels: Vector[Int])
117+
implicit val attachLabelReqCodec: Codec[AttachLabelReq] = deriveCodec
118+
}
119+
120+
final class GiteaApiAlg[F[_]: MonadThrow: HttpJsonClient](
121+
vcs: ForgeCfg,
122+
modify: Repo => Request[F] => F[Request[F]]
123+
) extends ForgeApiAlg[F] {
124+
import GiteaApiAlg._
125+
126+
def client: HttpJsonClient[F] = implicitly
127+
val url = new Url(vcs.apiHost)
128+
129+
val PULL_REQUEST_PAGE_SIZE: Int = 50 // default
130+
131+
def repoOut(r: Repository): RepoOut =
132+
RepoOut(
133+
name = r.name,
134+
owner = UserOut(r.owner.login),
135+
parent = r.parent.map(repoOut),
136+
clone_url = r.clone_url,
137+
default_branch = Branch(r.default_branch),
138+
archived = r.archived
139+
)
140+
141+
def pullRequestOut(x: PullRequestResp): PullRequestOut = {
142+
val state = x.state match {
143+
case "open" => PullRequestState.Open
144+
case _ => PullRequestState.Closed
145+
}
146+
PullRequestOut(
147+
html_url = x.html_url,
148+
state = state,
149+
number = PullRequestNumber(x.number),
150+
title = x.title
151+
)
152+
}
153+
154+
override def createFork(repo: Repo): F[RepoOut] =
155+
client
156+
.postWithBody[Repository, CreateForkOption](
157+
url.forks(repo),
158+
CreateForkOption(name = none, organization = none),
159+
modify(repo)
160+
)
161+
.map(repoOut(_))
162+
163+
override def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut] =
164+
for {
165+
labels <- getOrCreateLabel(repo, data.labels.toVector)
166+
create = CreatePullRequestOption(
167+
assignee = none,
168+
assignees = none,
169+
base = data.base.name.some,
170+
body = data.body.some,
171+
due_date = none,
172+
head = data.head.some,
173+
labels = labels.some,
174+
milestone = none,
175+
title = data.title.some
176+
)
177+
resp <- client
178+
.postWithBody[PullRequestResp, CreatePullRequestOption](
179+
url.pulls(repo),
180+
create,
181+
modify(repo)
182+
)
183+
} yield pullRequestOut(resp)
184+
185+
override def closePullRequest(repo: Repo, number: PullRequestNumber): F[PullRequestOut] = {
186+
val edit = EditPullRequestOption(state = "closed")
187+
client
188+
.patchWithBody[PullRequestResp, EditPullRequestOption](
189+
url.pull(repo, number),
190+
edit,
191+
modify(repo)
192+
)
193+
.map(pullRequestOut(_))
194+
}
195+
196+
override def getBranch(repo: Repo, branch: Branch): F[BranchOut] =
197+
client
198+
.get[BranchResp](url.repoBranch(repo, branch), modify(repo))
199+
.map { b =>
200+
BranchOut(branch, CommitOut(b.commit.id))
201+
}
202+
203+
override def getRepo(repo: Repo): F[RepoOut] =
204+
client
205+
.get[Repository](url.repos(repo), modify(repo))
206+
.map(repoOut(_))
207+
208+
override def listPullRequests(
209+
repo: Repo,
210+
head: String,
211+
base: Branch
212+
): F[List[PullRequestOut]] = {
213+
def go(page: Int) =
214+
client
215+
.get[Vector[PullRequestResp]](
216+
url
217+
.pulls(repo)
218+
.withQueryParam("page", page)
219+
.withQueryParam("limit", PULL_REQUEST_PAGE_SIZE),
220+
modify(repo)
221+
)
222+
223+
// basically unfoldEval
224+
def goLoop(page: Int, accu: Vector[PullRequestOut]): F[Vector[PullRequestOut]] =
225+
go(page).flatMap {
226+
case xs if xs.isEmpty => accu.pure[F]
227+
case xs =>
228+
val xs0 =
229+
xs.filter(x => x.head.label == head && x.base.label == base.name)
230+
.map(pullRequestOut)
231+
goLoop(page + 1, accu ++ xs0)
232+
}
233+
234+
goLoop(1, Vector.empty).map(_.toList)
235+
}
236+
237+
override def commentPullRequest(
238+
repo: Repo,
239+
number: PullRequestNumber,
240+
comment: String
241+
): F[Comment] = {
242+
val create = CreateIssueCommentOption(body = comment)
243+
client
244+
.postWithBody[CommentResp, CreateIssueCommentOption](
245+
url.comments(repo, number),
246+
create,
247+
modify(repo)
248+
)
249+
.map { x =>
250+
Comment(x.body)
251+
}
252+
}
253+
254+
override def labelPullRequest(
255+
repo: Repo,
256+
number: PullRequestNumber,
257+
labels: List[String]
258+
): F[Unit] = {
259+
def attachLabels(labels: Vector[Int]): F[Unit] =
260+
if (labels.nonEmpty)
261+
client
262+
.postWithBody[Vector[Label], AttachLabelReq](
263+
url.pullRequestLabels(repo, number),
264+
AttachLabelReq(labels),
265+
modify(repo)
266+
)
267+
.void
268+
else ().pure[F]
269+
270+
getOrCreateLabel(repo, labels.toVector) >>= attachLabels
271+
}
272+
273+
def getOrCreateLabel(repo: Repo, labels: Vector[String]): F[Vector[Int]] =
274+
listLabels(repo).flatMap { repoLabels =>
275+
val existing = repoLabels.filter(label => labels.contains(label.name))
276+
val creates =
277+
labels
278+
.filter(name => existing.exists(_.name == name))
279+
.traverse(createLabel(repo, _))
280+
281+
creates.map(_ ++ existing).map(_.map(_.id))
282+
}
283+
284+
def createLabel(repo: Repo, name: String): F[Label] =
285+
client.postWithBody[Label, CreateLabelReq](
286+
url.labels(repo),
287+
CreateLabelReq(name, DefaultLabelColor),
288+
modify(repo)
289+
)
290+
291+
def listLabels(repo: Repo): F[Vector[Label]] = {
292+
def paging(page: Int) =
293+
client.get[Vector[Label]](
294+
url.labels(repo).withQueryParam("page", page),
295+
modify(repo)
296+
)
297+
298+
def go(page: Int, accu: Vector[Label]): F[Vector[Label]] =
299+
paging(page).flatMap {
300+
case Vector() => accu.pure[F]
301+
case labels => go(page + 1, accu ++ labels)
302+
}
303+
go(1, Vector.empty)
304+
}
305+
}

0 commit comments

Comments
 (0)