Repository URL to install this package:
|
Version:
8.4.5.pre.alpha.20 ▾
|
# coding: utf-8
require "uri"
require "danger/helpers/comments_helper"
require "danger/helpers/comment"
require "danger/request_sources/support/get_ignored_violation"
module Danger
module RequestSources
class GitLab < RequestSource
include Danger::Helpers::CommentsHelper
attr_accessor :mr_json, :commits_json, :dismiss_out_of_range_messages, :endpoint, :host
FIRST_GITLAB_GEM_WITH_VERSION_CHECK = Gem::Version.new("4.6.0")
FIRST_VERSION_WITH_INLINE_COMMENTS = Gem::Version.new("10.8.0")
def self.env_vars
["DANGER_GITLAB_API_TOKEN"]
end
def self.optional_env_vars
["DANGER_GITLAB_HOST", "DANGER_GITLAB_API_BASE_URL"]
end
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
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
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
def validates_as_api_source?
@token && !@token.empty?
end
def scm
@scm ||= GitRepo.new
end
def base_commit
@base_commit ||= self.mr_json.diff_refs.base_sha
end
def mr_comments
# @raw_comments contains what we got back from the server.
# @comments contains Comment objects (that have less information)
@comments ||= begin
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
end
def mr_discussions
@mr_discussions ||= client.merge_request_discussions(ci_source.repo_slug, ci_source.pull_request_id)
end
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
def mr_changed_paths
@mr_changed_paths ||= begin
mr_changes
.changes.map { |change| change["new_path"] }
end
@mr_changed_paths
end
def mr_changes
@mr_changes ||= begin
client.merge_request_changes(ci_source.repo_slug, ci_source.pull_request_id)
end
end
def mr_closes_issues
@mr_closes_issues ||= begin
client.merge_request_closes_issues(ci_source.repo_slug, ci_source.pull_request_id)
end
end
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
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
def ignored_violations_from_pr
GetIgnoredViolation.new(self.mr_json.description).call
end
def supports_inline_comments
@supports_inline_comments ||= begin
# If we can't check GitLab's version, we assume we don't support 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
end
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
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
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
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.is_a?(Hash) && raw_comment["position"].nil?
begin
client.delete_merge_request_comment(
ci_source.repo_slug,
ci_source.pull_request_id,
comment.id
)
rescue
end
end
end
def markdown_link_to_message(message, _)
"#{message.file}#L#{message.line}: "
end
# @return [String] The organisation name, is nil if it can't be detected
def organisation
nil # TODO: Implement this
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)
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=#{branch}&private_token=#{@token}"
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
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
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
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
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
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=line_number-1
end
line_number=line_number+1
end
addition_lines
end
end
end
end