# frozen_string_literal: true require "octokit" require "securerandom" require "dependabot/clients/github_with_retries" require "dependabot/pull_request_creator" require "dependabot/pull_request_creator/commit_signer" module Dependabot class PullRequestCreator class Github attr_reader :source, :branch_name, :base_commit, :credentials, :files, :pr_description, :pr_name, :commit_message, :author_details, :signature_key, :labeler, :reviewers, :assignees, :milestone def initialize(source:, branch_name:, base_commit:, credentials:, files:, commit_message:, pr_description:, pr_name:, author_details:, signature_key:, labeler:, reviewers:, assignees:, milestone:) @source = source @branch_name = branch_name @base_commit = base_commit @credentials = credentials @files = files @commit_message = commit_message @pr_description = pr_description @pr_name = pr_name @author_details = author_details @signature_key = signature_key @labeler = labeler @reviewers = reviewers @assignees = assignees @milestone = milestone end def create return if branch_exists? && pull_request_exists? commit = create_commit branch = create_or_update_branch(commit) return unless branch pull_request = create_pull_request return unless pull_request annotate_pull_request(pull_request) pull_request rescue Octokit::Error => error handle_error(error) end private def github_client_for_source @github_client_for_source ||= Dependabot::Clients::GithubWithRetries.for_source( source: source, credentials: credentials ) end def branch_exists? @branch_ref ||= github_client_for_source.ref(source.repo, "heads/#{branch_name}") if @branch_ref.is_a?(Array) @branch_ref.any? { |r| r.ref == "refs/heads/#{branch_name}" } else @branch_ref.ref == "refs/heads/#{branch_name}" end rescue Octokit::NotFound false end def pull_request_exists? github_client_for_source.pull_requests( source.repo, head: "#{source.repo.split('/').first}:#{branch_name}", state: "all" ).any? rescue Octokit::InternalServerError # A GitHub bug sometimes means adding `state: all` causes problems. # In that case, fall back to making two separate requests. open_prs = github_client_for_source.pull_requests( source.repo, head: "#{source.repo.split('/').first}:#{branch_name}", state: "open" ) closed_prs = github_client_for_source.pull_requests( source.repo, head: "#{source.repo.split('/').first}:#{branch_name}", state: "closed" ) [*open_prs, *closed_prs].any? end def repo_exists? github_client_for_source.repo(source.repo) true rescue Octokit::NotFound false end def create_commit tree = create_tree options = author_details&.any? ? { author: author_details } : {} if options[:author]&.any? && signature_key options[:author][:date] = Time.now.utc.iso8601 options[:signature] = commit_signature(tree, options[:author]) end github_client_for_source.create_commit( source.repo, commit_message, tree.sha, base_commit, options ) end def create_tree file_trees = files.map do |file| if file.type == "submodule" { path: file.path.sub(%r{^/}, ""), mode: "160000", type: "commit", sha: file.content } else { path: file.path.sub(%r{^/}, ""), mode: "100644", type: "blob", content: file.content } end end github_client_for_source.create_tree( source.repo, file_trees, base_tree: base_commit ) end def create_or_update_branch(commit) branch_exists? ? update_branch(commit) : create_branch(commit) rescue Octokit::UnprocessableEntity # A race condition may cause GitHub to fail here, in which case we retry retry_count ||= 0 retry_count += 1 retry unless retry_count >= 2 end def create_branch(commit) github_client_for_source.create_ref( source.repo, "heads/#{branch_name}", commit.sha ) rescue Octokit::UnprocessableEntity => error # Return quietly in the case of a race return nil if error.message.match?(/Reference already exists/i) raise if @retrying_branch_creation @retrying_branch_creation = true # Branch creation will fail if a branch called `dependabot` already # exists, since git won't be able to create a folder with the same name @branch_name = SecureRandom.hex[0..3] + @branch_name retry end def update_branch(commit) github_client_for_source.update_ref( source.repo, "heads/#{branch_name}", commit.sha, true ) end def annotate_pull_request(pull_request) labeler.label_pull_request(pull_request.number) add_reviewers_to_pull_request(pull_request) if reviewers&.any? add_assignees_to_pull_request(pull_request) if assignees&.any? add_milestone_to_pull_request(pull_request) if milestone end def add_reviewers_to_pull_request(pull_request) reviewers_hash = Hash[reviewers.keys.map { |k| [k.to_sym, reviewers[k]] }] github_client_for_source.request_pull_request_review( source.repo, pull_request.number, reviewers: reviewers_hash[:reviewers] || [], team_reviewers: reviewers_hash[:team_reviewers] || [] ) rescue Octokit::UnprocessableEntity => error return if error.message.include?("not a collaborator") return if error.message.include?("Could not resolve to a node") raise end def add_assignees_to_pull_request(pull_request) github_client_for_source.add_assignees( source.repo, pull_request.number, assignees ) end def add_milestone_to_pull_request(pull_request) github_client_for_source.update_issue( source.repo, pull_request.number, milestone: milestone ) end def create_pull_request github_client_for_source.create_pull_request( source.repo, source.branch || default_branch, branch_name, pr_name, pr_description ) rescue Octokit::UnprocessableEntity => error # Ignore races that we lose raise unless error.message.include?("pull request already exists") end def default_branch @default_branch ||= github_client_for_source.repository(source.repo).default_branch end def commit_signature(tree, author_details_with_date) CommitSigner.new( author_details: author_details_with_date, commit_message: commit_message, tree_sha: tree.sha, parent_sha: base_commit, signature_key: signature_key ).signature end def handle_error(error) case error when Octokit::Forbidden raise error unless error.message.include?("Repository was archived") raise RepoArchived, error.message when Octokit::NotFound raise error if repo_exists? raise RepoNotFound, error.message when Octokit::UnprocessableEntity raise error unless error.message.include?("no history in common") raise NoHistoryInCommon, error.message else raise error end end end end end