lib/release_tools/security/merge_request_validator.rb (170 lines of code) (raw):
# frozen_string_literal: true
module ReleaseTools
module Security
class MergeRequestValidator
attr_reader :errors
# A regular expression to use for extracting all pending tasks from the
# merge request description. This pattern will match the following:
#
# - [ ] Task name here
# * [ ] Task name here
PENDING_TASKS = /(\*|-)\s*\[\s+\]/
# A regular expression to use for extracting all tasks (pending or not)
# from the merge request description. This pattern will match the
# following:
#
# - [ ] Task name here
# * [ ] Task name here
# - [x] Task name here
# * [x] Task name here
ALL_TASKS = /(\*|-)\s*\[(\s+|[xX])\]/
# A regular expression used to determine if the target branch of a merge
# request is valid.
ALLOWED_TARGET_BRANCHES = /^(master|main|\d+-\d+-stable(-ee)?)$/
# The label that must be applied to all security merge requests.
SECURITY_LABEL = 'security'
# The namespace that security implementation issues must reside in.
SECURITY_NAMESPACE = 'gitlab-org/security'
# Projects with EE stable branches
PROJECTS_WITH_EE_BRANCHES = [Project::GitlabEe.security_id].freeze
# @param [Gitlab::ObjectifiedHash] merge_request
# @param [ReleaseTools::Security::Client] client
def initialize(merge_request, client)
@merge_request = merge_request
@client = client
@errors = []
end
def validate
validate_pipeline_status
validate_merge_status
validate_work_in_progress
validate_pending_tasks
validate_merge_request_template
validate_target_branch
validate_discussions
validate_approvals
validate_stable_branches
end
def validate_pipeline_status
pipeline = Pipeline.latest_for_merge_request(@merge_request, @client)
if pipeline.nil?
error('Missing pipeline', <<~ERROR)
No pipeline could be found for this merge request. Security merge
requests must have a pipeline that passes before they can be merged.
ERROR
elsif pipeline.failed?
error('Failing pipeline', <<~ERROR)
The latest pipeline has one or more failing builds. Merge requests
can not be merged unless the pipeline has passed.
ERROR
elsif pipeline.pending? || (pipeline.running? && !default_branch?)
# This covers pipelines that are skipped or in another unknown state.
#
# Running pipelines against the default branch are allowed due to
# https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1231
error('Pending pipeline', <<~ERROR)
The latest pipeline did not pass. Merge requests should not be
assigned to me until the pipeline is successful.
ERROR
end
end
def validate_merge_status
if @merge_request.merge_status == 'cannot_be_merged'
error('The merge request can not be merged', <<~ERROR)
This merge request can currently not be merged, likely due to merge
conflicts introduced by other (security) merge requests. Please
rebase this merge request with the target branch and resolve any
conflicts.
ERROR
end
end
def validate_work_in_progress
if @merge_request.title.start_with?('WIP', 'Draft')
error('The merge request is marked as a work in progress', <<~ERROR)
Work in progress merge requests will not be merged, so make sure to
resolve the WIP status before assigning this merge request back to
me.
ERROR
end
end
def validate_pending_tasks
if @merge_request.description.match?(PENDING_TASKS)
error('There are one or more pending tasks', <<~ERROR)
Before a security merge request can be merged, _all_ tasks must have
been completed. If a task is not applicable, you can either mark it
as completed or remove it.
ERROR
end
end
def validate_merge_request_template
unless @merge_request.description.match?(ALL_TASKS)
error('The Security Release template is not used', <<~ERROR)
This merge request does not contain any tasks to complete,
suggesting that the "Security Release" merge request template was
not used. Security merge requests must use this merge request
template.
ERROR
end
end
def validate_target_branch
unless @merge_request.target_branch.match?(ALLOWED_TARGET_BRANCHES)
error('The target branch is invalid', <<~ERROR)
Security merge requests must target the default branch, or a stable
branch such as 11-8-stable-ee.
Security branches are no longer in use and should not be used as
target branches.
ERROR
end
end
def validate_discussions
# There might be many discussions and notes. Buffering all of those in
# an Array might require quite a bit of memory, so instead we process
# discussions as we retrieve them.
Retriable.with_context(:api) do
@client
.merge_request_discussions(@merge_request.project_id, @merge_request.iid)
.auto_paginate do |discussion|
if discussion.notes.any? { |n| n['resolvable'] && !n['resolved'] }
error('There are unresolved discussions', <<~ERROR)
This merge request has one or more unresolved discussions,
preventing it from being merged. Please mark all discussions as
resolved, then assign this merge request back to me.
ERROR
break
end
end
end
end
# Validates if a merge request has the needed approvals
#
# * If it's targeting the default branch, we validate at least two
# approvals:
# - From a team member (ideally a maintainer, but since there's
# no easy way to verify that, we're simply checking any approval)
# - From a member of the AppSec team
#
# * If it's targeting a stable branch, we validate one approval.
def validate_approvals
approvals =
Retriable.with_context(:api) do
@client.merge_request_approvals(@merge_request.project_id, @merge_request.iid)
end
if default_branch?
validate_approvals_on_default_branch(approvals)
else
validate_approval_on_stable_branch(approvals)
end
end
def validate_stable_branches
return if default_branch?
next_versions = next_security_versions
# When a stable branch is created for an upcoming monthly release, but
# the release hasn't yet been completed and there's no
# version.gitlab.com entry, `next_security_versions` won't return the
# correct versions for the maintenance window. Fake it by taking the
# highest version and adding the next minor (monthly) version.
next_versions.prepend(Version.new(next_versions.first.next_minor))
stable_branches =
if PROJECTS_WITH_EE_BRANCHES.include?(@merge_request.project_id)
next_security_versions.map do |version|
version.stable_branch(ee: true)
end
else
next_security_versions.map(&:stable_branch)
end
return if stable_branches.any? { |branch| @merge_request.target_branch.match?(/\A#{branch}(-ee)?\z/) }
error('The merge request is targeting a stable branch out of the maintenance policy', <<~ERROR)
This merge request should target any of the following stable
branches: #{stable_branches.join(', ')}
ERROR
end
private
# rubocop:disable ReleaseTools/DefaultBranchLiteral
def default_branch?
%w[master main].include?(@merge_request.target_branch)
end
# rubocop:enable ReleaseTools/DefaultBranchLiteral
def validate_approvals_on_default_branch(approvals)
return if approvals.approvals_left.to_i.zero? && approvals.approval_rules_left.empty?
error('The merge request requires two approvals', <<~ERROR)
This merge request is missing an approval. Please ensure
its approved by a maintainer, and by an AppSec team member.
ERROR
end
def validate_approval_on_stable_branch(approvals)
return unless approvals.approved_by.empty?
error('The merge request must be approved', <<~ERROR)
This merge request is missing an approval. Please ensure it has maintainer approval.
ERROR
end
def next_security_versions
@next_security_versions ||= ReleaseTools::Versions
.next_versions
end
# @param [String] summary
# @param [String] details
def error(summary, details)
@errors << <<~HTML
<details>
<summary><strong>#{summary}</strong></summary>
<br />
#{details}
</details>
HTML
end
end
end
end