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