Skip to content

Commit c317b87

Browse files
Add ability to add assignees and reviewers for GitLab and GitHub (#2953)
Notable changes: **Gitlab** - Adding reviewers and assignees is possible with "create MR" request by providing lists of **user ids** for reviewers and assignees and it was implemented by - getting usernames from config - fetching user ids by usernames using /users API **GitHub** - GitHub does not support creating PR and add reviewers and assignees in one call - adding was implemented as separate calls. - Team reviewers are specified with `organization/team` **Other** - Azure and BitBucket do not support assignees - warning was added - Azure and BitBucket support reviewers but it was not implemented at the time, instead warning was added saying that implementation is missing for now - Gitea supports both reviewers and assignees, warning was added saying that implementation is missing for now
1 parent 172abe5 commit c317b87

26 files changed

+522
-61
lines changed

docs/repo-specific-configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,16 @@ dependencyOverrides = [
164164
pullRequests = { frequency = "14 day" },
165165
}
166166
]
167+
168+
# Assign people from the list to the pull request or request a review.
169+
# Currently supported only for GitLab and GitHub.
170+
# GitLab users - free version of GitLab only supports one assignee and one reviewer, others will be ignored.
171+
# GitHub users - to request review from a team inside your organisation it should be specified
172+
# like "yourOrg/yourTeam" in `reviewers` config below.
173+
# Please note that only accounts with write access to the repository (Developer role for GitLab) are able
174+
# to add assignees or request reviews. Consequently, it won't work for public @scala-steward instance on GitHub.
175+
assignees = [ "username1", "username2" ]
176+
reviewers = [ "username1", "username2" ]
167177
```
168178

169179
The version information given in the patterns above can be in two formats:

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
package org.scalasteward.core.forge
1818

1919
import cats.syntax.all._
20-
import cats.{Applicative, MonadThrow}
20+
import cats.{Applicative, MonadThrow, Parallel}
2121
import org.http4s.headers.Authorization
2222
import org.http4s.{BasicCredentials, Header, Request}
2323
import org.scalasteward.core.application.Config
@@ -35,7 +35,7 @@ import org.typelevel.ci._
3535
import org.typelevel.log4cats.Logger
3636

3737
object ForgeSelection {
38-
def forgeApiAlg[F[_]](
38+
def forgeApiAlg[F[_]: Parallel](
3939
forgeCfg: ForgeCfg,
4040
forgeSpecificCfg: ForgeSpecificCfg,
4141
user: AuthenticatedUser

modules/core/src/main/scala/org/scalasteward/core/forge/azurerepos/AzureReposApiAlg.scala

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,26 +28,33 @@ import org.scalasteward.core.forge.azurerepos.JsonCodec._
2828
import org.scalasteward.core.forge.data._
2929
import org.scalasteward.core.git.Branch
3030
import org.scalasteward.core.util.HttpJsonClient
31+
import org.typelevel.log4cats.Logger
3132

3233
final class AzureReposApiAlg[F[_]](
3334
azureAPiHost: Uri,
3435
config: AzureReposCfg,
3536
modify: Repo => Request[F] => F[Request[F]]
36-
)(implicit client: HttpJsonClient[F], monadErrorF: MonadThrow[F])
37+
)(implicit client: HttpJsonClient[F], logger: Logger[F], F: MonadThrow[F])
3738
extends ForgeApiAlg[F] {
3839

3940
private val url = new Url(azureAPiHost, config.organization.getOrElse(""))
4041

4142
override def createFork(repo: Repo): F[RepoOut] =
42-
monadErrorF.raiseError(new NotImplementedError(s"createFork($repo)"))
43+
F.raiseError(new NotImplementedError(s"createFork($repo)"))
4344

4445
// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/create?view=azure-devops-rest-7.1
45-
override def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut] =
46-
client.postWithBody[PullRequestOut, PullRequestPayload](
46+
override def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut] = {
47+
val create = client.postWithBody[PullRequestOut, PullRequestPayload](
4748
url.pullRequests(repo),
4849
PullRequestPayload.from(data),
4950
modify(repo)
5051
)
52+
for {
53+
_ <- F.whenA(data.assignees.nonEmpty)(warnIfAssigneesAreUsed)
54+
_ <- F.whenA(data.reviewers.nonEmpty)(warnIfReviewersAreUsed)
55+
pullRequestOut <- create
56+
} yield pullRequestOut
57+
}
5158

5259
// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/update?view=azure-devops-rest-7.1
5360
override def closePullRequest(repo: Repo, number: PullRequestNumber): F[PullRequestOut] =
@@ -96,4 +103,11 @@ final class AzureReposApiAlg[F[_]](
96103
modify(repo)
97104
)
98105
.void
106+
107+
private def warnIfAssigneesAreUsed =
108+
logger.warn("assignees are not supported by AzureRepos")
109+
110+
private def warnIfReviewersAreUsed =
111+
logger.warn("reviewers are not implemented yet for AzureRepos")
112+
99113
}

modules/core/src/main/scala/org/scalasteward/core/forge/bitbucket/BitbucketApiAlg.scala

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,27 @@ class BitbucketApiAlg[F[_]](
7373
if (bitbucketCfg.useDefaultReviewers) getDefaultReviewers(repo)
7474
else F.pure(List.empty[Reviewer])
7575

76-
defaultReviewers
77-
.map(reviewers =>
78-
CreatePullRequestRequest(
79-
data.title,
80-
Branch(data.head),
81-
Repo(sourceBranchOwner, repo.repo, repo.branch),
82-
data.base,
83-
data.body,
84-
reviewers
76+
val create: F[PullRequestOut] =
77+
defaultReviewers
78+
.map(reviewers =>
79+
CreatePullRequestRequest(
80+
data.title,
81+
Branch(data.head),
82+
Repo(sourceBranchOwner, repo.repo, repo.branch),
83+
data.base,
84+
data.body,
85+
reviewers
86+
)
8587
)
86-
)
87-
.flatMap { payload =>
88-
client.postWithBody(url.pullRequests(repo), payload, modify(repo))
89-
}
88+
.flatMap { payload =>
89+
client.postWithBody(url.pullRequests(repo), payload, modify(repo))
90+
}
91+
92+
for {
93+
_ <- F.whenA(data.assignees.nonEmpty)(warnIfAssigneesAreUsed)
94+
_ <- F.whenA(data.reviewers.nonEmpty)(warnIfReviewersAreUsed)
95+
pullRequestOut <- create
96+
} yield pullRequestOut
9097
}
9198

9299
override def getBranch(repo: Repo, branch: Branch): F[BranchOut] =
@@ -133,4 +140,10 @@ class BitbucketApiAlg[F[_]](
133140
logger.warn(
134141
"Bitbucket does not support PR labels, remove --add-labels to make this warning disappear"
135142
)
143+
144+
private def warnIfAssigneesAreUsed =
145+
logger.warn("assignees are not supported by Bitbucket")
146+
147+
private def warnIfReviewersAreUsed =
148+
logger.warn("reviewers are not implemented yet for Bitbucket")
136149
}

modules/core/src/main/scala/org/scalasteward/core/forge/bitbucketserver/BitbucketServerApiAlg.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ final class BitbucketServerApiAlg[F[_]](
6868

6969
for {
7070
reviewers <- useDefaultReviewers(repo)
71+
_ <- F.whenA(data.assignees.nonEmpty)(warnIfAssigneesAreUsed)
72+
_ <- F.whenA(data.reviewers.nonEmpty)(warnIfReviewersAreUsed)
7173
req = Json.NewPR(
7274
title = data.title,
7375
description = data.body,
@@ -127,4 +129,10 @@ final class BitbucketServerApiAlg[F[_]](
127129
logger.warn(
128130
"Bitbucket does not support PR labels, remove --add-labels to make this warning disappear"
129131
)
132+
133+
private def warnIfAssigneesAreUsed =
134+
logger.warn("assignees are not supported by Bitbucket")
135+
136+
private def warnIfReviewersAreUsed =
137+
logger.warn("reviewers are not implemented yet for Bitbucket")
130138
}

modules/core/src/main/scala/org/scalasteward/core/forge/data/NewPullRequestData.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ final case class NewPullRequestData(
3737
head: String,
3838
base: Branch,
3939
labels: List[String],
40+
assignees: List[String],
41+
reviewers: List[String],
4042
draft: Boolean = false
4143
)
4244

@@ -246,7 +248,9 @@ object NewPullRequestData {
246248
),
247249
head = branchName,
248250
base = data.baseBranch,
249-
labels = labels
251+
labels = labels,
252+
assignees = data.repoConfig.assignees,
253+
reviewers = data.repoConfig.reviewers
250254
)
251255

252256
def updateTypeLabels(anUpdate: Update): List[String] = {

modules/core/src/main/scala/org/scalasteward/core/forge/gitea/GiteaApiAlg.scala

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import org.scalasteward.core.application.Config.ForgeCfg
2626
import org.scalasteward.core.data.Repo
2727
import org.scalasteward.core.forge.ForgeApiAlg
2828
import org.scalasteward.core.forge.data._
29+
import org.typelevel.log4cats.Logger
2930

3031
// docs
3132
// - https://docs.gitea.io/en-us/api-usage/
@@ -117,10 +118,11 @@ object GiteaApiAlg {
117118
implicit val attachLabelReqCodec: Codec[AttachLabelReq] = deriveCodec
118119
}
119120

120-
final class GiteaApiAlg[F[_]: MonadThrow: HttpJsonClient](
121+
final class GiteaApiAlg[F[_]: HttpJsonClient](
121122
vcs: ForgeCfg,
122123
modify: Repo => Request[F] => F[Request[F]]
123-
) extends ForgeApiAlg[F] {
124+
)(implicit logger: Logger[F], F: MonadThrow[F])
125+
extends ForgeApiAlg[F] {
124126
import GiteaApiAlg._
125127

126128
def client: HttpJsonClient[F] = implicitly
@@ -162,6 +164,8 @@ final class GiteaApiAlg[F[_]: MonadThrow: HttpJsonClient](
162164

163165
override def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut] =
164166
for {
167+
_ <- F.whenA(data.assignees.nonEmpty)(warnIfAssigneesAreUsed)
168+
_ <- F.whenA(data.reviewers.nonEmpty)(warnIfReviewersAreUsed)
165169
labels <- getOrCreateLabel(repo, data.labels.toVector)
166170
create = CreatePullRequestOption(
167171
assignee = none,
@@ -302,4 +306,10 @@ final class GiteaApiAlg[F[_]: MonadThrow: HttpJsonClient](
302306
}
303307
go(1, Vector.empty)
304308
}
309+
310+
private def warnIfAssigneesAreUsed =
311+
logger.warn("assignees are not implemented yet for Gitea")
312+
313+
private def warnIfReviewersAreUsed =
314+
logger.warn("reviewers are not implemented yet for Gitea")
305315
}

modules/core/src/main/scala/org/scalasteward/core/forge/github/GitHubApiAlg.scala

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ import org.scalasteward.core.forge.data._
2525
import org.scalasteward.core.forge.github.GitHubException._
2626
import org.scalasteward.core.git.Branch
2727
import org.scalasteward.core.util.HttpJsonClient
28+
import org.typelevel.log4cats.Logger
29+
import io.circe.Json
2830

2931
final class GitHubApiAlg[F[_]](
3032
gitHubApiHost: Uri,
3133
modify: Repo => Request[F] => F[Request[F]]
3234
)(implicit
3335
client: HttpJsonClient[F],
36+
logger: Logger[F],
3437
F: MonadThrow[F]
3538
) extends ForgeApiAlg[F] {
3639
private val url = new Url(gitHubApiHost)
@@ -42,11 +45,20 @@ final class GitHubApiAlg[F[_]](
4245
}
4346

4447
/** https://developer.github.com/v3/pulls/#create-a-pull-request */
45-
override def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut] =
46-
client
48+
override def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut] = {
49+
val create = client
4750
.postWithBody[PullRequestOut, NewPullRequestData](url.pulls(repo), data, modify(repo))
4851
.adaptErr(SecondaryRateLimitExceeded.fromThrowable)
4952

53+
for {
54+
pullRequestOut <- create
55+
_ <-
56+
F.whenA(data.assignees.nonEmpty)(addAssignees(repo, pullRequestOut.number, data.assignees))
57+
_ <-
58+
F.whenA(data.reviewers.nonEmpty)(addReviewers(repo, pullRequestOut.number, data.reviewers))
59+
} yield pullRequestOut
60+
}
61+
5062
/** https://developer.github.com/v3/repos/branches/#get-branch */
5163
override def getBranch(repo: Repo, branch: Branch): F[BranchOut] =
5264
client.get(url.branches(repo, branch), modify(repo))
@@ -92,4 +104,39 @@ final class GitHubApiAlg[F[_]](
92104
)
93105
.adaptErr(SecondaryRateLimitExceeded.fromThrowable)
94106
.void
107+
108+
private def addAssignees(
109+
repo: Repo,
110+
number: PullRequestNumber,
111+
assignees: List[String]
112+
): F[Unit] =
113+
client
114+
.postWithBody[Json, GitHubAssignees](
115+
url.assignees(repo, number),
116+
GitHubAssignees(assignees),
117+
modify(repo)
118+
)
119+
.void
120+
.handleErrorWith { error =>
121+
logger.error(error)(s"cannot add assignees '${assignees.mkString(",")}' to PR '$number'")
122+
}
123+
124+
private def addReviewers(
125+
repo: Repo,
126+
number: PullRequestNumber,
127+
reviewers: List[String]
128+
): F[Unit] =
129+
client
130+
.postWithBody[Json, GitHubReviewers](
131+
url.reviewers(repo, number),
132+
GitHubReviewers(reviewers),
133+
modify(repo)
134+
)
135+
.void
136+
.handleErrorWith { error =>
137+
logger.error(error)(
138+
s"cannot request review from '${reviewers.mkString(",")}' for PR '$number'"
139+
)
140+
}
141+
95142
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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.github
18+
19+
import io.circe.Encoder
20+
import io.circe.generic.semiauto.deriveEncoder
21+
22+
case class GitHubAssignees(assignees: List[String])
23+
24+
object GitHubAssignees {
25+
implicit val gitHubAssigneesEncoder: Encoder[GitHubAssignees] = deriveEncoder
26+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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.github
18+
19+
import io.circe.Encoder
20+
21+
class GitHubReviewers private (
22+
val reviewers: List[String],
23+
val teamReviewers: List[String]
24+
)
25+
26+
object GitHubReviewers {
27+
def apply(reviewersFromConfig: List[String]): GitHubReviewers = {
28+
val (simpleReviewers, teamReviewers) = reviewersFromConfig.partitionMap {
29+
case s"$_/$team" => Right(team)
30+
case user => Left(user)
31+
}
32+
new GitHubReviewers(simpleReviewers, teamReviewers)
33+
}
34+
35+
implicit val gitHubReviewersEncoder: Encoder[GitHubReviewers] =
36+
Encoder.forProduct2("reviewers", "team_reviewers")(gitHubReviewers =>
37+
(gitHubReviewers.reviewers, gitHubReviewers.teamReviewers)
38+
)
39+
}

0 commit comments

Comments
 (0)