Skip to content

Commit a086a64

Browse files
authored
Merge pull request #2528 from dkichler/issue-2519-gitlab-minimum-reviewers
feat: Adding support for setting required number of reviewers on a S…
2 parents fc0e27d + 2bfde62 commit a086a64

File tree

7 files changed

+214
-38
lines changed

7 files changed

+214
-38
lines changed

docs/help.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All command line arguments for the `scala-steward` application.
55
```
66
Usage:
77
scala-steward validate-repo-config
8-
scala-steward --workspace <file> --repos-file <file> [--git-author-name <string>] --git-author-email <string> [--git-author-signing-key <string>] --git-ask-pass <file> [--sign-commits] [--vcs-type <vcs-type>] [--vcs-api-host <uri>] --vcs-login <string> [--do-not-fork] [--add-labels] [--ignore-opts-files] [--env-var <name=value>]... [--process-timeout <duration>] [--whitelist <string>]... [--read-only <string>]... [--enable-sandbox | --disable-sandbox] [--max-buffer-size <integer>] [--repo-config <uri>]... [--disable-default-repo-config] [--scalafix-migrations <uri>]... [--disable-default-scalafix-migrations] [--artifact-migrations <uri>]... [--disable-default-artifact-migrations] [--cache-ttl <duration>] [--bitbucket-server-use-default-reviewers] [--gitlab-merge-when-pipeline-succeeds] [--github-app-id <integer> --github-app-key-file <file>] [--url-checker-test-url <uri>] [--default-maven-repo <string>] [--refresh-backoff-period <duration>]
8+
scala-steward --workspace <file> --repos-file <file> [--git-author-name <string>] --git-author-email <string> [--git-author-signing-key <string>] --git-ask-pass <file> [--sign-commits] [--vcs-type <vcs-type>] [--vcs-api-host <uri>] --vcs-login <string> [--do-not-fork] [--add-labels] [--ignore-opts-files] [--env-var <name=value>]... [--process-timeout <duration>] [--whitelist <string>]... [--read-only <string>]... [--enable-sandbox | --disable-sandbox] [--max-buffer-size <integer>] [--repo-config <uri>]... [--disable-default-repo-config] [--scalafix-migrations <uri>]... [--disable-default-scalafix-migrations] [--artifact-migrations <uri>]... [--disable-default-artifact-migrations] [--cache-ttl <duration>] [--bitbucket-server-use-default-reviewers] [--gitlab-merge-when-pipeline-succeeds] [--gitlab-required-reviewers <integer>] [--github-app-id <integer> --github-app-key-file <file>] [--url-checker-test-url <uri>] [--default-maven-repo <string>] [--refresh-backoff-period <duration>]
99
1010
1111
@@ -70,6 +70,8 @@ Options and flags:
7070
Whether to assign the default reviewers to a bitbucket pull request; default: false
7171
--gitlab-merge-when-pipeline-succeeds
7272
Whether to merge a gitlab merge request when the pipeline succeeds
73+
--gitlab-required-reviewers <integer>
74+
When set, the number of required reviewers for a merge request will be set to this number (non-negative integer). Is only used in the context of gitlab-merge-when-pipeline-succeeds being enabled, and requires that the configured access token have the appropriate privileges.
7375
--github-app-id <integer>
7476
GitHub application id
7577
--github-app-key-file <file>

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,14 @@ object Cli {
226226
"Whether to merge a gitlab merge request when the pipeline succeeds"
227227
).orFalse
228228

229+
private val gitlabRequiredReviewers: Opts[Option[Int]] =
230+
option[Int](
231+
"gitlab-required-reviewers",
232+
"When set, the number of required reviewers for a merge request will be set to this number (non-negative integer). Is only used in the context of gitlab-merge-when-pipeline-succeeds being enabled, and requires that the configured access token have the appropriate privileges."
233+
).validate("Required reviewers must be non-negative")(_ >= 0).orNone
234+
229235
private val gitLabCfg: Opts[GitLabCfg] =
230-
gitlabMergeWhenPipelineSucceeds.map(GitLabCfg.apply)
236+
(gitlabMergeWhenPipelineSucceeds, gitlabRequiredReviewers).mapN(GitLabCfg.apply)
231237

232238
private val githubAppId: Opts[Long] =
233239
option[Long]("github-app-id", "GitHub application id")

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ object Config {
133133
)
134134

135135
final case class GitLabCfg(
136-
mergeWhenPipelineSucceeds: Boolean
136+
mergeWhenPipelineSucceeds: Boolean,
137+
requiredReviewers: Option[Int]
137138
)
138139

139140
sealed trait StewardUsage

modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlg.scala

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ final private[gitlab] case class MergeRequestOut(
6262
val pullRequestOut: PullRequestOut = PullRequestOut(webUrl, state, iid, title)
6363
}
6464

65+
final private[gitlab] case class MergeRequestApprovalsOut(
66+
approvalsRequired: Int
67+
)
68+
6569
final private[gitlab] case class CommitId(id: Sha1) {
6670
val commitOut: CommitOut = CommitOut(id)
6771
}
@@ -109,6 +113,13 @@ private[gitlab] object GitLabJsonCodec {
109113
} yield MergeRequestOut(webUrl, state, title, iid, mergeStatus)
110114
}
111115

116+
implicit val mergeRequestApprovalsOutDecoder: Decoder[MergeRequestApprovalsOut] =
117+
Decoder.instance { c =>
118+
for {
119+
requiredReviewers <- c.downField("approvals_required").as[Int]
120+
} yield MergeRequestApprovalsOut(requiredReviewers)
121+
}
122+
112123
implicit val projectIdDecoder: Decoder[ProjectId] = deriveDecoder
113124
implicit val mergeRequestPayloadEncoder: Encoder[MergeRequestPayload] = deriveEncoder
114125
implicit val updateStateEncoder: Encoder[UpdateState] = Encoder.instance { newState =>
@@ -181,34 +192,64 @@ final class GitLabApiAlg[F[_]](
181192
val updatedMergeRequest =
182193
if (!gitLabCfg.mergeWhenPipelineSucceeds)
183194
mergeRequest
184-
else
185-
mergeRequest
186-
.flatMap(mr => waitForMergeRequestStatus(mr.iid))
187-
.flatMap {
188-
case mr if mr.mergeStatus === GitLabMergeStatus.CanBeMerged =>
189-
for {
190-
_ <- logger.info(s"Setting ${mr.webUrl} to merge when pipeline succeeds")
191-
res <-
192-
client
193-
.put[MergeRequestOut](
194-
url.mergeWhenPiplineSucceeds(repo, mr.iid),
195-
modify(repo)
196-
)
197-
// it's possible that our status changed from can be merged already,
198-
// so just handle it gracefully and proceed without setting auto merge.
199-
.recoverWith { case UnexpectedResponse(_, _, _, status, _) =>
200-
logger
201-
.warn(s"Unexpected gitlab response setting auto merge: $status")
202-
.as(mr)
203-
}
204-
} yield res
205-
case mr =>
206-
logger.info(s"Unable to automatically merge ${mr.webUrl}").map(_ => mr)
207-
}
195+
else {
196+
for {
197+
mr <- mergeRequest
198+
mrWithStatus <- waitForMergeRequestStatus(mr.iid)
199+
_ <- maybeSetReviewers(repo, mrWithStatus)
200+
mergedUponSuccess <- mergePipelineUponSuccess(repo, mrWithStatus)
201+
} yield mergedUponSuccess
202+
}
208203

209204
updatedMergeRequest.map(_.pullRequestOut)
210205
}
211206

207+
private def mergePipelineUponSuccess(repo: Repo, mr: MergeRequestOut): F[MergeRequestOut] =
208+
mr match {
209+
case mr if mr.mergeStatus === GitLabMergeStatus.CanBeMerged =>
210+
for {
211+
_ <- logger.info(s"Setting ${mr.webUrl} to merge when pipeline succeeds")
212+
res <-
213+
client
214+
.put[MergeRequestOut](
215+
url.mergeWhenPiplineSucceeds(repo, mr.iid),
216+
modify(repo)
217+
)
218+
// it's possible that our status changed from can be merged already,
219+
// so just handle it gracefully and proceed without setting auto merge.
220+
.recoverWith { case UnexpectedResponse(_, _, _, status, _) =>
221+
logger
222+
.warn(s"Unexpected gitlab response setting auto merge: $status")
223+
.as(mr)
224+
}
225+
} yield res
226+
case mr =>
227+
logger.info(s"Unable to automatically merge ${mr.webUrl}").map(_ => mr)
228+
}
229+
230+
private def maybeSetReviewers(repo: Repo, mrOut: MergeRequestOut): F[MergeRequestOut] =
231+
gitLabCfg.requiredReviewers match {
232+
case Some(requiredReviewers) =>
233+
for {
234+
_ <- logger.info(
235+
s"Setting number of required reviewers on ${mrOut.webUrl} to $requiredReviewers"
236+
)
237+
_ <-
238+
client
239+
.put[MergeRequestApprovalsOut](
240+
url.requiredApprovals(repo, mrOut.iid, requiredReviewers),
241+
modify(repo)
242+
)
243+
.map(_ => ())
244+
.recoverWith { case UnexpectedResponse(_, _, _, status, body) =>
245+
logger
246+
.warn(s"Unexpected response setting required reviewers: $status: $body")
247+
.as(())
248+
}
249+
} yield mrOut
250+
case None => F.pure(mrOut)
251+
}
252+
212253
override def closePullRequest(repo: Repo, number: PullRequestNumber): F[PullRequestOut] =
213254
client
214255
.putWithBody[MergeRequestOut, UpdateState](

modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/Url.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ class Url(apiHost: Uri) {
4242
(existingMergeRequest(repo, number) / "merge")
4343
.withQueryParam("merge_when_pipeline_succeeds", "true")
4444

45+
def requiredApprovals(repo: Repo, number: PullRequestNumber, approvalsRequired: Int): Uri =
46+
(existingMergeRequest(repo, number) / "approvals")
47+
.withQueryParam("approvals_required", approvalsRequired)
48+
4549
def listMergeRequests(repo: Repo, source: String, target: String): Uri =
4650
mergeRequest(repo)
4751
.withQueryParam("source_branch", source)

modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,22 @@ class CliTest extends FunSuite {
6262
)
6363
assertEquals(obtained.githubApp, Some(GitHubApp(12345678L, File("example_app_key"))))
6464
assertEquals(obtained.refreshBackoffPeriod, 1.day)
65+
assert(!obtained.gitLabCfg.mergeWhenPipelineSucceeds)
66+
assertEquals(obtained.gitLabCfg.requiredReviewers, None)
6567
}
6668

69+
val minimumRequiredParams = List(
70+
List("--workspace", "a"),
71+
List("--repos-file", "b"),
72+
List("--git-author-email", "d"),
73+
List("--vcs-login", "e"),
74+
List("--git-ask-pass", "f"),
75+
List("--disable-sandbox")
76+
)
77+
6778
test("parseArgs: minimal example") {
6879
val Success(StewardUsage.Regular(obtained)) = Cli.parseArgs(
69-
List(
70-
List("--workspace", "a"),
71-
List("--repos-file", "b"),
72-
List("--git-author-email", "d"),
73-
List("--vcs-login", "e"),
74-
List("--git-ask-pass", "f")
75-
).flatten
80+
minimumRequiredParams.flatten
7681
)
7782

7883
assert(!obtained.processCfg.sandboxCfg.enableSandbox)
@@ -144,6 +149,27 @@ class CliTest extends FunSuite {
144149
assert(clue(obtained).startsWith("Usage"))
145150
}
146151

152+
test("parseArgs: non-default GitLab arguments") {
153+
val params = minimumRequiredParams ++ List(
154+
List("--gitlab-merge-when-pipeline-succeeds"),
155+
List("--gitlab-required-reviewers", "5")
156+
)
157+
val Success(StewardUsage.Regular(obtained)) = Cli.parseArgs(params.flatten)
158+
159+
assert(obtained.gitLabCfg.mergeWhenPipelineSucceeds)
160+
assertEquals(obtained.gitLabCfg.requiredReviewers, Some(5))
161+
}
162+
163+
test("parseArgs: invalid GitLab required reviewers") {
164+
val params = minimumRequiredParams ++ List(
165+
List("--gitlab-merge-when-pipeline-succeeds"),
166+
List("--gitlab-required-reviewers", "-3")
167+
)
168+
val Error(errorMsg) = Cli.parseArgs(params.flatten)
169+
170+
assert(clue(errorMsg).startsWith("Required reviewers must be non-negative"))
171+
}
172+
147173
test("parseArgs: validate-repo-config") {
148174
val Success(StewardUsage.ValidateRepoConfig(file)) = Cli.parseArgs(
149175
List(

modules/core/src/test/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlgTest.scala

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class GitLabApiAlgTest extends FunSuite {
2929
object MergeWhenPipelineSucceedsMatcher
3030
extends QueryParamDecoderMatcher[Boolean]("merge_when_pipeline_succeeds")
3131

32+
object RequiredReviewersMatcher extends QueryParamDecoderMatcher[Int]("approvals_required")
33+
3234
val routes: HttpRoutes[IO] =
3335
HttpRoutes.of[IO] {
3436
case GET -> Root / "projects" / "foo/bar" =>
@@ -66,6 +68,14 @@ class GitLabApiAlgTest extends FunSuite {
6668
)
6769
)
6870

71+
case PUT -> Root / "projects" / "foo/bar" / "merge_requests" / "150" / "approvals"
72+
:? RequiredReviewersMatcher(requiredApprovers) =>
73+
Ok(
74+
putMrApprovals.deepMerge(
75+
json""" { "iid": 150, "approvals_required": $requiredApprovers, "web_url": "https://gitlab.com/foo/bar/merge_requests/150" } """
76+
)
77+
)
78+
6979
case POST -> Root / "projects" / "foo/bar" / "merge_requests" / "150" / "notes" =>
7080
Ok(json""" {
7181
"body": "Superseded by #1234"
@@ -80,7 +90,11 @@ class GitLabApiAlgTest extends FunSuite {
8090
implicit val httpJsonClient: HttpJsonClient[IO] = new HttpJsonClient[IO]
8191
implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO]
8292
val gitlabApiAlg =
83-
new GitLabApiAlg[IO](config.vcsCfg, GitLabCfg(mergeWhenPipelineSucceeds = false), _ => IO.pure)
93+
new GitLabApiAlg[IO](
94+
config.vcsCfg,
95+
GitLabCfg(mergeWhenPipelineSucceeds = false, requiredReviewers = None),
96+
_ => IO.pure
97+
)
8498

8599
private val data = UpdateData(
86100
RepoData(Repo("foo", "bar"), dummyRepoCache, RepoConfig.empty),
@@ -109,7 +123,7 @@ class GitLabApiAlgTest extends FunSuite {
109123
test("createPullRequest -- no fork") {
110124
val gitlabApiAlgNoFork = new GitLabApiAlg[IO](
111125
config.vcsCfg.copy(doNotFork = true),
112-
GitLabCfg(mergeWhenPipelineSucceeds = false),
126+
GitLabCfg(mergeWhenPipelineSucceeds = false, requiredReviewers = None),
113127
_ => IO.pure
114128
)
115129
val prOut = gitlabApiAlgNoFork
@@ -144,7 +158,7 @@ class GitLabApiAlgTest extends FunSuite {
144158
test("createPullRequest -- auto merge") {
145159
val gitlabApiAlgNoFork = new GitLabApiAlg[IO](
146160
config.vcsCfg.copy(doNotFork = true),
147-
GitLabCfg(mergeWhenPipelineSucceeds = true),
161+
GitLabCfg(mergeWhenPipelineSucceeds = true, requiredReviewers = None),
148162
_ => IO.pure
149163
)
150164

@@ -176,7 +190,7 @@ class GitLabApiAlgTest extends FunSuite {
176190

177191
val gitlabApiAlgNoFork = new GitLabApiAlg[IO](
178192
config.vcsCfg.copy(doNotFork = true),
179-
GitLabCfg(mergeWhenPipelineSucceeds = true),
193+
GitLabCfg(mergeWhenPipelineSucceeds = true, requiredReviewers = None),
180194
_ => IO.pure
181195
)(errorJsonClient, implicitly, implicitly)
182196

@@ -194,6 +208,59 @@ class GitLabApiAlgTest extends FunSuite {
194208
assertEquals(prOut, expected)
195209
}
196210

211+
test("createPullRequest -- reduce required reviewers") {
212+
val gitlabApiAlgLessReviewersRequired = new GitLabApiAlg[IO](
213+
config.vcsCfg.copy(doNotFork = true),
214+
GitLabCfg(mergeWhenPipelineSucceeds = true, requiredReviewers = Some(0)),
215+
_ => IO.pure
216+
)
217+
218+
val prOut = gitlabApiAlgLessReviewersRequired
219+
.createPullRequest(Repo("foo", "bar"), newPRData)
220+
.unsafeRunSync()
221+
222+
val expected = PullRequestOut(
223+
uri"https://gitlab.com/foo/bar/merge_requests/150",
224+
PullRequestState.Open,
225+
PullRequestNumber(150),
226+
"title"
227+
)
228+
229+
assertEquals(prOut, expected)
230+
}
231+
232+
test("createPullRequest -- no fail upon required reviewers error") {
233+
val errorClient: Client[IO] =
234+
Client.fromHttpApp(
235+
(HttpRoutes.of[IO] {
236+
case PUT -> Root / "projects" / "foo/bar" / "merge_requests" / "150" / "approvals"
237+
:? RequiredReviewersMatcher(requiredReviewers) =>
238+
BadRequest(s"Cannot set requiredReviewers to $requiredReviewers")
239+
} <+> routes).orNotFound
240+
)
241+
val errorJsonClient: HttpJsonClient[IO] =
242+
new HttpJsonClient[IO]()(errorClient, Concurrent[IO])
243+
244+
val gitlabApiAlgLessReviewersRequiredNoError = new GitLabApiAlg[IO](
245+
config.vcsCfg.copy(doNotFork = true),
246+
GitLabCfg(mergeWhenPipelineSucceeds = true, requiredReviewers = Some(0)),
247+
_ => IO.pure
248+
)(errorJsonClient, implicitly, implicitly)
249+
250+
val prOut = gitlabApiAlgLessReviewersRequiredNoError
251+
.createPullRequest(Repo("foo", "bar"), newPRData)
252+
.unsafeRunSync()
253+
254+
val expected = PullRequestOut(
255+
uri"https://gitlab.com/foo/bar/merge_requests/150",
256+
PullRequestState.Open,
257+
PullRequestNumber(150),
258+
"title"
259+
)
260+
261+
assertEquals(prOut, expected)
262+
}
263+
197264
test("commentPullRequest") {
198265
val reference = gitlabApiAlg.referencePullRequest(PullRequestNumber(1347))
199266
assertEquals(reference, "!1347")
@@ -365,4 +432,33 @@ class GitLabApiAlgTest extends FunSuite {
365432
"packages_enabled": true
366433
}
367434
"""
435+
436+
val putMrApprovals =
437+
json"""
438+
{
439+
"id": 138691106,
440+
"iid": 4,
441+
"project_id": 466,
442+
"title": "Standard ScalaSteward configured MR title",
443+
"description": "Standard ScalaSteward configured MR description",
444+
"state": "opened",
445+
"created_at": "2022-02-04T17:43:57.363Z",
446+
"updated_at": "2022-02-11T22:38:28.645Z",
447+
"merge_status": "can_be_merged",
448+
"approved": true,
449+
"approvals_required": 0,
450+
"approvals_left": 0,
451+
"require_password_to_approve": false,
452+
"approved_by": [],
453+
"suggested_approvers": [],
454+
"approvers": [],
455+
"approver_groups": [],
456+
"user_has_approved": false,
457+
"user_can_approve": true,
458+
"approval_rules_left": [],
459+
"has_approval_rules": true,
460+
"merge_request_approvers_available": true,
461+
"multiple_approval_rules_available": true
462+
}
463+
"""
368464
}

0 commit comments

Comments
 (0)