# frozen_string_literal: true require 'gitlab/dangerfiles/title_linting' module Tooling module Danger module Changelog NO_CHANGELOG_LABELS = [ 'type::tooling', 'tooling::pipelines', 'tooling::workflow', 'ci-build', 'meta' ].freeze NO_CHANGELOG_CATEGORIES = %i[docs none].freeze CHANGELOG_TRAILER_REGEX = /^(?Changelog):\s*(?.+)$/i.freeze CHANGELOG_EE_TRAILER_REGEX = /^EE: true$/.freeze CHANGELOG_MODIFIED_URL_TEXT = "**CHANGELOG.md was edited.** Please remove the additions and follow the [changelog guidelines](https://docs.gitlab.com/ee/development/changelog.html).\n\n" CHANGELOG_MISSING_URL_TEXT = "**[CHANGELOG missing](https://docs.gitlab.com/ee/development/changelog.html)**:\n\n" OPTIONAL_CHANGELOG_MESSAGE = { local: "If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.", ci: <<~MSG If you want to create a changelog entry for GitLab FOSS, add the `Changelog` trailer to the commit message you want to add to the changelog. If you want to create a changelog entry for GitLab EE, also [add the `EE: true` trailer](https://docs.gitlab.com/ee/development/changelog.html#gitlab-enterprise-changes) to your commit message. If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message. MSG }.freeze SEE_DOC = "See the [changelog documentation](https://docs.gitlab.com/ee/development/changelog.html)." REQUIRED_CHANGELOG_REASONS = { db_changes: 'introduces a database migration', feature_flag_removed: 'removes a feature flag' }.freeze REQUIRED_CHANGELOG_MESSAGE = { local: "This merge request requires a changelog entry because it [%s](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry).", ci: <<~MSG To create a changelog entry, add the `Changelog` trailer to one of your Git commit messages. This merge request requires a changelog entry because it [%s](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry). MSG }.freeze CATEGORIES = YAML .load_file(File.expand_path('../../.gitlab/changelog_config.yml', __dir__)) .fetch('categories') .keys .freeze class ChangelogCheckResult attr_reader :errors, :warnings, :markdowns, :messages def initialize(errors: [], warnings: [], markdowns: [], messages: []) @errors = errors @warnings = warnings @markdowns = markdowns @messages = messages end private_class_method :new def self.empty new end def self.error(error) new(errors: [error]) end def self.warning(warning) new(warnings: [warning]) end def error(error) errors << error end def warning(warning) warnings << warning end def markdown(markdown) markdowns << markdown end def message(message) messages << message end end # rubocop:disable Style/SignalException def check! if git.modified_files.include?("CHANGELOG.md") fail modified_text end if present? add_danger_messages(check_changelog_path) elsif required? required_texts.each { |_, text| fail(text) } # rubocop:disable Lint/UnreachableLoop elsif optional? message optional_text end check_changelog_commit_categories end # rubocop:enable Style/SignalException # rubocop:disable Style/SignalException def add_danger_messages(check_result) check_result.errors.each { |error| fail(error) } # rubocop:disable Lint/UnreachableLoop check_result.warnings.each { |warning| warn(warning) } check_result.markdowns.each { |markdown_hash| markdown(**markdown_hash) } check_result.messages.each { |text| message(text) } end # rubocop:enable Style/SignalException def check_changelog_commit_categories changelog_commits.each do |commit| add_danger_messages(check_changelog_trailer(commit)) end end def check_changelog_trailer(commit) trailer = commit.message.match(CHANGELOG_TRAILER_REGEX) name = trailer[:name] category = trailer[:category] unless name == 'Changelog' return ChangelogCheckResult.error("The changelog trailer for commit #{commit.sha} must be `Changelog` (starting with a capital C), not `#{name}`") end return ChangelogCheckResult.empty if CATEGORIES.include?(category) ChangelogCheckResult.error("Commit #{commit.sha} uses an invalid changelog category: #{category}") end def check_changelog_path check_result = ChangelogCheckResult.empty return check_result unless present? ee_changes = project_helper.all_ee_changes.dup if ee_changes.any? && !ee_changelog? && !required? check_result.warning("This MR changes code in `ee/`, but its Changelog commit is missing the [`EE: true` trailer](https://docs.gitlab.com/ee/development/changelog.html#gitlab-enterprise-changes). Consider adding it to your Changelog commits.") end if ee_changes.empty? && ee_changelog? check_result.warning("This MR has a Changelog commit for EE, but no code changes in `ee/`. Consider removing the `EE: true` trailer from your commits.") end if ee_changes.any? && ee_changelog? && required_reasons.include?(:db_changes) check_result.warning("This MR has a Changelog commit with the `EE: true` trailer, but there are database changes which [requires](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry) the Changelog commit to not have the `EE: true` trailer. Consider removing the `EE: true` trailer from your commits.") end check_result end def required_reasons [].tap do |reasons| reasons << :db_changes if helper.changes.added.has_category?(:migration) reasons << :feature_flag_removed if helper.changes.deleted.has_category?(:feature_flag) end end def required? required_reasons.any? end def optional? categories_need_changelog? && mr_without_no_changelog_label? end def present? valid_changelog_commits.any? end def changelog_commits git.commits.select do |commit| commit.message.match?(CHANGELOG_TRAILER_REGEX) end end def valid_changelog_commits changelog_commits.select do |commit| trailer = commit.message.match(CHANGELOG_TRAILER_REGEX) CATEGORIES.include?(trailer[:category]) end end def ee_changelog? changelog_commits.any? do |commit| commit.message.match?(CHANGELOG_EE_TRAILER_REGEX) end end def modified_text CHANGELOG_MODIFIED_URL_TEXT + (helper.ci? ? format(OPTIONAL_CHANGELOG_MESSAGE[:ci]) : OPTIONAL_CHANGELOG_MESSAGE[:local]) end def required_texts required_reasons.each_with_object({}) do |required_reason, memo| memo[required_reason] = CHANGELOG_MISSING_URL_TEXT + (helper.ci? ? format(REQUIRED_CHANGELOG_MESSAGE[:ci], reason: REQUIRED_CHANGELOG_REASONS.fetch(required_reason)) : REQUIRED_CHANGELOG_MESSAGE[:local]) end end def optional_text CHANGELOG_MISSING_URL_TEXT + (helper.ci? ? format(OPTIONAL_CHANGELOG_MESSAGE[:ci]) : OPTIONAL_CHANGELOG_MESSAGE[:local]) end private def read_file(path) File.read(path) end def categories_need_changelog? (helper.changes.categories - NO_CHANGELOG_CATEGORIES).any? end def mr_without_no_changelog_label? (helper.mr_labels & NO_CHANGELOG_LABELS).empty? end end end end