lib/release_tools/security/implementation_issue.rb (197 lines of code) (raw):
# frozen_string_literal: true
module ReleaseTools
module Security
class ImplementationIssue
include ::SemanticLogger::Loggable
# Number of merge requests that has to be associated to every Security Issue
MERGE_REQUESTS_SIZE = 4
# Label indicating issue doesn't require full backports
BACKPORT_EXCEPTION_LABEL = 'reduced backports'
# Internal ID of GitLab Release Bot
GITLAB_RELEASE_BOT_ID = 2_324_599
# Format of stable branches on GitLab repos
STABLE_BRANCH_REGEX = /^(\d+-\d+-stable(-ee)?)$/
# Collection of security projects that are allowed to early merge
PROJECTS_ALLOWED_TO_EARLY_MERGE = [
Project::GitlabEe.security_id,
Project::OmnibusGitlab.security_id,
Project::CNGImage.security_id
].freeze
DEFAULT_BRANCHES = %w[main master].freeze # rubocop:disable ReleaseTools/DefaultBranchLiteral
# The status of merge requests to be considered
OPENED = 'opened'
MERGED = 'merged'
ALLOWED_DEFAULT_MR_STATE = [OPENED, MERGED].freeze
attr_reader :project_id, :iid, :web_url, :reference, :merge_requests, :issue
delegate :labels, to: :@issue
delegate :assignees, to: :@issue
delegate :author, to: :@issue
delegate :title, to: :@issue
# this allows usage of GitLabClient methods that use `project_path`
alias project project_id
def initialize(issue, merge_requests)
@issue = issue
@project_id = issue.project_id
@iid = issue.iid
@web_url = issue.web_url
@reference = issue.references.full
@merge_requests = merge_requests
end
def ready_to_be_processed?
validate
pending_reasons.empty?
end
def pending_reasons
return [] unless @reasons
@reasons
end
def merge_requests_targeting_default_branch
@merge_requests_targeting_default_branch ||=
merge_requests
.select { |merge_request| DEFAULT_BRANCHES.include?(merge_request.target_branch) }
end
# Only returns the first MR targeting the default branch
def merge_request_targeting_default_branch
@merge_request_targeting_default_branch ||= merge_requests_targeting_default_branch.first
end
def default_merge_request_handled?
return merge_request_targeting_default_branch.state == MERGED if merge_request_targeting_default_branch.present?
@issue.labels.include?(BACKPORT_EXCEPTION_LABEL)
end
def mwps_set_on_default_merge_request?
merge_request_targeting_default_branch.present? &&
merge_request_targeting_default_branch.state == OPENED &&
merge_request_targeting_default_branch.merge_when_pipeline_succeeds
end
def default_merge_request_deployed?
return false unless merge_request_targeting_default_branch
# Check if the API returns the default branch MR when filtering by environment
mrs = GitlabClient.merge_requests(
merge_request_targeting_default_branch.project_id,
{
iids: [merge_request_targeting_default_branch.iid],
environment: 'gprd'
}
)
# Check if the first_deployed_to_production_at attribute is a reliable way to check if an MR has been deployed
# to gprd.
logger.info(
'Security default branch MR deployed to gprd?',
environment_filter: !mrs.empty?,
first_deployed_to_production_at: merge_request_targeting_default_branch.first_deployed_to_production_at.present?,
deployment_timestamp: merge_request_targeting_default_branch.first_deployed_to_production_at,
mr_url: merge_request_targeting_default_branch.web_url
)
!mrs.empty?
end
def backports
@backports ||= merge_requests
.select { |merge_request| merge_request.target_branch.match?(STABLE_BRANCH_REGEX) }
end
def backports_merged?
!backports.empty? && backports.all? { |mr| mr.state == MERGED }
end
def processed?
merge_requests.all? { |mr| mr.state == MERGED }
end
def allowed_to_early_merge?
merge_request_targeting_default_branch&.state == OPENED &&
PROJECTS_ALLOWED_TO_EARLY_MERGE.include?(merge_request_targeting_default_branch.project_id)
end
def pending_merge_requests
if PROJECTS_ALLOWED_TO_EARLY_MERGE.include?(project_id)
backports
else
opened_merge_requests
end
end
def security_target?
@issue.labels.include?(ReleaseTools::Security::IssueCrawler::SECURITY_TARGET_LABEL)
end
def cves_issue
issue = RelatedIssuesFinder.new(self).cves_issue
return unless issue
CvesIssue.new(issue)
end
def canonical_issue
RelatedIssuesFinder.new(self).canonical_issue
end
def opened_merge_requests_targeting_default_branch
merge_requests_targeting_default_branch.select { |merge_request| merge_request.state == OPENED }
end
private
def validate
return if defined?(@reasons)
@reasons = []
validate_merge_requests_present
validate_missing_backports
validate_default_mr_status
validate_backport_mr_status
validate_merge_requests_assigned_to_the_bot
validate_merge_requests
end
def reject(reason)
logger.warn("Rejecting implementation issue due to #{reason}", url: web_url)
@reasons << reason
end
def validate_merge_requests_present
return unless merge_requests.empty?
reject(
<<~EOF.squish
There are no MRs linked to this issue. Follow the steps in the issue description to
create the required MRs.
EOF
)
end
def validate_missing_backports
return unless
merge_requests.length < MERGE_REQUESTS_SIZE &&
@issue.labels.exclude?(BACKPORT_EXCEPTION_LABEL)
reject(
<<~EOF.squish
Every security issue is expected to have 4 MRs; 3 backports targeting the versions in the
[next scheduled patch release](https://gitlab.com/gitlab-org/gitlab/-/issues/?label_name%5B%5D=upcoming%20security%20release)
and 1 MR targeting master. If this issue legitimately does not need to have all 4 MRs,
please add the ~"reduced backports" label to this issue
EOF
)
end
def validate_merge_requests_assigned_to_the_bot
unassigned_mrs = merge_requests.reject do |merge_request|
merge_request
.assignees
.map { |assignee| assignee.to_h.transform_keys(&:to_sym)[:id] }.include?(GITLAB_RELEASE_BOT_ID)
end
return if unassigned_mrs.empty?
reject(
<<~EOF.squish
The following merge requests need to be assigned to the `@gitlab-release-tools-bot`: #{unassigned_mrs.map(&:web_url).join(', ')}
EOF
)
end
def validate_default_mr_status
return unless merge_request_targeting_default_branch
default_branch_state = merge_request_targeting_default_branch.state
return if ALLOWED_DEFAULT_MR_STATE.include?(default_branch_state)
reject(
<<~EOF.squish
The [MR targeting the default branch](#{merge_request_targeting_default_branch.web_url}) needs
to be in one of the following states: #{ALLOWED_DEFAULT_MR_STATE.join(', ')}
EOF
)
end
def validate_backport_mr_status
return if backports.all? { |mr| mr.state == OPENED }
reject(
<<~EOF.squish
The following backport MRs need to be in the 'opened' state:
#{backports.reject { |mr| mr.state == OPENED }.map(&:web_url).join(', ')}
EOF
)
end
def validate_merge_requests
invalid_mrs = ReleaseTools::Security::MergeRequestsValidator
.new(ReleaseTools::Security::Client.new)
.execute(merge_requests: opened_merge_requests)
.last
return if invalid_mrs.empty?
reject(
<<~EOF.squish
The following merge requests are not ready. A comment has been posted in each MR with more details:
#{invalid_mrs.map(&:web_url).join(', ')}
EOF
)
end
def opened_merge_requests
merge_requests
.select { |merge_request| merge_request.state == OPENED }
end
end
end
end