Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
danger / lib / danger / request_sources / github / github.rb
Size: Mime:
# coding: utf-8

# rubocop:disable Metrics/ClassLength

require "octokit"
require "danger/helpers/comments_helper"
require "danger/helpers/comment"
require "danger/request_sources/github/github_review"
require "danger/request_sources/github/github_review_unsupported"
require "danger/request_sources/support/get_ignored_violation"

module Danger
  module RequestSources
    class GitHub < RequestSource
      include Danger::Helpers::CommentsHelper

      attr_accessor :pr_json, :issue_json, :use_local_git, :support_tokenless_auth, :dismiss_out_of_range_messages, :host, :api_url, :verify_ssl

      def self.env_vars
        ["DANGER_GITHUB_API_TOKEN", "DANGER_GITHUB_BEARER_TOKEN"]
      end

      def self.optional_env_vars
        ["DANGER_GITHUB_HOST", "DANGER_GITHUB_API_BASE_URL", "DANGER_OCTOKIT_VERIFY_SSL"]
      end

      def initialize(ci_source, environment)
        self.ci_source = ci_source
        self.use_local_git = environment["DANGER_USE_LOCAL_GIT"]
        self.support_tokenless_auth = false
        self.dismiss_out_of_range_messages = false
        self.host = environment.fetch("DANGER_GITHUB_HOST", "github.com")
        # `DANGER_GITHUB_API_HOST` is the old name kept for legacy reasons and
        # backwards compatibility. `DANGER_GITHUB_API_BASE_URL` is the new
        # correctly named variable.
        self.api_url = environment.fetch("DANGER_GITHUB_API_HOST") do
          environment.fetch("DANGER_GITHUB_API_BASE_URL") do
            "https://api.github.com/".freeze
          end
        end
        self.verify_ssl = environment["DANGER_OCTOKIT_VERIFY_SSL"] != "false"

        @access_token = environment["DANGER_GITHUB_API_TOKEN"]
        @bearer_token = environment["DANGER_GITHUB_BEARER_TOKEN"]
      end

      def get_pr_from_branch(repo_name, branch_name, owner)
        prs = client.pull_requests(repo_name, head: "#{owner}:#{branch_name}")
        unless prs.empty?
          prs.first.number
        end
      end

      def validates_as_ci?
        true
      end

      def validates_as_api_source?
        valid_bearer_token? || valid_access_token? || use_local_git
      end

      def scm
        @scm ||= GitRepo.new
      end

      def client
        raise "No API token given, please provide one using `DANGER_GITHUB_API_TOKEN` or `DANGER_GITHUB_BEARER_TOKEN`" if !valid_access_token? && !valid_bearer_token? && !support_tokenless_auth
        @client ||= begin
          Octokit.configure do |config|
            config.connection_options[:ssl] = { verify: verify_ssl }
          end
          if valid_bearer_token?
            Octokit::Client.new(bearer_token: @bearer_token, auto_paginate: true, api_endpoint: api_url)
          elsif valid_access_token?
            Octokit::Client.new(access_token: @access_token, auto_paginate: true, api_endpoint: api_url)
          end
        end
      end

      def pr_diff
        @pr_diff ||= client.pull_request(ci_source.repo_slug, ci_source.pull_request_id, accept: "application/vnd.github.v3.diff")
      end

      def review
        return @review unless @review.nil?
        begin
          @review = client.pull_request_reviews(ci_source.repo_slug, ci_source.pull_request_id)
            .map { |review_json| Danger::RequestSources::GitHubSource::Review.new(client, ci_source, review_json) }
            .select(&:generated_by_danger?)
            .last
          @review ||= Danger::RequestSources::GitHubSource::Review.new(client, ci_source)
          @review
        rescue Octokit::NotFound
          @review = Danger::RequestSources::GitHubSource::ReviewUnsupported.new
          @review
        end
      end

      def setup_danger_branches
        # we can use a github specific feature here:
        base_branch = self.pr_json["base"]["ref"]
        base_commit = self.pr_json["base"]["sha"]
        head_branch = self.pr_json["head"]["ref"]
        head_commit = self.pr_json["head"]["sha"]

        # Next, we want to ensure that we have a version of the current branch at a known location
        scm.ensure_commitish_exists_on_branch! base_branch, base_commit
        self.scm.exec "branch #{EnvironmentManager.danger_base_branch} #{base_commit}"

        # OK, so we want to ensure that we have a known head branch, this will always represent
        # the head of the PR ( e.g. the most recent commit that will be merged. )
        scm.ensure_commitish_exists_on_branch! head_branch, head_commit
        self.scm.exec "branch #{EnvironmentManager.danger_head_branch} #{head_commit}"
      end

      def fetch_details
        self.pr_json = client.pull_request(ci_source.repo_slug, ci_source.pull_request_id)
        if self.pr_json["message"] == "Moved Permanently"
          raise "Repo moved or renamed, make sure to update the git remote".red
        end

        fetch_issue_details(self.pr_json)
        self.ignored_violations = ignored_violations_from_pr
      end

      def ignored_violations_from_pr
        GetIgnoredViolation.new(self.pr_json["body"]).call
      end

      def fetch_issue_details(pr_json)
        href = pr_json["_links"]["issue"]["href"]
        self.issue_json = client.get(href)
      end

      def issue_comments
        @comments ||= begin
          client.issue_comments(ci_source.repo_slug, ci_source.pull_request_id)
            .map { |comment| Comment.from_github(comment) }
        end
      end

      # Sending data to GitHub
      def update_pull_request!(warnings: [], errors: [], messages: [], markdowns: [],
                               danger_id: "danger", new_comment: false, remove_previous_comments: false)
        comment_result = {}
        editable_comments = issue_comments.select { |comment| comment.generated_by_danger?(danger_id) }
        last_comment = editable_comments.last
        should_create_new_comment = new_comment || last_comment.nil? || remove_previous_comments

        previous_violations =
          if should_create_new_comment
            {}
          else
            parse_comment(last_comment.body)
          end

        regular_violations = regular_violations_group(
          warnings: warnings,
          errors: errors,
          messages: messages,
          markdowns: markdowns
        )

        inline_violations = inline_violations_group(
          warnings: warnings,
          errors: errors,
          messages: messages,
          markdowns: markdowns
        )

        rest_inline_violations = submit_inline_comments!(**{
          danger_id: danger_id,
          previous_violations: previous_violations
        }.merge(inline_violations))

        main_violations = merge_violations(
          regular_violations, rest_inline_violations
        )

        main_violations_sum = main_violations.values.inject(:+)

        if (previous_violations.empty? && main_violations_sum.empty?) || remove_previous_comments
          # Just remove the comment, if there's nothing to say or --remove-previous-comments CLI was set.
          delete_old_comments!(danger_id: danger_id)
        end

        # If there are still violations to show
        if main_violations_sum.any?
          body = generate_comment(**{
            template: "github",
            danger_id: danger_id,
            previous_violations: previous_violations
          }.merge(main_violations))

          comment_result =
            if should_create_new_comment
              client.add_comment(ci_source.repo_slug, ci_source.pull_request_id, body)
            else
              client.update_comment(ci_source.repo_slug, last_comment.id, body)
            end
        end

        # Now, set the pull request status.
        # Note: this can terminate the entire process.
        submit_pull_request_status!(
          warnings: warnings,
          errors: errors,
          details_url: comment_result["html_url"],
          danger_id: danger_id
        )
      end

      def submit_pull_request_status!(warnings: [], errors: [], details_url: [], danger_id: "danger")
        status = (errors.count.zero? ? "success" : "failure")
        message = generate_description(warnings: warnings, errors: errors)
        latest_pr_commit_ref = self.pr_json["head"]["sha"]

        if latest_pr_commit_ref.empty? || latest_pr_commit_ref.nil?
          raise "Couldn't find a commit to update its status".red
        end

        begin
          client.create_status(ci_source.repo_slug, latest_pr_commit_ref, status, {
            description: message,
            context: "danger/#{danger_id}",
            target_url: details_url
          })
        rescue
          # This usually means the user has no commit access to this repo
          # That's always the case for open source projects where you can only
          # use a read-only GitHub account
          if errors.count > 0
            # We need to fail the actual build here
            is_private = pr_json["base"]["repo"]["private"]
            if is_private
              abort("\nDanger has failed this build. \nFound #{'error'.danger_pluralize(errors.count)} and I don't have write access to the PR to set a PR status.")
            else
              abort("\nDanger has failed this build. \nFound #{'error'.danger_pluralize(errors.count)}.")
            end
          else
            puts message
            puts "\nDanger does not have write access to the PR to set a PR status.".yellow
          end
        end
      end

      # Get rid of the previously posted comment, to only have the latest one
      def delete_old_comments!(except: nil, danger_id: "danger")
        issue_comments.each do |comment|
          next unless comment.generated_by_danger?(danger_id)
          next if comment.id == except
          client.delete_comment(ci_source.repo_slug, comment.id)
        end
      end

      def submit_inline_comments!(warnings: [], errors: [], messages: [], markdowns: [],
                                  previous_violations: [], danger_id: "danger")
        # Avoid doing any fetchs if there's no inline comments
        return {} if (warnings + errors + messages + markdowns).select(&:inline?).empty?

        diff_lines = self.pr_diff.lines
        pr_comments = client.pull_request_comments(ci_source.repo_slug, ci_source.pull_request_id)
        danger_comments = pr_comments.select { |comment| Comment.from_github(comment).generated_by_danger?(danger_id) }
        non_danger_comments = pr_comments - danger_comments

        warnings = submit_inline_comments_for_kind!(:warning, warnings, diff_lines, danger_comments, previous_violations["warning"], danger_id: danger_id)
        errors = submit_inline_comments_for_kind!(:error, errors, diff_lines, danger_comments, previous_violations["error"], danger_id: danger_id)
        messages = submit_inline_comments_for_kind!(:message, messages, diff_lines, danger_comments, previous_violations["message"], danger_id: danger_id)
        markdowns = submit_inline_comments_for_kind!(:markdown, markdowns, diff_lines, danger_comments, [], danger_id: danger_id)

        # submit removes from the array all comments that are still in force
        # so we strike out all remaining ones
        danger_comments.each do |comment|
          violation = violations_from_table(comment["body"]).first
          if !violation.nil? && violation.sticky
            body = generate_inline_comment_body("white_check_mark", violation, danger_id: danger_id, resolved: true, template: "github")
            client.update_pull_request_comment(ci_source.repo_slug, comment["id"], body)
          else
            # We remove non-sticky violations that have no replies
            # Since there's no direct concept of a reply in GH, we simply consider
            # the existence of non-danger comments in that line as replies
            replies = non_danger_comments.select do |potential|
              potential["path"] == comment["path"] &&
                potential["position"] == comment["position"] &&
                potential["commit_id"] == comment["commit_id"]
            end

            client.delete_pull_request_comment(ci_source.repo_slug, comment["id"]) if replies.empty?
          end
        end

        {
          warnings: warnings,
          errors: errors,
          messages: messages,
          markdowns: markdowns
        }
      end

      def messages_are_equivalent(m1, m2)
        blob_regexp = %r{blob/[0-9a-z]+/}
        m1.file == m2.file && m1.line == m2.line &&
          m1.message.sub(blob_regexp, "") == m2.message.sub(blob_regexp, "")
      end

      def submit_inline_comments_for_kind!(kind, messages, diff_lines, danger_comments, previous_violations, danger_id: "danger")
        head_ref = pr_json["head"]["sha"]
        previous_violations ||= []
        is_markdown_content = kind == :markdown
        emoji = { warning: "warning", error: "no_entry_sign", message: "book" }[kind]

        messages.reject do |m|
          next false unless m.file && m.line

          position = find_position_in_diff diff_lines, m, kind

          # Keep the change if it's line is not in the diff and not in dismiss mode
          next dismiss_out_of_range_messages_for(kind) if position.nil?

          # Once we know we're gonna submit it, we format it
          if is_markdown_content
            body = generate_inline_markdown_body(m, danger_id: danger_id, template: "github")
          else
            # Hide the inline link behind a span
            m = process_markdown(m, true)
            body = generate_inline_comment_body(emoji, m, danger_id: danger_id, template: "github")
            # A comment might be in previous_violations because only now it's part of the unified diff
            # We remove from the array since it won't have a place in the table anymore
            previous_violations.reject! { |v| messages_are_equivalent(v, m) }
          end

          matching_comments = danger_comments.select do |comment_data|
            if comment_data["path"] == m.file && comment_data["position"] == position
              # Parse it to avoid problems with strikethrough
              violation = violations_from_table(comment_data["body"]).first
              if violation
                messages_are_equivalent(violation, m)
              else
                blob_regexp = %r{blob/[0-9a-z]+/}
                comment_data["body"].sub(blob_regexp, "") == body.sub(blob_regexp, "")
              end
            else
              false
            end
          end

          if matching_comments.empty?
            begin
              client.create_pull_request_comment(ci_source.repo_slug, ci_source.pull_request_id,
                                                 body, head_ref, m.file, position)
            rescue Octokit::UnprocessableEntity => e
              # Show more detail for UnprocessableEntity error
              message = [e, "body: #{body}", "head_ref: #{head_ref}", "filename: #{m.file}", "position: #{position}"].join("\n")
              puts message

              # Not reject because this comment has not completed
              next false
            end
          else
            # Remove the surviving comment so we don't strike it out
            danger_comments.reject! { |c| matching_comments.include? c }

            # Update the comment to remove the strikethrough if present
            comment = matching_comments.first
            client.update_pull_request_comment(ci_source.repo_slug, comment["id"], body)
          end

          # Remove this element from the array
          next true
        end
      end

      def find_position_in_diff(diff_lines, message, kind)
        range_header_regexp = /@@ -([0-9]+)(,([0-9]+))? \+(?<start>[0-9]+)(,(?<end>[0-9]+))? @@.*/
        file_header_regexp = %r{^diff --git a/.*}

        pattern = "+++ b/" + message.file + "\n"
        file_start = diff_lines.index(pattern)

        # Files containing spaces sometimes have a trailing tab
        if file_start.nil?
          pattern = "+++ b/" + message.file + "\t\n"
          file_start = diff_lines.index(pattern)
        end

        return nil if file_start.nil?

        position = -1
        file_line = nil

        diff_lines.drop(file_start).each do |line|
          # If the line has `No newline` annotation, position need increment
          if line.eql?("\\ No newline at end of file\n")
            position += 1
            next
          end
          # If we found the start of another file diff, we went too far
          break if line.match file_header_regexp

          match = line.match range_header_regexp

          # file_line is set once we find the hunk the line is in
          # we need to count how many lines in new file we have
          # so we do it one by one ignoring the deleted lines
          if !file_line.nil? && !line.start_with?("-")
            if file_line == message.line
              file_line = nil if dismiss_out_of_range_messages_for(kind) && !line.start_with?("+")
              break
            end
            file_line += 1
          end

          # We need to count how many diff lines are between us and
          # the line we're looking for
          position += 1

          next unless match

          range_start = match[:start].to_i
          if match[:end]
            range_end = match[:end].to_i + range_start
          else
            range_end = range_start
          end

          # We are past the line position, just abort
          break if message.line.to_i < range_start
          next unless message.line.to_i >= range_start && message.line.to_i < range_end

          file_line = range_start
        end

        position unless file_line.nil?
      end

      # See the tests for examples of data coming in looks like
      def parse_message_from_row(row)
        message_regexp = %r{(<(a |span data-)href="https://#{host}/#{ci_source.repo_slug}/blob/[0-9a-z]+/(?<file>[^#]+)#L(?<line>[0-9]+)"(>[^<]*</a> - |/>))?(?<message>.*?)}im
        match = message_regexp.match(row)

        if match[:line]
          line = match[:line].to_i
        else
          line = nil
        end
        Violation.new(row, true, match[:file], line)
      end

      def markdown_link_to_message(message, hide_link)
        url = "https://#{host}/#{ci_source.repo_slug}/blob/#{pr_json['head']['sha']}/#{message.file}#L#{message.line}"

        if hide_link
          "<span data-href=\"#{url}\"/>"
        else
          "[#{message.file}#L#{message.line}](#{url}) - "
        end
      end

      # @return [String] The organisation name, is nil if it can't be detected
      def organisation
        matched = self.issue_json["repository_url"].match(%r{repos\/(.*)\/})
        return matched[1] if matched && matched[1]
      rescue
        nil
      end

      def dismiss_out_of_range_messages_for(kind)
        if self.dismiss_out_of_range_messages.kind_of?(Hash) && self.dismiss_out_of_range_messages[kind]
          self.dismiss_out_of_range_messages[kind]
        elsif self.dismiss_out_of_range_messages == true
          self.dismiss_out_of_range_messages
        else
          false
        end
      end

      # @return [String] A URL to the specific file, ready to be downloaded
      def file_url(organisation: nil, repository: nil, branch: nil, path: nil)
        organisation ||= self.organisation

        begin
          # Retrieve the download URL (default branch on nil param)
          contents = client.contents("#{organisation}/#{repository}", path: path, ref: branch)
          @download_url = contents["download_url"]
        rescue Octokit::ClientError
          # Fallback to github.com
          branch ||= "master"
          @download_url = "https://raw.githubusercontent.com/#{organisation}/#{repository}/#{branch}/#{path}"
        end
      end

      private

      def valid_access_token?
        @access_token && !@access_token.empty?
      end

      def valid_bearer_token?
        @bearer_token && !@bearer_token.empty?
      end

      def regular_violations_group(warnings: [], errors: [], messages: [], markdowns: [])
        {
          warnings: warnings.reject(&:inline?),
          errors: errors.reject(&:inline?),
          messages: messages.reject(&:inline?),
          markdowns: markdowns.reject(&:inline?)
        }
      end

      def inline_violations_group(warnings: [], errors: [], messages: [], markdowns: [])
        cmp = proc do |a, b|
          next -1 unless a.file && a.line
          next 1 unless b.file && b.line

          next a.line <=> b.line if a.file == b.file
          next a.file <=> b.file
        end

        # Sort to group inline comments by file
        {
          warnings: warnings.select(&:inline?).sort(&cmp),
          errors: errors.select(&:inline?).sort(&cmp),
          messages: messages.select(&:inline?).sort(&cmp),
          markdowns: markdowns.select(&:inline?).sort(&cmp)
        }
      end

      def merge_violations(*violation_groups)
        violation_groups.inject({}) do |accumulator, group|
          accumulator.merge(group) { |_, old, fresh| old + fresh }
        end
      end
    end
  end
end