class Danger::RequestSources::GitLab

Constants

FIRST_GITLAB_GEM_WITH_VERSION_CHECK
FIRST_VERSION_WITH_INLINE_COMMENTS

Attributes

commits_json[RW]
dismiss_out_of_range_messages[RW]
endpoint[RW]
host[RW]
mr_json[RW]

Public Class Methods

env_vars() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 15
def self.env_vars
  ["DANGER_GITLAB_API_TOKEN"]
end
new(ci_source, environment) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 23
def initialize(ci_source, environment)
  self.ci_source = ci_source
  self.dismiss_out_of_range_messages = false
  @endpoint = environment["DANGER_GITLAB_API_BASE_URL"] || environment.fetch("CI_API_V4_URL", "https://gitlab.com/api/v4")
  @host = environment.fetch("DANGER_GITLAB_HOST", URI.parse(endpoint).host) || "gitlab.com"
  @token = environment["DANGER_GITLAB_API_TOKEN"]
end
optional_env_vars() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 19
def self.optional_env_vars
  ["DANGER_GITLAB_HOST", "DANGER_GITLAB_API_BASE_URL"]
end

Public Instance Methods

base_commit() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 67
def base_commit
  @base_commit ||= self.mr_json.diff_refs.base_sha
end
client() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 31
def client
  raise "No API token given, please provide one using `DANGER_GITLAB_API_TOKEN`" unless @token

  # The require happens inline so that it won't cause exceptions when just using the `danger` gem.
  require "gitlab"

  @client ||= Gitlab.client(endpoint: endpoint, private_token: @token)
rescue LoadError => e
  if e.path == "gitlab"
    puts "The GitLab gem was not installed, you will need to change your Gem from `danger` to `danger-gitlab`.".red
    puts "\n - See https://github.com/danger/danger/blob/master/CHANGELOG.md#400"
  else
    puts "Error: #{e}".red
  end
  abort
end
delete_old_comments!(except: nil, danger_id: "danger") click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 268
def delete_old_comments!(except: nil, danger_id: "danger")
  @raw_comments.each do |raw_comment|
    comment = Comment.from_gitlab(raw_comment)
    next unless comment.generated_by_danger?(danger_id)
    next if comment.id == except
    next unless raw_comment.kind_of?(Hash) && raw_comment["position"].nil?

    begin
      client.delete_merge_request_comment(
        ci_source.repo_slug,
        ci_source.pull_request_id,
        comment.id
      )
    rescue StandardError
    end
  end
end
dismiss_out_of_range_messages_for(kind) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 295
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/gitlab.rb, line 138
def fetch_details
  self.mr_json = client.merge_request(ci_source.repo_slug, self.ci_source.pull_request_id)
  self.ignored_violations = ignored_violations_from_pr
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/gitlab.rb, line 306
def file_url(organisation: nil, repository: nil, ref: nil, branch: nil, path: nil)
  ref ||= branch || "master"
  # According to GitLab Repositories API docs path and id(slug) should be encoded.
  path = URI.encode_www_form_component(path)
  repository = URI.encode_www_form_component(repository)
  "#{endpoint}/projects/#{repository}/repository/files/#{path}/raw?ref=#{ref}&private_token=#{@token}"
end
find_old_position_in_diff(changes, message) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 471
def find_old_position_in_diff(changes, message)
  range_header_regexp = /@@ -(?<old>[0-9]+)(,([0-9]+))? \+(?<new>[0-9]+)(,([0-9]+))? @@.*/

  change = changes.find { |c| c["new_path"] == message.file }
  # If there is no changes or rename only or deleted, return nil.
  return nil if change.nil? || change["diff"].empty? || change["deleted_file"]

  modified_position = {
    path: change["old_path"],
    line: nil
  }

  # If the file is new one, old line number must be nil.
  return modified_position if change["new_file"]

  current_old_line = 0
  current_new_line = 0

  change["diff"].each_line do |line|
    match = line.match range_header_regexp

    if match
      # If the message line is at before next diffs, break from loop.
      break if message.line.to_i < match[:new].to_i

      # The match [:old] line does not appear yet at the header position, so reduce line number.
      current_old_line = match[:old].to_i - 1
      current_new_line = match[:new].to_i - 1
      next
    end

    if line.start_with?("-")
      current_old_line += 1
    elsif line.start_with?("+")
      current_new_line += 1
      # If the message line starts with '+', old line number must be nil.
      return modified_position if current_new_line == message.line.to_i
    elsif !line.eql?("\\ No newline at end of file\n")
      current_old_line += 1
      current_new_line += 1
      # If the message line doesn't start with '+', old line number must be specified.
      break if current_new_line == message.line.to_i
    end
  end

  {
    path: change["old_path"],
    line: current_old_line - current_new_line + message.line.to_i
  }
end
generate_addition_lines(diff) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 536
def generate_addition_lines(diff)
  range_header_regexp = /@@ -(?<old>[0-9]+)(,([0-9]+))? \+(?<new>[0-9]+)(,([0-9]+))? @@.*/
  addition_lines = []
  line_number = 0
  diff.each_line do |line|
    if line.match range_header_regexp
      line = line.split("+").last
      line = line.split(" ").first
      range_string = line.split(",")
      line_number = range_string[0].to_i - 1
    elsif line.start_with?("+")
      addition_lines.push(line_number)
    elsif line.start_with?("-")
      line_number -= 1
    end
    line_number += 1
  end
  addition_lines
end
ignored_violations_from_pr() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 143
def ignored_violations_from_pr
  GetIgnoredViolation.new(self.mr_json.description).call
end
inline_violations_group(warnings: [], errors: [], messages: [], markdowns: []) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 323
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
is_out_of_range(changes, message) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 522
def is_out_of_range(changes, message)
  change = changes.find { |c| c["new_path"] == message.file }
  # If there is no changes or rename only or deleted, return out of range.
  return true if change.nil? || change["diff"].empty? || change["deleted_file"]

  # If new file then return in range
  return false if change["new_file"]

  addition_lines = generate_addition_lines(change["diff"])
  return false if addition_lines.include?(message.line.to_i)

  return true
end
merge_violations(*violation_groups) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 342
def merge_violations(*violation_groups)
  violation_groups.inject({}) do |accumulator, group|
    accumulator.merge(group) { |_, old, fresh| old + fresh }
  end
end
mr_changed_paths() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 106
def mr_changed_paths
  @mr_changed_paths ||= mr_changes
    .changes.map { |change| change["new_path"] }

  @mr_changed_paths
end
mr_changes() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 113
def mr_changes
  @mr_changes ||= client.merge_request_changes(ci_source.repo_slug, ci_source.pull_request_id)
end
mr_closes_issues() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 117
def mr_closes_issues
  @mr_closes_issues ||= client.merge_request_closes_issues(ci_source.repo_slug, ci_source.pull_request_id)
end
mr_comments() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 71
def mr_comments
  # @raw_comments contains what we got back from the server.
  # @comments contains Comment objects (that have less information)
  @comments ||= if supports_inline_comments
                  @raw_comments = mr_discussions
                    .auto_paginate
                    .flat_map { |discussion| discussion.notes.map { |note| note.to_h.merge({ "discussion_id" => discussion.id }) } }
                  @raw_comments
                    .map { |comment| Comment.from_gitlab(comment) }
                else
                  @raw_comments = client.merge_request_comments(ci_source.repo_slug, ci_source.pull_request_id, per_page: 100)
                    .auto_paginate
                  @raw_comments
                    .map { |comment| Comment.from_gitlab(comment) }
                end
end
mr_diff() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 92
def mr_diff
  @mr_diff ||= begin
    diffs = mr_changes.changes.map do |change|
      diff = change["diff"]
      if diff.start_with?("--- a/")
        diff
      else
        "--- a/#{change['old_path']}\n+++ b/#{change['new_path']}\n#{diff}"
      end
    end
    diffs.join("\n")
  end
end
mr_discussions() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 88
def mr_discussions
  @mr_discussions ||= client.merge_request_discussions(ci_source.repo_slug, ci_source.pull_request_id)
end
organisation() click to toggle source

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

# File lib/danger/request_sources/gitlab.rb, line 291
def organisation
  nil # TODO: Implement this
end
regular_violations_group(warnings: [], errors: [], messages: [], markdowns: []) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 314
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
scm() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 63
def scm
  @scm ||= GitRepo.new
end
setup_danger_branches() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 121
def setup_danger_branches
  # we can use a GitLab specific feature here:
  base_branch = self.mr_json.source_branch
  base_commit = self.mr_json.diff_refs.base_sha
  head_branch = self.mr_json.target_branch
  head_commit = self.mr_json.diff_refs.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/gitlab.rb, line 348
def submit_inline_comments!(warnings: [], errors: [], messages: [], markdowns: [], previous_violations: [], danger_id: "danger")
  comments = mr_discussions
    .auto_paginate
    .flat_map { |discussion| discussion.notes.map { |note| note.to_h.merge({ "discussion_id" => discussion.id }) } }
    .select { |comment| Comment.from_gitlab(comment).inline? }

  danger_comments = comments.select { |comment| Comment.from_gitlab(comment).generated_by_danger?(danger_id) }
  non_danger_comments = comments - danger_comments

  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
  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: "gitlab")
      client.update_merge_request_discussion_note(ci_source.repo_slug, ci_source.pull_request_id, comment["discussion_id"], comment["id"], body: 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_merge_request_comment(ci_source.repo_slug, ci_source.pull_request_id, comment["id"]) if replies.empty?
    end
  end

  {
    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/gitlab.rb, line 393
def submit_inline_comments_for_kind!(kind, messages, _diff_lines, danger_comments, previous_violations, danger_id: "danger")
  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
    # Reject if it's out of range and in dismiss mode
    next true if dismiss_out_of_range_messages_for(kind) && is_out_of_range(mr_changes.changes, m)

    # 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: "gitlab")
    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: "gitlab")
      # 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|
      position = comment_data["position"]

      if position.nil?
        false
      else
        position["new_path"] == m.file && position["new_line"] == m.line
      end
    end

    if matching_comments.empty?
      old_position = find_old_position_in_diff mr_changes.changes, m
      next false if old_position.nil?

      params = {
        body: body,
        position: {
          position_type: "text",
          new_path: m.file,
          new_line: m.line,
          old_path: old_position[:path],
          old_line: old_position[:line],
          base_sha: self.mr_json.diff_refs.base_sha,
          start_sha: self.mr_json.diff_refs.start_sha,
          head_sha: self.mr_json.diff_refs.head_sha
        }
      }
      begin
        client.create_merge_request_discussion(ci_source.repo_slug, ci_source.pull_request_id, params)
      rescue Gitlab::Error::Error => e
        message = [e, "body: #{body}", "position: #{params[:position].inspect}"].join("\n")
        puts message

        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
      begin
        client.update_merge_request_discussion_note(ci_source.repo_slug, ci_source.pull_request_id, comment["discussion_id"], comment["id"], body: body)
      rescue Gitlab::Error::Error => e
        message = [e, "body: #{body}"].join("\n")
        puts message

        next false
      end
    end

    # Remove this element from the array
    next true
  end
end
supports_inline_comments() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 147
def supports_inline_comments
  # If we can't check GitLab's version, we assume we don't support inline comments
  @supports_inline_comments ||= if Gem.loaded_specs["gitlab"].version < FIRST_GITLAB_GEM_WITH_VERSION_CHECK
                                  false
                                else
                                  current_version = Gem::Version.new(client.version.version)

                                  current_version >= FIRST_VERSION_WITH_INLINE_COMMENTS
                                end
end
update_pull_request!(warnings: [], errors: [], messages: [], markdowns: [], danger_id: "danger", new_comment: false, remove_previous_comments: false) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 158
def update_pull_request!(warnings: [], errors: [], messages: [], markdowns: [], danger_id: "danger", new_comment: false, remove_previous_comments: false)
  if supports_inline_comments
    update_pull_request_with_inline_comments!(warnings: warnings, errors: errors, messages: messages, markdowns: markdowns, danger_id: danger_id, new_comment: new_comment, remove_previous_comments: remove_previous_comments)
  else
    update_pull_request_without_inline_comments!(warnings: warnings, errors: errors, messages: messages, markdowns: markdowns, danger_id: danger_id, new_comment: new_comment, remove_previous_comments: remove_previous_comments)
  end
end
update_pull_request_with_inline_comments!(warnings: [], errors: [], messages: [], markdowns: [], danger_id: "danger", new_comment: false, remove_previous_comments: false) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 166
def update_pull_request_with_inline_comments!(warnings: [], errors: [], messages: [], markdowns: [], danger_id: "danger", new_comment: false, remove_previous_comments: false)
  editable_regular_comments = mr_comments
    .select { |comment| comment.generated_by_danger?(danger_id) }
    .reject(&:inline?)

  last_comment = editable_regular_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: "gitlab",
      danger_id: danger_id,
      previous_violations: previous_violations
    }.merge(main_violations))

    comment_result =
      if should_create_new_comment
        client.create_merge_request_note(ci_source.repo_slug, ci_source.pull_request_id, body)
      else
        client.edit_merge_request_note(ci_source.repo_slug, ci_source.pull_request_id, last_comment.id, body)
      end
  end
end
update_pull_request_without_inline_comments!(warnings: [], errors: [], messages: [], markdowns: [], danger_id: "danger", new_comment: false, remove_previous_comments: false) click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 228
def update_pull_request_without_inline_comments!(warnings: [], errors: [], messages: [], markdowns: [], danger_id: "danger", new_comment: false, remove_previous_comments: false)
  editable_comments = mr_comments.select { |comment| comment.generated_by_danger?(danger_id) }

  should_create_new_comment = new_comment || editable_comments.empty? || remove_previous_comments

  if should_create_new_comment
    previous_violations = {}
  else
    comment = editable_comments.first.body
    previous_violations = parse_comment(comment)
  end

  if (previous_violations.empty? && (warnings + errors + messages + markdowns).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)
  else
    body = generate_comment(warnings: warnings,
                            errors: errors,
                            messages: messages,
                            markdowns: markdowns,
                            previous_violations: previous_violations,
                            danger_id: danger_id,
                            template: "gitlab")

    if editable_comments.empty? or should_create_new_comment
      client.create_merge_request_comment(
        ci_source.repo_slug, ci_source.pull_request_id, body
      )
    else
      original_id = editable_comments.first.id
      client.edit_merge_request_comment(
        ci_source.repo_slug,
        ci_source.pull_request_id,
        original_id,
        { body: body }
      )
    end
  end
end
validates_as_api_source?() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 59
def validates_as_api_source?
  @token && !@token.empty?
end
validates_as_ci?() click to toggle source
# File lib/danger/request_sources/gitlab.rb, line 48
def validates_as_ci?
  includes_port = host.include? ":"
  raise "Port number included in `DANGER_GITLAB_HOST`, this will fail with GitLab CI Runners" if includes_port

  # We don't call super because in some cases the Git remote doesn't match the GitLab instance host.
  # In Danger::EnvironmentManager#initialize we still check that the request source is #validates_as_api_source?
  # so that should be sufficient to validate GitLab as request source.
  # See https://github.com/danger/danger/issues/1231 and https://gitlab.com/gitlab-com/gl-infra/infrastructure/-/issues/10069.
  true
end