class Danger::RequestSources::GitHub

Attributes

api_url[RW]
dismiss_out_of_range_messages[RW]
host[RW]
issue_json[RW]
pr_json[RW]
support_tokenless_auth[RW]
use_local_git[RW]
verify_ssl[RW]

Public Class Methods

env_vars() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 17
def self.env_vars
  ["DANGER_GITHUB_API_TOKEN", "DANGER_GITHUB_BEARER_TOKEN"]
end
new(ci_source, environment) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 25
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
optional_env_vars() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 21
def self.optional_env_vars
  ["DANGER_GITHUB_HOST", "DANGER_GITHUB_API_BASE_URL", "DANGER_OCTOKIT_VERIFY_SSL"]
end

Public Instance Methods

client() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 64
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
delete_old_comments!(except: nil, danger_id: "danger") click to toggle source

Get rid of the previously posted comment, to only have the latest one

# File lib/danger/request_sources/github/github.rb, line 262
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
delete_old_inline_violations(danger_comments: [], non_danger_comments: []) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 299
def delete_old_inline_violations(danger_comments: [], non_danger_comments: [])
  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
end
dismiss_out_of_range_messages_for(kind) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 489
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
fetch_details() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 133
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
fetch_issue_details(pr_json) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 147
def fetch_issue_details(pr_json)
  href = pr_json["_links"]["issue"]["href"]
  self.issue_json = client.get(href)
end
file_url(organisation: nil, repository: nil, ref: nil, branch: nil, path: nil) click to toggle source

@return [String] A URL to the specific file, ready to be downloaded

# File lib/danger/request_sources/github/github.rb, line 500
def file_url(organisation: nil, repository: nil, ref: nil, branch: nil, path: nil)
  organisation ||= self.organisation
  ref ||= branch

  begin
    # Retrieve the download URL (default ref on nil param)
    contents = client.contents("#{organisation}/#{repository}", path: path, ref: ref)
    @download_url = contents["download_url"]
  rescue Octokit::ClientError
    # Fallback to github.com
    ref ||= "master"
    @download_url = "https://raw.githubusercontent.com/#{organisation}/#{repository}/#{ref}/#{path}"
  end
end
find_position_in_diff(diff_lines, message, kind) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 395
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
get_pr_from_branch(repo_name, branch_name, owner) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 45
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
ignored_violations_from_pr() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 143
def ignored_violations_from_pr
  GetIgnoredViolation.new(self.pr_json["body"]).call
end
issue_comments() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 152
def issue_comments
  @comments ||= client.issue_comments(ci_source.repo_slug, ci_source.pull_request_id)
    .map { |comment| Comment.from_github(comment) }
end
messages_are_equivalent(m1, m2) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 320
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
organisation() click to toggle source

@return [String] The organisation name, is nil if it can’t be detected

# File lib/danger/request_sources/github/github.rb, line 482
def organisation
  matched = self.issue_json["repository_url"].match(%r{repos/(.*)/})
  return matched[1] if matched && matched[1]
rescue StandardError
  nil
end
parse_message_from_row(row) click to toggle source

See the tests for examples of data coming in looks like

# File lib/danger/request_sources/github/github.rb, line 459
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
pr_diff() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 79
      def pr_diff
        # This is a hack to get the file patch into a format that parse-diff accepts
        # as the GitHub API for listing pull request files is missing file names in the patch.
        prefixed_patch = lambda do |file:|
          <<~PATCH
          diff --git a/#{file['filename']} b/#{file['filename']}
          --- a/#{file['filename']}
          +++ b/#{file['filename']}
          #{file['patch']}
          PATCH
        end

        files = client.pull_request_files(
          ci_source.repo_slug,
          ci_source.pull_request_id,
          accept: "application/vnd.github.v3.diff"
        )

        @pr_diff ||= files.map { |file| prefixed_patch.call(file: file) }.join("\n")
      end
review() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 100
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
scm() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 60
def scm
  @scm ||= GitRepo.new
end
setup_danger_branches() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 116
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
submit_inline_comments!(warnings: [], errors: [], messages: [], markdowns: [], previous_violations: [], danger_id: "danger") click to toggle source
# File lib/danger/request_sources/github/github.rb, line 271
def submit_inline_comments!(warnings: [], errors: [], messages: [], markdowns: [], previous_violations: [], danger_id: "danger")
  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

  if (warnings + errors + messages + markdowns).select(&:inline?).empty?
    delete_old_inline_violations(danger_comments: danger_comments, non_danger_comments: non_danger_comments)
    return {}
  end

  diff_lines = self.pr_diff.lines
  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
  delete_old_inline_violations(danger_comments: danger_comments, non_danger_comments: non_danger_comments)

  {
    warnings: warnings,
    errors: errors,
    messages: messages,
    markdowns: markdowns
  }
end
submit_inline_comments_for_kind!(kind, messages, diff_lines, danger_comments, previous_violations, danger_id: "danger") click to toggle source
# File lib/danger/request_sources/github/github.rb, line 326
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
        # Since Octokit v8, the signature of create_pull_request_comment has been changed.
        # See https://github.com/danger/danger/issues/1475 for detailed information.
        client.create_pull_request_comment(ci_source.repo_slug, ci_source.pull_request_id,
                                           body, head_ref, m.file, (Octokit::MAJOR >= 8 ? m.line : 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
submit_pull_request_status!(warnings: [], errors: [], details_url: [], danger_id: "danger") click to toggle source
# File lib/danger/request_sources/github/github.rb, line 227
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 StandardError
    # 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
update_pull_request!(warnings: [], errors: [], messages: [], markdowns: [], danger_id: "danger", new_comment: false, remove_previous_comments: false) click to toggle source

Sending data to GitHub

# File lib/danger/request_sources/github/github.rb, line 158
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
validates_as_api_source?() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 56
def validates_as_api_source?
  valid_bearer_token? || valid_access_token? || use_local_git
end
validates_as_ci?() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 52
def validates_as_ci?
  true
end

Private Instance Methods

inline_violations_group(warnings: [], errors: [], messages: [], markdowns: []) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 534
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
merge_violations(*violation_groups) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 553
def merge_violations(*violation_groups)
  violation_groups.inject({}) do |accumulator, group|
    accumulator.merge(group) { |_, old, fresh| old + fresh }
  end
end
regular_violations_group(warnings: [], errors: [], messages: [], markdowns: []) click to toggle source
# File lib/danger/request_sources/github/github.rb, line 525
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
valid_access_token?() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 517
def valid_access_token?
  @access_token && !@access_token.empty?
end
valid_bearer_token?() click to toggle source
# File lib/danger/request_sources/github/github.rb, line 521
def valid_bearer_token?
  @bearer_token && !@bearer_token.empty?
end