Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support merge request level approval rules to set required approvals #2794 #3014

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ All command line arguments for the `scala-steward` application.
```
Usage:
scala-steward validate-repo-config
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] [--forge-type <forge-type>] [--forge-api-host <uri>] --forge-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-use-default-reviewers] [--bitbucket-server-use-default-reviewers] [--gitlab-merge-when-pipeline-succeeds] [--gitlab-required-reviewers <integer>] [--gitlab-remove-source-branch] [--azure-repos-organization <string>] [--github-app-id <integer> --github-app-key-file <file>] [--url-checker-test-url <uri>]... [--default-maven-repo <string>] [--refresh-backoff-period <duration>]
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] [--forge-type <forge-type>] [--forge-api-host <uri>] --forge-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-use-default-reviewers] [--bitbucket-server-use-default-reviewers] [--gitlab-merge-when-pipeline-succeeds] [--gitlab-required-reviewers <integer>] [--merge-request-level-approval-rule <approvals_rule_name=required_approvals>]... [--gitlab-remove-source-branch] [--azure-repos-organization <string>] [--github-app-id <integer> --github-app-key-file <file>] [--url-checker-test-url <uri>]... [--default-maven-repo <string>] [--refresh-backoff-period <duration>]



Expand Down Expand Up @@ -80,6 +80,8 @@ Options and flags:
Whether to merge a gitlab merge request when the pipeline succeeds
--gitlab-required-reviewers <integer>
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. Also requires a Gitlab Premium subscription.
--merge-request-level-approval-rule <approvals_rule_name=required_approvals>
Additional repo config file (can be used multiple times)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this description of the flag accurate?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it's not :) I will update it 👍

--gitlab-remove-source-branch
Flag indicating if a merge request should remove the source branch when merging.
--azure-repos-organization <string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import org.scalasteward.core.forge.github.GitHubApp
import org.scalasteward.core.git.Author
import org.scalasteward.core.util.Nel
import org.scalasteward.core.util.dateTime.renderFiniteDuration

import scala.concurrent.duration._
import scala.util.{Failure, Success, Try}

object Cli {
final case class EnvVar(name: String, value: String)
Expand All @@ -44,6 +46,25 @@ object Cli {
val processTimeout = "process-timeout"
}

implicit val mergeRequestApprovalsCfgArgument: Argument[MergeRequestApprovalRulesCfg] =
Argument.from("approvals_rule_name=required_approvals") { s =>
s.trim.split("=").toList match {
case approvalRuleName :: requiredApprovalsAsString :: Nil =>
Try(requiredApprovalsAsString.trim.toInt) match {
case Failure(_) =>
Validated.invalidNel(s"[$requiredApprovalsAsString] is not a valid Integer")
case Success(requiredApprovals) =>
Validated.valid(
MergeRequestApprovalRulesCfg(approvalRuleName.trim, requiredApprovals)
)
}
case _ =>
Validated.invalidNel(
"The value is expected in the following format: APPROVALS_RULE_NAME=REQUIRED_APPROVALS"
)
}
}

implicit val envVarArgument: Argument[EnvVar] =
Argument.from("name=value") { s =>
s.trim.split('=').toList match {
Expand Down Expand Up @@ -286,10 +307,32 @@ object Cli {
"Flag indicating if a merge request should remove the source branch when merging."
).orFalse

private val gitLabCfg: Opts[GitLabCfg] =
(gitlabMergeWhenPipelineSucceeds, gitlabRequiredReviewers, gitlabRemoveSourceBranch).mapN(
GitLabCfg.apply
private val gitlabMergeRequestApprovalsConfig: Opts[Option[Nel[MergeRequestApprovalRulesCfg]]] =
options[MergeRequestApprovalRulesCfg](
"merge-request-level-approval-rule",
s"Additional repo config file $multiple"
)
.validate("Merge request level required approvals must be non-negative")(
_.forall(_.requiredApprovals >= 0) == true
)
.orNone

private val gitlabReviewersAndApprovalsConfig
: Opts[Option[Either[Int, Nel[MergeRequestApprovalRulesCfg]]]] =
((gitlabRequiredReviewers, gitlabMergeRequestApprovalsConfig).tupled.mapValidated {
case (None, None) => None.validNel
case (None, Some(gitlabMergeRequestApprovalsConfig)) =>
Some(gitlabMergeRequestApprovalsConfig.asRight[Int]).validNel
case (Some(requiredReviewers), None) => Some(Left(requiredReviewers)).validNel
case (Some(_), Some(_)) =>
s"You can't use both --gitlab-required-reviewers and --merge-request-level-approval-rule at the same time".invalidNel
})

private val gitLabCfg: Opts[GitLabCfg] =
(gitlabMergeWhenPipelineSucceeds, gitlabReviewersAndApprovalsConfig, gitlabRemoveSourceBranch)
.mapN(
GitLabCfg.apply
)

private val githubAppId: Opts[Long] =
option[Long](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,11 @@ object Config {
final case class GitHubCfg(
) extends ForgeSpecificCfg

final case class MergeRequestApprovalRulesCfg(approvalRuleName: String, requiredApprovals: Int)

final case class GitLabCfg(
mergeWhenPipelineSucceeds: Boolean,
requiredReviewers: Option[Int],
requiredApprovals: Option[Either[Int, Nel[MergeRequestApprovalRulesCfg]]],
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is how to read this;
Left side the old flow, user just provides the old configuration via --gitlab-required-reviewers <integer>
Right side is the new flow, user provides the new configuration via --merge-request-level-approval-rule <approvals_rule_name=required_approvals>

I also renamed the name of this variable. It's not reviewers, it's actually approvals.

I can wrap left side to a value class. It might be more explanatory then just Int. WDYT?

Copy link
Contributor

@henricook henricook Nov 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@endertunc I'd suggest a gitlab- prefix for this new config param perhaps, for consistency?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense I will add the prefix 👍

removeSourceBranch: Boolean
) extends ForgeSpecificCfg

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,18 @@ import io.circe._
import io.circe.generic.semiauto._
import io.circe.syntax._
import org.http4s.{Request, Status, Uri}
import org.scalasteward.core.application.Config.{ForgeCfg, GitLabCfg}
import org.scalasteward.core.application.Config.{ForgeCfg, GitLabCfg, MergeRequestApprovalRulesCfg}
import org.scalasteward.core.data.Repo
import org.scalasteward.core.forge.ForgeApiAlg
import org.scalasteward.core.forge.data._
import org.scalasteward.core.git.{Branch, Sha1}
import org.scalasteward.core.util.uri.uriDecoder
import org.scalasteward.core.util.{intellijThisImportIsUsed, HttpJsonClient, UnexpectedResponse}
import org.scalasteward.core.util.{
intellijThisImportIsUsed,
HttpJsonClient,
Nel,
UnexpectedResponse
}
import org.typelevel.log4cats.Logger

import scala.concurrent.duration.{Duration, DurationInt}
Expand All @@ -48,6 +53,8 @@ final private[gitlab] case class MergeRequestPayload(
target_branch: Branch
)

final private[gitlab] case class UpdateMergeRequestLevelApprovalRulePayload(approvals_required: Int)

private[gitlab] object MergeRequestPayload {
def apply(
id: String,
Expand Down Expand Up @@ -87,6 +94,11 @@ final private[gitlab] case class MergeRequestApprovalsOut(
approvalsRequired: Int
)

final private[gitlab] case class MergeRequestLevelApprovalRuleOut(
id: Int,
name: String
)

final private[gitlab] case class CommitId(id: Sha1) {
val commitOut: CommitOut = CommitOut(id)
}
Expand All @@ -102,6 +114,8 @@ private[gitlab] object GitLabJsonCodec {
intellijThisImportIsUsed(uriDecoder)

implicit val forkPayloadEncoder: Encoder[ForkPayload] = deriveEncoder
implicit val updateMergeRequestLevelApprovalRulePayloadEncoder
: Encoder[UpdateMergeRequestLevelApprovalRulePayload] = deriveEncoder
implicit val userOutDecoder: Decoder[UserOut] = Decoder.instance {
_.downField("username").as[String].map(UserOut(_))
}
Expand Down Expand Up @@ -140,6 +154,14 @@ private[gitlab] object GitLabJsonCodec {
} yield MergeRequestApprovalsOut(requiredReviewers)
}

implicit val mergeRequestLevelApprovalRuleOutDecoder: Decoder[MergeRequestLevelApprovalRuleOut] =
Decoder.instance { c =>
for {
id <- c.downField("id").as[Int]
name <- c.downField("name").as[String]
} yield MergeRequestLevelApprovalRuleOut(id, name)
}

implicit val projectIdDecoder: Decoder[ProjectId] = deriveDecoder
implicit val mergeRequestPayloadEncoder: Encoder[MergeRequestPayload] =
deriveEncoder[MergeRequestPayload].mapJson(_.dropNullValues)
Expand Down Expand Up @@ -240,7 +262,13 @@ final class GitLabApiAlg[F[_]: Parallel](
for {
mr <- mergeRequest
mrWithStatus <- waitForMergeRequestStatus(mr.iid)
_ <- maybeSetReviewers(repo, mrWithStatus)
_ <- gitLabCfg.requiredApprovals match {
case Some(Right(approvalRules)) =>
setApprovalRules(repo, mrWithStatus, approvalRules)
case Some(Left(requiredReviewers)) =>
setReviewers(repo, mrWithStatus, requiredReviewers)
case None => F.unit
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we call one flow or the other depending on the configuration provided.

}
mergedUponSuccess <- mergePipelineUponSuccess(repo, mrWithStatus)
} yield mergedUponSuccess
}
Expand Down Expand Up @@ -270,28 +298,86 @@ final class GitLabApiAlg[F[_]: Parallel](
case mr =>
logger.info(s"Unable to automatically merge ${mr.webUrl}").map(_ => mr)
}
import cats.implicits._

private def maybeSetReviewers(repo: Repo, mrOut: MergeRequestOut): F[MergeRequestOut] =
gitLabCfg.requiredReviewers match {
case Some(requiredReviewers) =>
for {
_ <- logger.info(
s"Setting number of required reviewers on ${mrOut.webUrl} to $requiredReviewers"
private def setReviewers(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be worth adding a comment here reminding readers that this is retained only for Gitlab versions older than... 12.3 I think?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should diffidently mention it in the description of the flag that enable this behavior. I wouldn't mind mentioning it here as well.

repo: Repo,
mrOut: MergeRequestOut,
requiredReviewers: Int
): F[MergeRequestOut] =
for {
_ <- logger.info(
s"Setting number of required reviewers on ${mrOut.webUrl} to $requiredReviewers"
)
_ <-
client
.put[MergeRequestApprovalsOut](
url.requiredApprovals(repo, mrOut.iid, requiredReviewers),
modify(repo)
)
_ <-
.map(_ => ())
.recoverWith { case UnexpectedResponse(_, _, _, status, body) =>
logger
.warn(s"Unexpected response setting required reviewers: $status: $body")
.as(())
}
} yield mrOut

private def setApprovalRules(
repo: Repo,
mrOut: MergeRequestOut,
approvalRulesCfg: Nel[MergeRequestApprovalRulesCfg]
): F[MergeRequestOut] =
for {
_ <- logger.info(
s"Adjusting merge request approvals rules on ${mrOut.webUrl} with following config: $approvalRulesCfg"
)
activeApprovalRules <-
client
.get[List[MergeRequestLevelApprovalRuleOut]](
url.listMergeRequestLevelApprovalRules(repo, mrOut.iid),
modify(repo)
)
.recoverWith { case UnexpectedResponse(_, _, _, status, body) =>
logger
.warn(s"Unexpected response getting merge request approval rules: $status: $body")
.as(List.empty)
}
approvalRulesToUpdate = calculateRulesToUpdate(activeApprovalRules, approvalRulesCfg)
_ <-
approvalRulesToUpdate.map { case (approvalRuleCfg, activeRule) =>
logger.info(
s"Setting required approval count to ${approvalRuleCfg.requiredApprovals} for merge request approval rule '${approvalRuleCfg.approvalRuleName}' on ${mrOut.webUrl}"
) >>
client
.put[MergeRequestApprovalsOut](
url.requiredApprovals(repo, mrOut.iid, requiredReviewers),
.putWithBody[
MergeRequestLevelApprovalRuleOut,
UpdateMergeRequestLevelApprovalRulePayload
](
url.updateMergeRequestLevelApprovalRule(
repo,
mrOut.iid,
activeRule.id
),
UpdateMergeRequestLevelApprovalRulePayload(approvalRuleCfg.requiredApprovals),
modify(repo)
)
.map(_ => ())
.as(())
.recoverWith { case UnexpectedResponse(_, _, _, status, body) =>
logger
.warn(s"Unexpected response setting required reviewers: $status: $body")
.as(())
.warn(s"Unexpected response setting required approvals: $status: $body")
}
} yield mrOut
case None => F.pure(mrOut)
}.sequence
} yield mrOut

private[gitlab] def calculateRulesToUpdate(
activeApprovalRules: List[MergeRequestLevelApprovalRuleOut],
approvalRulesCfg: Nel[MergeRequestApprovalRulesCfg]
): List[(MergeRequestApprovalRulesCfg, MergeRequestLevelApprovalRuleOut)] =
activeApprovalRules.flatMap { activeRule =>
approvalRulesCfg
.find(_.approvalRuleName == activeRule.name)
.map(_ -> activeRule)
}

private def getUsernameToUserIdsMapping(repo: Repo, usernames: Set[String]): F[Map[String, Int]] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ class Url(apiHost: Uri) {
(existingMergeRequest(repo, number) / "approvals")
.withQueryParam("approvals_required", approvalsRequired)

def listMergeRequestLevelApprovalRules(repo: Repo, number: PullRequestNumber): Uri =
existingMergeRequest(repo, number) / "approval_rules"

def updateMergeRequestLevelApprovalRule(
repo: Repo,
number: PullRequestNumber,
approvalRuleId: Int
): Uri = existingMergeRequest(repo, number) / "approval_rules" / approvalRuleId

def listMergeRequests(repo: Repo, source: String, target: String): Uri =
mergeRequest(repo)
.withQueryParam("source_branch", source)
Expand Down