# frozen_string_literal: true
require "net/http"
require "json"
require_relative "../../gitlab/dangerfiles/changes"
require_relative "../../gitlab/dangerfiles/config"
require_relative "../../gitlab/dangerfiles/teammate"
require_relative "../../gitlab/dangerfiles/title_linting"
module Danger
# Common helper functions for our danger scripts.
class Helper < Danger::Plugin
RELEASE_TOOLS_BOT = "gitlab-release-tools-bot"
CATEGORY_LABELS = {
docs: "~documentation", # Docs are reviewed along DevOps stages, so don't need roulette for now.
none: "",
qa: "~QA",
test: "~test ~Quality for `spec/features/*`",
# Deprecated as of 2.3.0 in favor of tooling
engineering_productivity: '~"Engineering Productivity" for CI, Danger',
tooling: '~"type::tooling" for CI, Danger',
ci_template: '~"ci::templates"',
product_intelligence: '~"product intelligence"',
}.freeze
# Allows to set specific rule's configuration by passing a block.
#
# @yield [c] Yield a Gitlab::Dangerfiles::Config object
#
# @yieldparam [Gitlab::Dangerfiles::Config] The Gitlab::Dangerfiles::Config object
# @yieldreturn [Gitlab::Dangerfiles::Config] The Gitlab::Dangerfiles::Config object
#
# @example
# helper.config do |config|
# config.code_size_thresholds = { high: 42, medium: 12 }
# end
#
# @return [Gitlab::Dangerfiles::Config]
def config
(@config ||= Gitlab::Dangerfiles::Config.new).tap do |c|
yield c if block_given?
end
end
# @example
# file.txt
#
# @param [String, Array] paths
# A list of strings to convert to gitlab anchors
# @param [Boolean] full_path
# Shows the full path as the link's text, defaults to +true+.
#
# @see https://danger.systems/reference.html Danger reference where #html_link is described
# @see https://github.com/danger/danger/blob/eca19719d3e585fe1cc46bc5377f9aa955ebf609/lib/danger/danger_core/plugins/dangerfile_gitlab_plugin.rb#L216 Danger reference where #html_link is implemented
#
# @return [String] a list of HTML anchors for a file, or multiple files
def html_link(paths, full_path: true)
ci? ? gitlab_helper.html_link(paths, full_path: full_path) : paths
end
# @return [Boolean] whether we're in the CI context or not.
def ci?
!gitlab_helper.nil?
end
# @return [Array] a list of filenames added in this MR.
def added_files
@added_files ||= if changes_from_api
changes_from_api.select { |file| file["new_file"] }.map { |file| file["new_path"] }
else
git.added_files.to_a
end
end
# @return [Array] a list of filenames modifier in this MR.
def modified_files
@modified_files ||= if changes_from_api
changes_from_api.select { |file| !file["new_file"] && !file["deleted_file"] && !file["renamed_file"] }.map { |file| file["new_path"] }
else
git.modified_files.to_a
end
end
# @return [Array] a list of filenames renamed in this MR.
def renamed_files
@renamed_files ||= if changes_from_api
changes_from_api.select { |file| file["renamed_file"] }.each_with_object([]) do |file, memo|
memo << { before: file["old_path"], after: file["new_path"] }
end
else
git.renamed_files.to_a
end
end
# @return [Array] a list of filenames deleted in this MR.
def deleted_files
@deleted_files ||= if changes_from_api
changes_from_api.select { |file| file["deleted_file"] }.map { |file| file["new_path"] }
else
git.deleted_files.to_a
end
end
# @example
# # Considering these changes:
# # - A new_file.rb
# # - D deleted_file.rb
# # - M modified_file.rb
# # - R renamed_file_before.rb -> renamed_file_after.rb
# # it will return:
#
# #=> ['new_file.rb', 'modified_file.rb', 'renamed_file_after.rb']
#
#
# @return [Array] a list of all files that have been added, modified or renamed.
# +modified_files+ might contain paths that already have been renamed,
# so we need to remove them from the list.
def all_changed_files
Set.new
.merge(added_files)
.merge(modified_files)
.merge(renamed_files.map { |x| x[:after] })
.subtract(renamed_files.map { |x| x[:before] })
.to_a
.sort
end
# @param filename [String] A file name for which we want the diff.
#
# @example
# # Considering changing a line in lib/gitlab/usage_data.rb, it will return:
#
# ["--- a/lib/gitlab/usage_data.rb",
# "+++ b/lib/gitlab/usage_data.rb",
# "+ # Test change",
# "- # Old change"]
#
# @return [Array] an array of changed lines in Git diff format.
def changed_lines(filename)
diff = diff_for_file(filename)
return [] unless diff
diff.split("\n").select { |line| %r{^[+-]}.match?(line) }
end
def release_automation?
gitlab_helper&.mr_author == RELEASE_TOOLS_BOT
end
# @param items [Array] An array of items to transform into a bullet list.
#
# @example
# markdown_list(%w[foo bar])
# # => * foo
# * bar
#
# @return [String] a bullet list for the given +items+. If there are more than 10 items, wrap the list in a + + block.
def markdown_list(items)
list = items.map { |item| "* `#{item}`" }.join("\n")
if items.size > 10
"\n\n\n#{list}\n\n \n"
else
list
end
end
# @param categories [{Regexp => Array}, {Array => Array}] A hash of the form +{ filename_regex => categories, [filename_regex, changes_regex] => categories }+.
# +filename_regex+ is the regex pattern to match file names. +changes_regex+ is the regex pattern to
# match changed lines in files that match +filename_regex+
#
# @return [{Symbol => Array}] a hash of the type +{ category1: ["file1", "file2"], category2: ["file3", "file4"] }+
# using filename regex (+filename_regex+) and specific change regex (+changes_regex+) from the given +categories+ hash.
def changes_by_category(categories)
all_changed_files.each_with_object(Hash.new { |h, k| h[k] = [] }) do |file, hash|
categories_for_file(file, categories).each { |category| hash[category] << file }
end
end
# @param categories [{Regexp => Array}, {Array => Array}] A hash of the form +{ filename_regex => categories, [filename_regex, changes_regex] => categories }+.
# +filename_regex+ is the regex pattern to match file names. +changes_regex+ is the regex pattern to
# match changed lines in files that match +filename_regex+
#
# @return [Gitlab::Dangerfiles::Changes] a +Gitlab::Dangerfiles::Changes+ object that represents the changes of an MR
# using filename regex (+filename_regex+) and specific change regex (+changes_regex+) from the given +categories+ hash.
def changes(categories)
Gitlab::Dangerfiles::Changes.new([]).tap do |changes|
added_files.each do |file|
categories_for_file(file, categories).each { |category| changes << Gitlab::Dangerfiles::Change.new(file, :added, category) }
end
modified_files.each do |file|
categories_for_file(file, categories).each { |category| changes << Gitlab::Dangerfiles::Change.new(file, :modified, category) }
end
deleted_files.each do |file|
categories_for_file(file, categories).each { |category| changes << Gitlab::Dangerfiles::Change.new(file, :deleted, category) }
end
renamed_files.map { |x| x[:before] }.each do |file|
categories_for_file(file, categories).each { |category| changes << Gitlab::Dangerfiles::Change.new(file, :renamed_before, category) }
end
renamed_files.map { |x| x[:after] }.each do |file|
categories_for_file(file, categories).each { |category| changes << Gitlab::Dangerfiles::Change.new(file, :renamed_after, category) }
end
end
end
# @param filename [String] A file name.
# @param categories [{Regexp => Array}, {Array => Array}] A hash of the form +{ filename_regex => categories, [filename_regex, changes_regex] => categories }+.
# +filename_regex+ is the regex pattern to match file names. +changes_regex+ is the regex pattern to
# match changed lines in files that match +filename_regex+
#
# @return [Array] the categories a file is in, e.g., +[:frontend]+, +[:backend]+, or +%i[frontend tooling]+
# using filename regex (+filename_regex+) and specific change regex (+changes_regex+) from the given +categories+ hash.
def categories_for_file(filename, categories)
_, categories = categories.find do |key, _|
filename_regex, changes_regex = Array(key)
found = filename_regex.match?(filename)
found &&= changed_lines(filename).any? { |changed_line| changes_regex.match?(changed_line) } if changes_regex
found
end
Array(categories || :unknown)
end
# @param category [Symbol] A category.
#
# @return [String] the GFM for a category label, making its best guess if it's not
# a category we know about.
def label_for_category(category)
CATEGORY_LABELS.fetch(category, "~#{category}")
end
# @return [String] +""+ when not in the CI context, and the MR IID as a string otherwise.
def mr_iid
return "" unless ci?
gitlab_helper.mr_json["iid"].to_s
end
# @return [String] +`whoami`+ when not in the CI context, and the MR author username otherwise.
def mr_author
return `whoami`.strip unless ci?
gitlab_helper.mr_author
end
# @return [String] +""+ when not in the CI context, and the MR title otherwise.
def mr_title
return "" unless ci?
gitlab_helper.mr_json["title"]
end
# @return [String] +""+ when not in the CI context, and the MR URL otherwise.
def mr_web_url
return "" unless ci?
gitlab_helper.mr_json["web_url"]
end
# @return [Array] +[]+ when not in the CI context, and the MR labels otherwise.
def mr_labels
return [] unless ci?
gitlab_helper.mr_labels
end
# @return [String] +`git rev-parse --abbrev-ref HEAD`+ when not in the CI context, and the MR source branch otherwise.
def mr_source_branch
return `git rev-parse --abbrev-ref HEAD`.strip unless ci?
gitlab_helper.mr_json["source_branch"]
end
# @return [String] +""+ when not in the CI context, and the MR target branch otherwise.
def mr_target_branch
return "" unless ci?
gitlab_helper.mr_json["target_branch"]
end
# @return [Boolean] +true+ when not in the CI context, and whether the MR is set to be squashed otherwise.
def squash_mr?
return true unless ci?
gitlab.mr_json["squash"]
end
# @return [Boolean] whether a MR is a Draft or not.
def draft_mr?
return false unless ci?
gitlab.mr_json["work_in_progress"]
end
# @return [Boolean] whether a MR is opened in the security mirror or not.
def security_mr?
mr_web_url.include?("/gitlab-org/security/")
end
# @return [Boolean] whether a MR title includes "cherry-pick" or not.
def cherry_pick_mr?
Gitlab::Dangerfiles::TitleLinting.has_cherry_pick_flag?(mr_title)
end
# @return [Boolean] whether a MR title includes "RUN ALL RSPEC" or not.
def run_all_rspec_mr?
Gitlab::Dangerfiles::TitleLinting.has_run_all_rspec_flag?(mr_title)
end
# @return [Boolean] whether a MR title includes "RUN AS-IF-FOSS" or not.
def run_as_if_foss_mr?
Gitlab::Dangerfiles::TitleLinting.has_run_as_if_foss_flag?(mr_title)
end
# @return [Boolean] whether a MR targets a stable branch or not.
def stable_branch?
/\A\d+-\d+-stable-ee/i.match?(mr_target_branch)
end
# Whether a MR has any database-scoped labels (i.e. +"database::*"+) set or not.
#
# @return [Boolean]
def has_database_scoped_labels?
mr_labels.any? { |label| label.start_with?("database::") }
end
# @return [Boolean] whether a MR has any CI-related changes (i.e. +".gitlab-ci.yml"+ or +".gitlab/ci/*"+) or not.
def has_ci_changes?
changed_files(%r{\A(\.gitlab-ci\.yml|\.gitlab/ci/)}).any?
end
# @param labels [Array] An array of labels.
#
# @return [Boolean] whether a MR has the given +labels+ set or not.
def mr_has_labels?(*labels)
labels = labels.flatten.uniq
(labels & mr_labels) == labels
end
# @param labels [Array] An array of labels.
# @param sep [String] A separator.
#
# @example
# labels_list(["foo", "bar baz"], sep: "; ")
# # => '~"foo"; ~"bar baz"'
#
# @return [String] the list of +labels+ ready for being used in a Markdown comment, separated by +sep+.
def labels_list(labels, sep: ", ")
labels.map { |label| %Q{~"#{label}"} }.join(sep)
end
# @deprecated Use {#quick_action_label} instead.
def prepare_labels_for_mr(labels)
quick_action_label(labels)
end
# @param labels [Array] An array of labels.
#
# @example
# quick_action_label(["foo", "bar baz"])
# # => '/label ~"foo" ~"bar baz"'
#
# @return [String] a quick action to set the +given+ labels. Returns +""+ if +labels+ is empty.
def quick_action_label(labels)
return "" unless labels.any?
"/label #{labels_list(labels, sep: " ")}"
end
# @param regex [Regexp] A Regexp to match against.
#
# @return [Array] changed files matching the given +regex+.
def changed_files(regex)
all_changed_files.grep(regex)
end
# @return [Array] the group labels (i.e. +"group::*"+) set on the MR.
def group_label
mr_labels.find { |label| label.start_with?("group::") }
end
private
# @return [Danger::RequestSources::GitLab, nil] the +gitlab+ helper, or +nil+ when it's not available.
def gitlab_helper
# Unfortunately the following does not work:
# - respond_to?(:gitlab)
# - respond_to?(:gitlab, true)
gitlab
rescue NoMethodError
nil
end
# @param filename [String] A filename for which we want the diff.
#
# @return [String] the raw diff as a string for the given +filename+.
def diff_for_file(filename)
if changes_from_api
changes_hash = changes_from_api.find { |file| file["new_path"] == filename }
changes_hash["diff"] if changes_hash
else
git.diff_for_file(filename)&.patch
end
end
# Fetches MR changes from the API instead of Git (default).
#
# @return [Array, nil]
def changes_from_api
return nil unless ci?
return nil if defined?(@force_changes_from_git)
@changes_from_api ||= gitlab_helper.mr_changes
rescue
# Fallback to the Git strategy in any case
@force_changes_from_git = true
nil
end
end
end