lib/release_tools/gitlab_client.rb (511 lines of code) (raw):
# frozen_string_literal: true
module ReleaseTools
# rubocop: disable Metrics/ClassLength
class GitlabClient
include ::SemanticLogger::Loggable
DEFAULT_GITLAB_API_ENDPOINT = 'https://gitlab.com/api/v4'
DEFAULT_GITLAB_GRAPHQL_ENDPOINT = 'https://gitlab.com/api/graphql'
# The regular expression to use for matching auto deploy branch names.
AUTO_DEPLOY_BRANCH_REGEX = /\A\d+-\d+-auto-deploy-\d{8,}\z/
# Some methods get delegated directly to the internal client
class << self
extend Forwardable
def_delegator :client, :commit_refs
def_delegator :client, :create_group_label
def_delegator :client, :create_issue_link
def_delegator :client, :create_merge_request_comment
def_delegator :client, :create_variable
def_delegator :client, :create_environment
def_delegator :client, :delete_issue_link
def_delegator :client, :edit_issue
def_delegator :client, :environments
def_delegator :client, :group_labels
def_delegator :client, :group_milestones
def_delegator :client, :group_projects
def_delegator :client, :issue
def_delegator :client, :issue_links
def_delegator :client, :protect_branch
def_delegator :client, :unprotect_branch
def_delegator :client, :update_merge_request
def_delegator :client, :update_variable
def_delegator :client, :version
def_delegator :client, :job
def_delegator :client, :search_in_project
end
class MissingMilestone
def id
nil
end
end
def self.current_user
@current_user ||= client.user
end
def self.default_branch(project)
client.project(project_path(project)).default_branch
end
def self.job_play(project, *)
client.job_play(project_path(project), *)
end
def self.file_contents(project, *)
client.file_contents(project_path(project), *)
end
def self.get_file(project, *)
client.get_file(project_path(project), *)
end
def self.create_file(project, *)
client.create_file(project_path(project), *)
end
def self.edit_file(project, *)
client.edit_file(project_path(project), *)
end
def self.issues(project = Project::GitlabCe, options = {})
client.issues(project_path(project), options)
end
def self.merge_base(project, refs)
client.merge_base(project_path(project), refs)
end
def self.merge_requests(project = Project::GitlabCe, options = {})
client.merge_requests(project_path(project), options)
end
def self.merge_request(project = Project::GitlabCe, iid:)
client.merge_request(project_path(project), iid)
end
def self.merge_request_changes(project, iid:)
client.merge_request_changes(project_path(project), iid)
end
def self.pipelines(project = Project::GitlabCe, options = {})
client.pipelines(project_path(project), options)
end
def self.pipeline(project = Project::GitlabCe, pipeline_id)
client.pipeline(project_path(project), pipeline_id)
end
def self.pipeline_jobs(project = Project::GitlabCe, pipeline_id = nil, options = {})
client.pipeline_jobs(project_path(project), pipeline_id, options)
end
# rubocop:disable Metrics/ParameterLists
def self.pipeline_job_by_name(project = Project::GitlabCe, pipeline_id = nil, job_name = nil, options = {})
client.pipeline_jobs(project_path(project), pipeline_id, { per_page: 100 }.merge(options)).auto_paginate do |job|
return job if job.name == job_name
end
end
# rubocop:enable Metrics/ParameterLists
def self.job_trace(project = Project::GitlabCe, job_id)
client.job_trace(project_path(project), job_id)
end
def self.run_trigger(project = Project::GitlabCe, token, ref, options)
client.run_trigger(project_path(project), token, ref, options)
end
def self.commit_merge_requests(project = Project::GitlabCe, sha:)
client.commit_merge_requests(project_path(project), sha)
end
def self.compare(project = Project::GitlabCe, from:, to:)
client.compare(project_path(project), from, to)
end
def self.commits(project = Project::GitlabCe, options = {})
client.commits(project_path(project), options)
end
def self.commit(project = Project::GitlabCe, ref:)
client.commit(project_path(project), ref)
end
def self.create_commit(project, *)
client.create_commit(project_path(project), *)
end
def self.commit_diff(project, sha)
client.commit_diff(project_path(project), sha)
end
def self.commit_status(project, sha, options = {})
client.commit_status(project_path(project), sha, options)
end
def self.create_issue_note(project = Project::GitlabCe, issue:, body:)
client.create_issue_note(project_path(project), issue.iid, body)
end
def self.issue_notes(project, issue_iid:)
client.issue_notes(project_path(project), issue_iid)
end
def self.edit_issue_note(project, issue_iid:, note_id:, body:)
client.edit_issue_note(project_path(project), issue_iid, note_id, body)
end
def self.close_issue(project = Project::GitlabCe, issue)
client.close_issue(project_path(project), issue.iid)
end
def self.milestones(project = Project::GitlabCe, options = {})
# FIXME (rspeicher): See https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1032
project_milestones = client.milestones(project_path(project), options).auto_paginate
group_milestones = client.group_milestones('gitlab-org', options).auto_paginate
project_milestones + group_milestones
end
def self.current_milestone
ReleaseTools::ProductMilestone.current || MissingMilestone.new
end
def self.milestone(project = Project::GitlabCe, title:)
return MissingMilestone.new if title.nil?
# FIXME (rspeicher): See https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1032
milestones(project, include_parent_milestones: true)
.detect { |m| m.title == title } || raise("Milestone #{title} not found for project #{project_path(project)}!")
end
# Create an issue in the CE project based on the provided issue
#
# issue - An object that responds to the following messages:
# :title - Issue title String
# :description - Issue description String
# :labels - Comma-separated String of label names
# :version - Version object
# :assignees - An Array of user IDs to use as the assignees.
# project - An object that responds to :path
#
# The issue is always assigned to the authenticated user.
#
# Returns a Gitlab::ObjectifiedHash object
def self.create_issue(issue, project = Project::GitlabCe)
assignees =
if issue.respond_to?(:assignees)
issue.assignees
else
[current_user.id]
end
options = {
description: issue.description,
assignee_ids: assignees,
labels: issue.labels,
confidential: issue.confidential?
}
options[:due_date] = issue.due_date if issue.respond_to?(:due_date)
options[:milestone_id] = milestone(project, title: issue.version.milestone_name).id if issue.try(:version)
client.create_issue(
project_path(project),
issue.title,
options
)
end
# Update an issue in the CE project based on the provided issue
#
# issue - An object that responds to the following messages:
# :title - Issue title String
# :description - Issue description String
# :labels - Comma-separated String of label names
# project - An object that responds to :path
#
# The issue is always assigned to the authenticated user.
#
# Returns a Gitlab::ObjectifiedHash object
def self.update_issue(issue, project = Project::GitlabCe)
options = {}
options[:description] = issue.description if issue.try(:description)
options[:labels] = issue.labels if issue.try(:labels)
options[:confidential] = issue.confidential? if issue.try(:confidential?)
options[:milestone_id] = milestone(project, title: issue.version.milestone_name).id if issue.try(:version)
client.edit_issue(
project_path(project),
issue.iid,
options
)
end
# Link an issue as related to another
#
# issue - An Issuable object
# target - An Issuable object
def self.link_issues(issue, target)
# NOTE: The GitLab gem doesn't currently support paths for the target_project_id
path = client.url_encode(project_path(issue.project))
# NOTE: `target_project_id` parameter doesn't support encoded values
# See https://gitlab.com/gitlab-org/gitlab/-/issues/9143
client.post(
"/projects/#{path}/issues/#{issue.iid}/links", query: {
target_project_id: project_path(target.project),
target_issue_iid: target.iid
}
)
end
# Delete an issue link two related issues
#
# issue - An Issuable object
# link_id - an Issue link id (returned by issue_links)
def self.delete_issue_link(issue, link_id)
client.delete_issue_link(project_path(issue.project), issue.iid, link_id)
end
# Create a branch with the given name
#
# branch_name - Name of the new branch
# ref - commit sha or existing branch ref
# project - An object that responds to :path
#
# Returns a Gitlab::ObjectifiedHash object
def self.create_branch(branch_name, ref, project = Project::GitlabCe)
client.create_branch(project_path(project), branch_name, ref)
end
def self.delete_branch(branch_name, project = Project::GitlabCe)
client.delete_branch(project_path(project), branch_name)
end
def self.branches(project = Project::GitlabCe, options = {})
client.branches(project_path(project), options)
end
# Find a branch in a given project
#
# Returns a Gitlab::ObjectifiedHash object, or nil
def self.find_branch(branch_name, project = Project::GitlabCe)
client.branch(project_path(project), branch_name)
rescue Gitlab::Error::NotFound
nil
end
def self.tree(project, options = {})
client.tree(project_path(project), options)
end
def self.find_or_create_branch(branch, ref, project = Project::GitlabCe)
if (existing = find_branch(branch, project))
existing
else
create_branch(branch, ref, project)
end
end
def self.find_or_create_tag(project, tag, ref, message: nil)
tag(project, tag: tag)
rescue Gitlab::Error::NotFound
create_tag(project, tag, ref, message)
end
# Create a merge request in the given project based on the provided merge request
#
# merge_request - An object that responds to the following messages:
# :title - Merge request title String
# :description - Merge request description String
# :labels - Comma-separated String of label names
# :source_branch - The source branch
# :target_branch - The target branch
# project - An object that responds to :path
#
# The merge request is always assigned to the authenticated user.
#
# Returns a Gitlab::ObjectifiedHash object
def self.create_merge_request(merge_request, project = Project::GitlabCe)
milestone =
if merge_request.milestone.nil?
current_milestone
else
milestone(project, title: merge_request.milestone)
end
params = {
description: merge_request.description,
labels: merge_request.labels,
source_branch: merge_request.source_branch,
target_branch: merge_request.target_branch,
milestone_id: milestone.id,
remove_source_branch: true
}
params[:assignee_ids] =
if merge_request.assignee_ids.present?
merge_request.assignee_ids
else
[current_user.id]
end
client.create_merge_request(project_path(project), merge_request.title, params)
end
# Accept a merge request in the given project specified by the iid
#
# merge_request - An object that responds to the following message:
# :iid - Internal id of merge request
# project - An object that responds to :path
#
# Returns a Gitlab::ObjectifiedHash object
def self.accept_merge_request(merge_request, project = Project::GitlabCe)
params = {
merge_when_pipeline_succeeds: true
}
client.accept_merge_request(project_path(project), merge_request.iid, params)
end
def self.cancel_merge_when_pipeline_succeeds(merge_request)
client.post(
"/projects/#{merge_request.project_id}/merge_requests/#{merge_request.iid}/cancel_merge_when_pipeline_succeeds"
)
end
# Find an issue in the given project based on the provided issue
#
# issue - An object that responds to the following messages:
# :title - Issue title String
# :labels - Comma-separated String of label names
# project - An object that responds to :path
#
# Returns a Gitlab::ObjectifiedHash object, or nil
def self.find_issue(issue, project = Project::GitlabCe)
opts = {
labels: issue.labels,
search: issue.title
}
opts[:milestone] = issue.version&.milestone_name if issue.try(:version)
issues(project, opts).detect { |i| i.title == issue.title }
end
def self.cherry_pick(project = Project::GitlabCe, ref:, target:, dry_run: false, message: nil)
raise ArgumentError, "Invalid ref" if ref.blank?
options = { dry_run: dry_run }
# If `message` is explicitly passed as `nil`, the Grape API treats this as
# an empty String, resulting in an empty commit message.
options[:message] = message if message
client.cherry_pick_commit(project_path(project), ref, target, options)
end
# Add the given merge request, for the given sha to the given project's Merge Train
#
# merge_request - An object that responds to the following messages:
# :iid - Merge request IID
# :project_id - Project ID
# sha - The SHA at which the MR should be added to the Merge Train
#
# Returns the response's status
def self.add_to_merge_train(merge_request, sha, graphql: true, pipeline_succeed: false, **args)
if graphql
result = graphql_query(ReleaseTools::GitlabGraphqlQueries::ADD_TO_MERGE_TRAIN_QUERY, project: merge_request.project_id, mr: merge_request.iid.to_s, sha: sha, **args)
raise "MR couldn't be added to Merge Train: #{result.errors.messages.to_h.to_json}" if result.errors.any?
result.merge_request_accept.merge_request.auto_merge_enabled
else
client.post(
"/projects/#{merge_request.project_id}/merge_trains/merge_requests/#{merge_request.iid}",
body: {
sha: sha,
when_pipeline_succeeds: pipeline_succeed
}
)
end
end
def self.graphql_query(parsed_query, **args)
client = graphql_client(args.delete(:token))
client.query(parsed_query, **args)
end
def self.client
@client ||= Gitlab.client(
endpoint: DEFAULT_GITLAB_API_ENDPOINT,
private_token: ENV.fetch('RELEASE_BOT_PRODUCTION_TOKEN', nil),
httparty: httparty_opts
)
end
def self.graphql_client(token = nil)
GraphqlAdapter.new(
endpoint: DEFAULT_GITLAB_GRAPHQL_ENDPOINT,
token: token || ENV.fetch('RELEASE_BOT_PRODUCTION_TOKEN', nil)
)
end
def self.httparty_opts
{ logger: logger, log_level: :debug }
end
# Overridden by GitLabDevClient
def self.project_path(project)
if project.respond_to?(:path)
project.path
else
project
end
end
# Overridden by GitLabOpsClient
def self.project_id(project)
project.canonical_id
end
def self.tag(project, tag:)
client.tag(project_path(project), tag)
end
def self.create_tag(project, *)
client.create_tag(project_path(project), *)
end
def self.tags(project, *)
client.tags(project_path(project), *)
end
# rubocop: disable Metrics/ParameterLists
def self.create_deployment(project, environment, ref:, sha:, status:, tag: false)
client.post(
"/projects/#{client.url_encode(project_path(project))}/deployments",
body: {
ref: ref,
sha: sha,
tag: tag,
status: status,
environment: environment
}
)
end
# rubocop: enable Metrics/ParameterLists
def self.update_deployment(project, id, attrs = {})
client.put(
"/projects/#{client.url_encode(project_path(project))}/deployments/#{id}",
body: attrs
)
end
# rubocop: disable Metrics/ParameterLists
def self.update_or_create_deployment(project, environment, ref:, sha:, status:, tag: false)
# First try to find a running deployment with this environment, ref, and sha
existing = deployments(project, environment, status: 'running')
.detect { |dep| dep.ref == ref && dep.sha == sha }
if existing && existing.status == status
existing
elsif existing
update_deployment(project, existing.id, status: status)
else
create_deployment(project, environment, ref: ref, sha: sha, status: status, tag: tag)
end
end
# rubocop: enable Metrics/ParameterLists
# rubocop: disable Metrics/ParameterLists
def self.deployments(project, environment, status: nil, order_by: 'id', sort: 'desc', opts: {})
options = {
environment: environment,
order_by: order_by,
sort: sort
}
options[:status] = status if status
options = opts.merge(options)
client.deployments(project_path(project), options)
end
# rubocop: enable Metrics/ParameterLists
def self.last_successful_deployment(project_path, environment)
options = {
environment: environment,
status: 'success',
order_by: 'id',
sort: 'desc',
per_page: 1
}
client.deployments(project_path, options).first
end
def self.deployed_merge_requests(project, deployment_id)
project_path = client.url_encode(project_path(project))
client.get(
"/projects/#{project_path}/deployments/#{deployment_id}/merge_requests"
)
end
def self.related_merge_requests(project, issue_iid)
project_path = client.url_encode(project_path(project))
client.get(
"/projects/#{project_path}/issues/#{issue_iid}/related_merge_requests"
)
end
def self.remote_mirrors(project)
client.remote_mirrors(project_path(project))
end
def self.sync_remote_mirror(project, mirror_id)
project_path = client.url_encode(project_path(project))
client.post(
"/projects/#{project_path}/remote_mirrors/#{mirror_id}/sync"
)
end
def self.create_merge_request_pipeline(project_id, merge_request_id)
client.post(
"/projects/#{client.url_encode(project_id)}/merge_requests/#{merge_request_id}/pipelines"
)
end
def self.compile_changelog(project, version, commit, branch, skip_ci: true)
path = project_path(project)
message = ["Update changelog for #{version}"]
message << "\n[ci skip]" if skip_ci
client.post(
"/projects/#{client.url_encode(path)}/repository/changelog",
body: {
version: version,
to: commit,
branch: branch,
message: message.join("\n")
}
)
end
def self.merge_request_commits(project, id)
client.merge_request_commits(project_path(project), id)
end
def self.pipeline_bridges(project = Project::ReleaseTools, pipeline_id)
client.pipeline_bridges(project_path(project), pipeline_id)
end
def self.create_pipeline(project, variables, branch: project.default_branch)
client.create_pipeline(
project_path(project),
branch,
variables
)
end
def self.download_raw_job_artifact(project, job_id, artifact_path)
client.get(
"/projects/#{project_id(project)}/jobs/#{job_id}/artifacts/#{artifact_path}",
format: nil,
headers: { Accept: 'application/octet-stream' },
parser: ::Gitlab::Request::Parser
)
end
def self.cancel_job(project, job_id)
client.job_cancel(project_path(project), job_id)
end
def self.pipeline_variables(project, pipeline_id)
path = client.url_encode(project_path(project))
client.get("/projects/#{path}/pipelines/#{pipeline_id}/variables")
end
def self.update_project_variable(project, variable_key, value)
path = client.url_encode(project_path(project))
client.put("/projects/#{path}/variables/#{variable_key}", body: { value: value })
end
def self.pipeline_schedules(project)
client.pipeline_schedules(project_path(project))
end
def self.pipeline_schedule(project, pipeline_schedule_id)
client.pipeline_schedule(project_path(project), pipeline_schedule_id)
end
def self.pipeline_schedule_take_ownership(project, pipeline_schedule_id)
client.pipeline_schedule_take_ownership(project_path(project), pipeline_schedule_id)
end
def self.run_pipeline_schedule(project, pipeline_schedule_id)
client.run_pipeline_schedule(project_path(project), pipeline_schedule_id)
end
def self.edit_pipeline_schedule(project, pipeline_schedule_id, options = {})
client.edit_pipeline_schedule(project_path(project), pipeline_schedule_id, options)
end
def self.edit_pipeline_schedule_variable(project, pipeline_schedule_id, key, options = {})
client.edit_pipeline_schedule_variable(project_path(project), pipeline_schedule_id, key, options)
end
def self.security_blog_merge_request(security: true)
project = ReleaseTools::Project::WWWGitlabCom
path = security ? project.security_path : project.path
client.merge_requests(path, labels: 'patch release post', state: 'opened').first
end
def self.next_security_tracking_issue
project = ReleaseTools::Project::GitlabEe
label = 'upcoming security release'
# project.path is required because the security tracking issue only exists on canonical
client
.issues(project.path, labels: label, state: 'opened')
.first
end
def self.current_security_task_issue
project = ReleaseTools::Project::Release::Tasks
labels = ['security', 'Monthly Release']
client
.issues(project.path, labels: labels, state: 'opened')
.first
end
def self.security_communication_issue
project = ReleaseTools::Project::GlSecurity::SecurityCommunications::Communications
label = 'Security Release Blog Alert::development'
client
.issues(project.path, labels: label, state: 'opened')
.first
end
def self.project_access_tokens(project)
path = client.url_encode(project_path(project))
client.get("/projects/#{path}/access_tokens")
end
def self.rotate_project_access_token(project, token_id, expires_at: nil)
path = client.url_encode(project_path(project))
client.post("/projects/#{path}/access_tokens/#{token_id}/rotate",
body: { expires_at: expires_at }.compact)
end
def self.approve_merge_request(project, merge_request_iid)
client
.approve_merge_request(project_path(project), merge_request_iid)
end
def self.merge_request_approvals(project, merge_request_iid)
client
.merge_request_approvals(project_path(project), merge_request_iid)
end
def self.merge_request_pipelines(project, merge_request_iid)
client.merge_request_pipelines(project_path(project), merge_request_iid)
end
def self.merge_request_diffs(project, merge_request_iid)
project_path = client.url_encode(project_path(project))
client.get("/projects/#{project_path}/merge_requests/#{merge_request_iid}/diffs")
end
def self.edit_resource_group(resource_group_key:, process_mode:, project: ENV.fetch('CI_PROJECT_PATH'))
path = client.url_encode(project_path(project))
client.put("/projects/#{path}/resource_groups/#{resource_group_key}", body: { process_mode: process_mode })
end
def self.issue_label_events(project, issue_iid)
client.issue_label_events(project_path(project), issue_iid)
end
end
# rubocop: enable Metrics/ClassLength
end