Files
gitlabhq/scripts/pipeline/pre_merge_checks.rb
2025-07-10 21:07:54 +00:00

202 lines
6.4 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
if Object.const_defined?(:RSpec)
# Ok, we're testing, we know we're going to stub `Gitlab`, so we just ignore
else
require 'gitlab'
if Gitlab.singleton_class.method_defined?(:com?)
abort 'lib/gitlab.rb is loaded, and this means we can no longer load the client and we cannot proceed'
end
end
require 'time'
class PreMergeChecks
DEFAULT_API_ENDPOINT = "https://gitlab.com/api/v4"
MERGE_TRAIN_REF_REGEX = %r{\Arefs/merge-requests/\d+/train\z}
PIPELINE_FRESHNESS_DEFAULT_THRESHOLD_IN_HOURS = 8
PIPELINE_FRESHNESS_STABLE_BRANCHES_THRESHOLD_IN_HOURS = 72
PREDICTIVE_PIPELINE_IDENTIFIER = 'predictive'
REQUIRED_TIER_IDENTIFIER = 'tier:3'
STABLE_BRANCH_SUFFIX = '-stable-ee'
TIER_IDENTIFIER_REGEX = /tier:\d/
PreMergeChecksFailedError = Class.new(StandardError)
PreMergeChecksStatus = Struct.new(:exitstatus, :message) do
def success?
exitstatus == 0
end
end
def initialize(
api_endpoint: ENV.fetch('CI_API_V4_URL', DEFAULT_API_ENDPOINT),
project_id: ENV['CI_PROJECT_ID'],
merge_request_iid: ENV['CI_MERGE_REQUEST_IID'],
target_branch: ENV['CI_MERGE_REQUEST_TARGET_BRANCH_NAME'])
@api_endpoint = api_endpoint
@project_id = project_id
@merge_request_iid = merge_request_iid.to_i
@target_branch = target_branch
end
def execute
check_required_ids!
# Find the first non merge-train pipeline in project with id `project_id`
latest_pipeline_id = api_client.merge_request_pipelines(project_id, merge_request_iid).auto_paginate do |pipeline|
next if pipeline.ref.match?(MERGE_TRAIN_REF_REGEX)
# We ensure we are comparing strings (not integers and strings)
next if pipeline.project_id.to_s != project_id.to_s
break pipeline.id
end
unless latest_pipeline_id
fail_check!("Expected to have a latest pipeline that ran in project ##{project_id} but got none!")
end
latest_pipeline = api_client.pipeline(project_id, latest_pipeline_id)
check_pipeline_for_merged_results!(latest_pipeline)
check_pipeline_success!(latest_pipeline)
check_pipeline_identifier!(latest_pipeline)
check_pipeline_freshness!(latest_pipeline)
PreMergeChecksStatus.new(0, "All good for merge! 🚀")
rescue PreMergeChecksFailedError => ex
PreMergeChecksStatus.new(1, ex.message)
end
private
attr_reader :api_endpoint, :project_id, :merge_request_iid, :target_branch
def api_client
@api_client ||= begin
Gitlab.configure do |config|
config.endpoint = api_endpoint
config.private_token = ENV.fetch('GITLAB_API_PRIVATE_TOKEN', '')
end
Gitlab.client
end
end
def check_required_ids!
fail_check!("Missing project_id!") unless project_id
fail_check!("Missing merge_request_iid!") if merge_request_iid == 0
fail_check!("Missing target_branch!") unless target_branch
end
def check_pipeline_for_merged_results!(pipeline)
return if pipeline.ref == "refs/merge-requests/#{merge_request_iid}/merge"
fail_check! <<~TEXT
Expected to have a Merged Results pipeline but got #{pipeline.ref}!
Please start a new pipeline.
TEXT
end
def check_pipeline_success!(pipeline)
return if pipeline.status == 'success'
fail_check! <<~TEXT
Expected latest pipeline (#{pipeline.web_url}) to be successful! Pipeline status was "#{pipeline.status}".
Please start a new pipeline.
TEXT
end
def check_pipeline_freshness!(pipeline)
hours_ago = ((Time.now - Time.parse(pipeline.created_at)) / 3600).ceil(2)
threshold =
if target_branch_is_stable_branch?
PIPELINE_FRESHNESS_STABLE_BRANCHES_THRESHOLD_IN_HOURS
else
PIPELINE_FRESHNESS_DEFAULT_THRESHOLD_IN_HOURS
end
return if hours_ago < threshold
fail_check! <<~TEXT
Expected latest pipeline (#{pipeline.web_url}) to be created within the last #{threshold} hours (it was created #{hours_ago} hours ago)!
Please start a new pipeline.
TEXT
end
def check_pipeline_identifier!(pipeline)
if pipeline.name.match?(TIER_IDENTIFIER_REGEX) && !pipeline.name.include?(REQUIRED_TIER_IDENTIFIER) # rubocop:disable Rails/NegateInclude -- Not executed in Rails context
fail_check! <<~MSG
Expected latest pipeline (#{pipeline.web_url}) to be a tier-3 pipeline! Pipeline name was "#{pipeline.name}".
Please ensure the MR has all the required approvals, start a new pipeline and put the MR back on the Merge Train.
MSG
end
return unless pipeline.name.include?(PREDICTIVE_PIPELINE_IDENTIFIER)
fail_check! <<~MSG
Expected latest pipeline (#{pipeline.web_url}) not to be a predictive pipeline! Pipeline name was "#{pipeline.name}".
Please ensure the MR has all the required approvals, start a new pipeline and put the MR back on the Merge Train.
MSG
end
def target_branch_is_stable_branch?
target_branch.end_with?(STABLE_BRANCH_SUFFIX)
end
def fail_check!(text)
raise PreMergeChecksFailedError, text
end
end
if $PROGRAM_NAME == __FILE__
require 'optparse'
options = {}
OptionParser.new do |opts|
opts.on("-p", "--project_id [string]", String, "Project ID") do |value|
options[:project_id] = value
end
opts.on("-m", "--merge_request_iid [string]", String, "Merge request IID") do |value|
options[:merge_request_iid] = value
end
opts.on("-t", "--target-branch [string]", String, "Target branch name") do |value|
options[:target_branch] = value
end
opts.on("-h", "--help") do
puts "Usage: #{File.basename(__FILE__)} [--project_id <PROJECT_ID>] " \
"[--merge_request_iid <MERGE_REQUEST_IID>] [--target-branch <TARGET_BRANCH>]"
puts
puts "Examples:"
puts
puts "#{File.basename(__FILE__)} --project_id \"gitlab-org/gitlab\" " \
"--merge_request_iid \"1\" --target-branch \"master\""
exit
end
end.parse!
colors_to_codes = {
red: 31,
green: 32
}.freeze
pre_merge_checks_status = PreMergeChecks.new(**options).execute
if pre_merge_checks_status.success?
puts "\e[#{colors_to_codes[:green]}m#{pre_merge_checks_status.message}\e[0m"
else
puts "\e[#{colors_to_codes[:red]}m#{pre_merge_checks_status.message}\e[0m"
end
exit(pre_merge_checks_status.exitstatus)
end