From e9cc000a945ca4fbf31cc00ee55017c900cf3228 Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Tue, 15 Dec 2020 16:33:09 +0100 Subject: [PATCH] Add code for generating Markdown changelogs This adds the basic building blocks for generating changelogs from a list of commits. The output is Markdown, and each release section is generated using a minimal, custom template language. In an early iteration we used Liquid, but this was found to suffer from unresolved security issues. Alternative template languages had their own share of issues that made using them not an option. The template engine introduced in this commit is the bare minimum that we need for changelog generation, and works by compiling the user provided template to an ERB template; making sure the user can't run arbitrary code. Using ERB allows us to offload the heavy lifting of a template engine to ERB, instead of having to write and maintain such an engine ourselves. The use of a template engine gives users control over how they want to present their changelog data, without GitLab having to provide potentially dozens of settings to enable this. This builds on the changes introduced in merge requests https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49243 and https://gitlab.com/gitlab-org/gitaly/-/merge_requests/2842. See https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1369 for more information. --- app/models/repository.rb | 4 + lib/gitlab/changelog/committer.rb | 65 +++++++ lib/gitlab/changelog/config.rb | 74 ++++++++ lib/gitlab/changelog/generator.rb | 59 +++++++ lib/gitlab/changelog/release.rb | 94 ++++++++++ lib/gitlab/changelog/template.tpl | 14 ++ lib/gitlab/changelog/template/compiler.rb | 146 ++++++++++++++++ lib/gitlab/changelog/template/context.rb | 70 ++++++++ lib/gitlab/changelog/template/template.rb | 29 ++++ spec/lib/gitlab/changelog/committer_spec.rb | 90 ++++++++++ spec/lib/gitlab/changelog/config_spec.rb | 96 ++++++++++ spec/lib/gitlab/changelog/generator_spec.rb | 164 ++++++++++++++++++ spec/lib/gitlab/changelog/release_spec.rb | 107 ++++++++++++ .../changelog/template/compiler_spec.rb | 129 ++++++++++++++ 14 files changed, 1141 insertions(+) create mode 100644 lib/gitlab/changelog/committer.rb create mode 100644 lib/gitlab/changelog/config.rb create mode 100644 lib/gitlab/changelog/generator.rb create mode 100644 lib/gitlab/changelog/release.rb create mode 100644 lib/gitlab/changelog/template.tpl create mode 100644 lib/gitlab/changelog/template/compiler.rb create mode 100644 lib/gitlab/changelog/template/context.rb create mode 100644 lib/gitlab/changelog/template/template.rb create mode 100644 spec/lib/gitlab/changelog/committer_spec.rb create mode 100644 spec/lib/gitlab/changelog/config_spec.rb create mode 100644 spec/lib/gitlab/changelog/generator_spec.rb create mode 100644 spec/lib/gitlab/changelog/release_spec.rb create mode 100644 spec/lib/gitlab/changelog/template/compiler_spec.rb diff --git a/app/models/repository.rb b/app/models/repository.rb index c646f567b94964..ef204170732c90 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1059,6 +1059,10 @@ def lfsconfig_for(sha) blob_data_at(sha, '.lfsconfig') end + def changelog_config(ref = 'HEAD') + blob_data_at(ref, Gitlab::Changelog::Config::FILE_PATH) + end + def fetch_ref(source_repository, source_ref:, target_ref:) raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref) end diff --git a/lib/gitlab/changelog/committer.rb b/lib/gitlab/changelog/committer.rb new file mode 100644 index 00000000000000..d2563590bed7b4 --- /dev/null +++ b/lib/gitlab/changelog/committer.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Gitlab + module Changelog + # A class used for committing a release's changelog to a Git repository. + class Committer + CommitError = Class.new(StandardError) + + def initialize(project, user) + @project = project + @user = user + end + + # Commits a release's changelog to a file on a branch. + # + # The `release` argument is a `Gitlab::Changelog::Release` for which to + # update the changelog. + # + # The `file` argument specifies the path to commit the changes to. + # + # The `branch` argument specifies the branch to commit the changes on. + # + # The `message` argument specifies the commit message to use. + def commit(release:, file:, branch:, message:) + # When retrying, we need to reprocess the existing changelog from + # scratch, otherwise we may end up throwing away changes. As such, all + # the logic is contained within the retry block. + Retriable.retriable(on: CommitError) do + commit = @project.commit(branch) + content = blob_content(file, commit) + + # If the release has already been added (e.g. concurrently by another + # API call), we don't want to add it again. + break if content&.match?(release.header_start_pattern) + + service = Files::MultiService.new( + @project, + @user, + commit_message: message, + branch_name: branch, + start_branch: branch, + actions: [ + { + action: content ? 'update' : 'create', + content: Generator.new(content.to_s).add(release), + file_path: file, + last_commit_id: commit&.sha + } + ] + ) + + result = service.execute + + raise CommitError.new(result[:message]) if result[:status] != :success + end + end + + def blob_content(file, commit = nil) + return unless commit + + @project.repository.blob_at(commit.sha, file)&.data + end + end + end +end diff --git a/lib/gitlab/changelog/config.rb b/lib/gitlab/changelog/config.rb new file mode 100644 index 00000000000000..ac62572576e2ba --- /dev/null +++ b/lib/gitlab/changelog/config.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Gitlab + module Changelog + # Configuration settings used when generating changelogs. + class Config + ConfigError = Class.new(StandardError) + + # When rendering changelog entries, authors are not included. + AUTHORS_NONE = 'none' + + # The path to the configuration file as stored in the project's Git + # repository. + FILE_PATH = '.gitlab/changelog_config.yml' + + # The default date format to use for formatting release dates. + DEFAULT_DATE_FORMAT = '%Y-%m-%d' + + # The default template to use for generating release sections. + DEFAULT_TEMPLATE = File.read(File.join(__dir__, 'template.tpl')) + + attr_accessor :date_format, :categories, :template + + def self.from_git(project) + if (yaml = project.repository.changelog_config) + from_hash(project, YAML.safe_load(yaml)) + else + new(project) + end + end + + def self.from_hash(project, hash) + config = new(project) + + if (date = hash['date_format']) + config.date_format = date + end + + if (template = hash['template']) + config.template = Template::Compiler.new.compile(template) + end + + if (categories = hash['categories']) + if categories.is_a?(Hash) + config.categories = categories + else + raise ConfigError, 'The "categories" configuration key must be a Hash' + end + end + + config + end + + def initialize(project) + @project = project + @date_format = DEFAULT_DATE_FORMAT + @template = Template::Compiler.new.compile(DEFAULT_TEMPLATE) + @categories = {} + end + + def contributor?(user) + @project.team.contributor?(user) + end + + def category(name) + @categories[name] || name + end + + def format_date(date) + date.strftime(@date_format) + end + end + end +end diff --git a/lib/gitlab/changelog/generator.rb b/lib/gitlab/changelog/generator.rb new file mode 100644 index 00000000000000..a80ca0728f9859 --- /dev/null +++ b/lib/gitlab/changelog/generator.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Gitlab + module Changelog + # Parsing and generating of Markdown changelogs. + class Generator + # The regex used to parse a release header. + RELEASE_REGEX = + /^##\s+(?#{Gitlab::Regex.unbounded_semver_regex})/.freeze + + # The `input` argument must be a `String` containing the existing + # changelog Markdown. If no changelog exists, this should be an empty + # `String`. + def initialize(input = '') + @lines = input.lines + @locations = {} + + @lines.each_with_index do |line, index| + matches = line.match(RELEASE_REGEX) + + next if !matches || !matches[:version] + + @locations[matches[:version]] = index + end + end + + # Generates the Markdown for the given release and returns the new + # changelog Markdown content. + # + # The `release` argument must be an instance of + # `Gitlab::Changelog::Release`. + def add(release) + versions = [release.version, *@locations.keys] + + VersionSorter.rsort!(versions) + + new_index = versions.index(release.version) + new_lines = @lines.dup + markdown = release.to_markdown + + if (insert_after = versions[new_index + 1]) + line_index = @locations[insert_after] + + new_lines.insert(line_index, markdown) + else + # When adding to the end of the changelog, the previous section only + # has a single newline, resulting in the release section title + # following it immediately. When this is the case, we insert an extra + # empty line to keep the changelog readable in its raw form. + new_lines.push("\n") if versions.length > 1 + new_lines.push(markdown.rstrip) + new_lines.push("\n") + end + + new_lines.join + end + end + end +end diff --git a/lib/gitlab/changelog/release.rb b/lib/gitlab/changelog/release.rb new file mode 100644 index 00000000000000..4c78eb5080c7ce --- /dev/null +++ b/lib/gitlab/changelog/release.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Gitlab + module Changelog + # A release to add to a changelog. + class Release + attr_reader :version + + def initialize(version:, date:, config:) + @version = version + @date = date + @config = config + @entries = Hash.new { |h, k| h[k] = [] } + + # This ensures that entries are presented in the same order as the + # categories Hash in the user's configuration. + @config.categories.values.each do |category| + @entries[category] = [] + end + end + + def add_entry( + title:, + commit:, + category:, + author: nil, + merge_request: nil + ) + # When changing these fields, keep in mind that this needs to be + # backwards compatible. For example, you can't just remove a field as + # this will break the changelog generation process for existing users. + entry = { + 'title' => title, + 'commit' => { + 'reference' => commit.to_reference(full: true), + 'trailers' => commit.trailers + } + } + + if author + entry['author'] = { + 'reference' => author.to_reference(full: true), + 'contributor' => @config.contributor?(author) + } + end + + if merge_request + entry['merge_request'] = { + 'reference' => merge_request.to_reference(full: true) + } + end + + @entries[@config.category(category)] << entry + end + + def to_markdown + # While not critical, we would like release sections to be separated by + # an empty line in the changelog; ensuring it's readable even in its + # raw form. + # + # Since it can be a bit tricky to get this right using Liquid, we + # enforce an empty line separator ourselves. + markdown = + @config.template.render('categories' => entries_for_template).strip + + # The release header can't be changed using the Liquid template, as we + # need this to be in a known format. Without this restriction, we won't + # know where to insert a new release section in an existing changelog. + "## #{@version} (#{release_date})\n\n#{markdown}\n\n" + end + + def header_start_pattern + /^##\s*#{Regexp.escape(@version)}/ + end + + private + + def release_date + @config.format_date(@date) + end + + def entries_for_template + @entries.map do |category, entries| + { + 'title' => category, + 'count' => entries.length, + 'single_change' => entries.length == 1, + 'entries' => entries + } + end + end + end + end +end diff --git a/lib/gitlab/changelog/template.tpl b/lib/gitlab/changelog/template.tpl new file mode 100644 index 00000000000000..838b7080f68411 --- /dev/null +++ b/lib/gitlab/changelog/template.tpl @@ -0,0 +1,14 @@ +{% if categories %} +{% each categories %} +### {{ title }} ({% if single_change %}1 change{% else %}{{ count }} changes{% end %}) + +{% each entries %} +- [{{ title }}]({{ commit.reference }})\ +{% if author.contributor %} by {{ author.reference }}{% end %}\ +{% if merge_request %} ([merge request]({{ merge_request.reference }})){% end %} +{% end %} + +{% end %} +{% else %} +No changes. +{% end %} diff --git a/lib/gitlab/changelog/template/compiler.rb b/lib/gitlab/changelog/template/compiler.rb new file mode 100644 index 00000000000000..f67bab0f29fa0b --- /dev/null +++ b/lib/gitlab/changelog/template/compiler.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module Gitlab + module Changelog + module Template + # Compiler is used for turning a minimal user templating language into an + # ERB template, without giving the user access to run arbitrary code. + # + # The template syntax is deliberately made as minimal as possible, and + # only supports the following: + # + # * Printing a value + # * Iterating over collections + # * if/else + # + # The syntax looks as follows: + # + # {% each users %} + # + # Name: {{user}} + # Likes cats: {% if likes_cats %}yes{% else %}no{% end %} + # + # {% end %} + # + # Newlines can be escaped by ending a line with a backslash. So this: + # + # foo \ + # bar + # + # Is the same as this: + # + # foo bar + # + # Templates are compiled into ERB templates, while taking care to make + # sure the user can't run arbitrary code. By using ERB we can let it do + # the heavy lifting of rendering data; all we need to provide is a + # translation layer. + # + # # Security + # + # The template syntax this compiler exposes is safe to be used by + # untrusted users. Not only are they unable to run arbitrary code, the + # compiler also enforces a limit on the integer sizes and the number of + # nested loops. ERB tags added by the user are also disabled. + class Compiler + # A pattern to match a single integer, with an upper size limit. + # + # We enforce a limit of 10 digits (= a 32 bits integer) so users can't + # trigger the allocation of infinitely large bignums, or trigger + # RangeError errors when using such integers to access an array value. + INTEGER = /^\d{1,10}$/.freeze + + # The name/path of a variable, such as `user.address.city`. + # + # It's important that this regular expression _doesn't_ allow for + # anything but letters, numbers, and underscores, otherwise a user may + # use those to "escape" our template and run arbirtary Ruby code. For + # example, take this variable: + # + # {{') ::Kernel.exit #'}} + # + # This would then be compiled into: + # + # <%= read(variables, '') ::Kernel.exit #'') %> + # + # Restricting the allowed characters makes this impossible. + VAR_NAME = /([\w\.]+)/.freeze + + # A variable tag, such as `{{username}}`. + VAR = /{{ \s* #{VAR_NAME} \s* }}/x.freeze + + # The opening tag for a statement. + STM_START = /{% \s*/x.freeze + + # The closing tag for a statement. + STM_END = /\s* %}/x.freeze + + # A regular `end` closing tag. + NORMAL_END = /#{STM_START} end #{STM_END}/x.freeze + + # An `end` closing tag on its own line, without any non-whitespace + # preceding or following it. + # + # These tags need some special care to make it easier to control + # whitespace. + LONELY_END = /^\s*#{NORMAL_END}\s$/x.freeze + + # An `else` tag. + ELSE = /#{STM_START} else #{STM_END}/x.freeze + + # The start of an `each` tag. + EACH = /#{STM_START} each \s+ #{VAR_NAME} #{STM_END}/x.freeze + + # The start of an `if` tag. + IF = /#{STM_START} if \s+ #{VAR_NAME} #{STM_END}/x.freeze + + # The pattern to use for escaping newlines. + ESCAPED_NEWLINE = /\\\n$/.freeze + + # The start tag for ERB tags. These tags will be escaped, preventing + # users FROM USING erb DIRECTLY. + ERB_START_TAG = '<%' + + def compile(template) + transformed_lines = ['<% it = variables %>'] + + template.each_line { |line| transformed_lines << transform(line) } + Template.new(transformed_lines.join) + end + + def transform(line) + line.gsub!(ESCAPED_NEWLINE, '') + line.gsub!(ERB_START_TAG, '<%%') + + # This replacement ensures that "end" blocks on their own lines + # don't add extra newlines. Using an ERB -%> tag sadly swallows too + # many newlines. + line.gsub!(LONELY_END, '<% end %>') + line.gsub!(NORMAL_END, '<% end %>') + line.gsub!(ELSE, '<% else -%>') + + line.gsub!(EACH) do + # No, `it; variables` isn't a syntax error. Using `;` marks + # `variables` as block-local, making it possible to re-assign it + # without affecting outer definitions of this variable. We use + # this to scope template variables to the right input Hash. + "<% each(#{read_path(Regexp.last_match(1))}) do |it; variables| -%><% variables = it -%>" + end + + line.gsub!(IF) { "<% if truthy?(#{read_path(Regexp.last_match(1))}) -%>" } + line.gsub!(VAR) { "<%= #{read_path(Regexp.last_match(1))} %>" } + line + end + + def read_path(path) + return path if path == 'it' + + args = path.split('.') + args.map! { |arg| arg.match?(INTEGER) ? "#{arg}" : "'#{arg}'" } + + "read(variables, #{args.join(', ')})" + end + end + end + end +end diff --git a/lib/gitlab/changelog/template/context.rb b/lib/gitlab/changelog/template/context.rb new file mode 100644 index 00000000000000..8a0796d767e2b1 --- /dev/null +++ b/lib/gitlab/changelog/template/context.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Gitlab + module Changelog + module Template + # Context is used to provide a binding/context to ERB templates used for + # rendering changelogs. + # + # This class extends BasicObject so that we only expose the bare minimum + # needed to render the ERB template. + class Context < BasicObject + MAX_NESTED_LOOPS = 4 + + def initialize(variables) + @variables = variables + @loop_nesting = 0 + end + + def get_binding + ::Kernel.binding + end + + def each(value, &block) + max = MAX_NESTED_LOOPS + + if @loop_nesting == max + ::Kernel.raise( + ::Template::TemplateError.new("You can only nest up to #{max} loops") + ) + end + + @loop_nesting += 1 + result = value.each(&block) if value.respond_to?(:each) + @loop_nesting -= 1 + + result + end + + # rubocop: disable Style/TrivialAccessors + def variables + @variables + end + # rubocop: enable Style/TrivialAccessors + + def read(source, *steps) + current = source + + steps.each do |step| + case current + when ::Hash + current = current[step] + when ::Array + return '' unless step.is_a?(::Integer) + + current = current[step] + else + break + end + end + + current + end + + def truthy?(value) + value.respond_to?(:any?) ? value.any? : !!value + end + end + end + end +end diff --git a/lib/gitlab/changelog/template/template.rb b/lib/gitlab/changelog/template/template.rb new file mode 100644 index 00000000000000..0ff2852d6d486d --- /dev/null +++ b/lib/gitlab/changelog/template/template.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Gitlab + module Changelog + module Template + # A wrapper around an ERB template user for rendering changelogs. + class Template + TemplateError = Class.new(StandardError) + + def initialize(erb) + # Don't change the trim mode, as this may require changes to the + # regular expressions used to turn the template syntax into ERB + # tags. + @erb = ERB.new(erb, trim_mode: '-') + end + + def render(data) + context = Context.new(data).get_binding + + # ERB produces a SyntaxError when processing templates, as it + # internally uses eval() for this. + @erb.result(context) + rescue SyntaxError + raise TemplateError.new("The template's syntax is invalid") + end + end + end + end +end diff --git a/spec/lib/gitlab/changelog/committer_spec.rb b/spec/lib/gitlab/changelog/committer_spec.rb new file mode 100644 index 00000000000000..71a80264f29d49 --- /dev/null +++ b/spec/lib/gitlab/changelog/committer_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Changelog::Committer do + let(:project) { create(:project, :repository) } + let(:user) { project.creator } + let(:committer) { described_class.new(project, user) } + let(:config) { Gitlab::Changelog::Config.new(project) } + + describe '#commit' do + context "when the release isn't in the changelog" do + it 'commits the changes' do + release = Gitlab::Changelog::Release + .new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config) + + committer.commit( + release: release, + file: 'CHANGELOG.md', + branch: 'master', + message: 'Test commit' + ) + + content = project.repository.blob_at('master', 'CHANGELOG.md').data + + expect(content).to eq(<<~MARKDOWN) + ## 1.0.0 (2020-01-01) + + No changes. + MARKDOWN + end + end + + context 'when the release is already in the changelog' do + it "doesn't commit the changes" do + release = Gitlab::Changelog::Release + .new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config) + + 2.times do + committer.commit( + release: release, + file: 'CHANGELOG.md', + branch: 'master', + message: 'Test commit' + ) + end + + content = project.repository.blob_at('master', 'CHANGELOG.md').data + + expect(content).to eq(<<~MARKDOWN) + ## 1.0.0 (2020-01-01) + + No changes. + MARKDOWN + end + end + + context 'when committing the changes fails' do + it 'retries the operation' do + release = Gitlab::Changelog::Release + .new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config) + + service = instance_spy(Files::MultiService) + errored = false + + allow(Files::MultiService) + .to receive(:new) + .and_return(service) + + allow(service).to receive(:execute) do + if errored + { status: :success } + else + errored = true + { status: :error } + end + end + + expect do + committer.commit( + release: release, + file: 'CHANGELOG.md', + branch: 'master', + message: 'Test commit' + ) + end.not_to raise_error + end + end + end +end diff --git a/spec/lib/gitlab/changelog/config_spec.rb b/spec/lib/gitlab/changelog/config_spec.rb new file mode 100644 index 00000000000000..adf82fa3ac201a --- /dev/null +++ b/spec/lib/gitlab/changelog/config_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Changelog::Config do + let(:project) { build_stubbed(:project) } + + describe '.from_git' do + it 'retrieves the configuration from Git' do + allow(project.repository) + .to receive(:changelog_config) + .and_return("---\ndate_format: '%Y'") + + expect(described_class) + .to receive(:from_hash) + .with(project, 'date_format' => '%Y') + + described_class.from_git(project) + end + + it 'returns the default configuration when no YAML file exists in Git' do + allow(project.repository) + .to receive(:changelog_config) + .and_return(nil) + + expect(described_class) + .to receive(:new) + .with(project) + + described_class.from_git(project) + end + end + + describe '.from_hash' do + it 'sets the configuration according to a Hash' do + config = described_class.from_hash( + project, + 'date_format' => 'foo', + 'template' => 'bar', + 'categories' => { 'foo' => 'bar' } + ) + + expect(config.date_format).to eq('foo') + expect(config.template).to be_instance_of(Gitlab::Changelog::Template::Template) + expect(config.categories).to eq({ 'foo' => 'bar' }) + end + + it 'raises ConfigError when the categories are not a Hash' do + expect { described_class.from_hash(project, 'categories' => 10) } + .to raise_error(described_class::ConfigError) + end + end + + describe '#contributor?' do + it 'returns true if a user is a contributor' do + user = build_stubbed(:author) + + allow(project.team).to receive(:contributor?).with(user).and_return(true) + + expect(described_class.new(project).contributor?(user)).to eq(true) + end + + it "returns true if a user isn't a contributor" do + user = build_stubbed(:author) + + allow(project.team).to receive(:contributor?).with(user).and_return(false) + + expect(described_class.new(project).contributor?(user)).to eq(false) + end + end + + describe '#category' do + it 'returns the name of a category' do + config = described_class.new(project) + + config.categories['foo'] = 'Foo' + + expect(config.category('foo')).to eq('Foo') + end + + it 'returns the raw category name when no alternative name is configured' do + config = described_class.new(project) + + expect(config.category('bla')).to eq('bla') + end + end + + describe '#format_date' do + it 'formats a date according to the configured date format' do + config = described_class.new(project) + time = Time.utc(2021, 1, 5) + + expect(config.format_date(time)).to eq('2021-01-05') + end + end +end diff --git a/spec/lib/gitlab/changelog/generator_spec.rb b/spec/lib/gitlab/changelog/generator_spec.rb new file mode 100644 index 00000000000000..bc4a7c5dd6b932 --- /dev/null +++ b/spec/lib/gitlab/changelog/generator_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Changelog::Generator do + describe '#add' do + let(:project) { build_stubbed(:project) } + let(:author) { build_stubbed(:user) } + let(:commit) { build_stubbed(:commit) } + let(:config) { Gitlab::Changelog::Config.new(project) } + + it 'generates the Markdown for the first release' do + release = Gitlab::Changelog::Release.new( + version: '1.0.0', + date: Time.utc(2021, 1, 5), + config: config + ) + + release.add_entry( + title: 'This is a new change', + commit: commit, + category: 'added', + author: author + ) + + gen = described_class.new('') + + expect(gen.add(release)).to eq(<<~MARKDOWN) + ## 1.0.0 (2021-01-05) + + ### added (1 change) + + - [This is a new change](#{commit.to_reference(full: true)}) + MARKDOWN + end + + it 'generates the Markdown for a newer release' do + release = Gitlab::Changelog::Release.new( + version: '2.0.0', + date: Time.utc(2021, 1, 5), + config: config + ) + + release.add_entry( + title: 'This is a new change', + commit: commit, + category: 'added', + author: author + ) + + gen = described_class.new(<<~MARKDOWN) + This is a changelog file. + + ## 1.0.0 + + This is the changelog for version 1.0.0. + MARKDOWN + + expect(gen.add(release)).to eq(<<~MARKDOWN) + This is a changelog file. + + ## 2.0.0 (2021-01-05) + + ### added (1 change) + + - [This is a new change](#{commit.to_reference(full: true)}) + + ## 1.0.0 + + This is the changelog for version 1.0.0. + MARKDOWN + end + + it 'generates the Markdown for a patch release' do + release = Gitlab::Changelog::Release.new( + version: '1.1.0', + date: Time.utc(2021, 1, 5), + config: config + ) + + release.add_entry( + title: 'This is a new change', + commit: commit, + category: 'added', + author: author + ) + + gen = described_class.new(<<~MARKDOWN) + This is a changelog file. + + ## 2.0.0 + + This is another release. + + ## 1.0.0 + + This is the changelog for version 1.0.0. + MARKDOWN + + expect(gen.add(release)).to eq(<<~MARKDOWN) + This is a changelog file. + + ## 2.0.0 + + This is another release. + + ## 1.1.0 (2021-01-05) + + ### added (1 change) + + - [This is a new change](#{commit.to_reference(full: true)}) + + ## 1.0.0 + + This is the changelog for version 1.0.0. + MARKDOWN + end + + it 'generates the Markdown for an old release' do + release = Gitlab::Changelog::Release.new( + version: '0.5.0', + date: Time.utc(2021, 1, 5), + config: config + ) + + release.add_entry( + title: 'This is a new change', + commit: commit, + category: 'added', + author: author + ) + + gen = described_class.new(<<~MARKDOWN) + This is a changelog file. + + ## 2.0.0 + + This is another release. + + ## 1.0.0 + + This is the changelog for version 1.0.0. + MARKDOWN + + expect(gen.add(release)).to eq(<<~MARKDOWN) + This is a changelog file. + + ## 2.0.0 + + This is another release. + + ## 1.0.0 + + This is the changelog for version 1.0.0. + + ## 0.5.0 (2021-01-05) + + ### added (1 change) + + - [This is a new change](#{commit.to_reference(full: true)}) + MARKDOWN + end + end +end diff --git a/spec/lib/gitlab/changelog/release_spec.rb b/spec/lib/gitlab/changelog/release_spec.rb new file mode 100644 index 00000000000000..50a23d23299300 --- /dev/null +++ b/spec/lib/gitlab/changelog/release_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Changelog::Release do + describe '#to_markdown' do + let(:config) { Gitlab::Changelog::Config.new(build_stubbed(:project)) } + let(:commit) { build_stubbed(:commit) } + let(:author) { build_stubbed(:user) } + let(:mr) { build_stubbed(:merge_request) } + let(:release) do + described_class + .new(version: '1.0.0', date: Time.utc(2021, 1, 5), config: config) + end + + context 'when there are no entries' do + it 'includes a notice about the lack of entries' do + expect(release.to_markdown).to eq(<<~OUT) + ## 1.0.0 (2021-01-05) + + No changes. + + OUT + end + end + + context 'when all data is present' do + it 'includes all data' do + allow(config).to receive(:contributor?).with(author).and_return(true) + + release.add_entry( + title: 'Entry title', + commit: commit, + category: 'fixed', + author: author, + merge_request: mr + ) + + expect(release.to_markdown).to eq(<<~OUT) + ## 1.0.0 (2021-01-05) + + ### fixed (1 change) + + - [Entry title](#{commit.to_reference(full: true)}) \ + by #{author.to_reference(full: true)} \ + ([merge request](#{mr.to_reference(full: true)})) + + OUT + end + end + + context 'when no merge request is present' do + it "doesn't include a merge request link" do + allow(config).to receive(:contributor?).with(author).and_return(true) + + release.add_entry( + title: 'Entry title', + commit: commit, + category: 'fixed', + author: author + ) + + expect(release.to_markdown).to eq(<<~OUT) + ## 1.0.0 (2021-01-05) + + ### fixed (1 change) + + - [Entry title](#{commit.to_reference(full: true)}) \ + by #{author.to_reference(full: true)} + + OUT + end + end + + context 'when the author is not a contributor' do + it "doesn't include the author" do + allow(config).to receive(:contributor?).with(author).and_return(false) + + release.add_entry( + title: 'Entry title', + commit: commit, + category: 'fixed', + author: author + ) + + expect(release.to_markdown).to eq(<<~OUT) + ## 1.0.0 (2021-01-05) + + ### fixed (1 change) + + - [Entry title](#{commit.to_reference(full: true)}) + + OUT + end + end + end + + describe '#header_start_position' do + it 'returns a regular expression for finding the start of a release section' do + config = Gitlab::Changelog::Config.new(build_stubbed(:project)) + release = described_class + .new(version: '1.0.0', date: Time.utc(2021, 1, 5), config: config) + + expect(release.header_start_pattern).to eq(/^##\s*1\.0\.0/) + end + end +end diff --git a/spec/lib/gitlab/changelog/template/compiler_spec.rb b/spec/lib/gitlab/changelog/template/compiler_spec.rb new file mode 100644 index 00000000000000..d940fbaec89e52 --- /dev/null +++ b/spec/lib/gitlab/changelog/template/compiler_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Changelog::Template::Compiler do + def compile(template, data = {}) + Gitlab::Changelog::Template::Compiler.new.compile(template).render(data) + end + + describe '#compile' do + it 'compiles an empty template' do + expect(compile('')).to eq('') + end + + it 'compiles a template with an undefined variable' do + expect(compile('{{number}}')).to eq('') + end + + it 'compiles a template with a defined variable' do + expect(compile('{{number}}', 'number' => 42)).to eq('42') + end + + it 'compiles a template with the special "it" variable' do + expect(compile('{{it}}', 'values' => 10)).to eq({ 'values' => 10 }.to_s) + end + + it 'compiles a template containing an if statement' do + expect(compile('{% if foo %}yes{% end %}', 'foo' => true)).to eq('yes') + end + + it 'compiles a template containing an if/else statement' do + expect(compile('{% if foo %}yes{% else %}no{% end %}', 'foo' => false)) + .to eq('no') + end + + it 'compiles a template that iterates over an Array' do + expect(compile('{% each numbers %}{{it}}{% end %}', 'numbers' => [1, 2, 3])) + .to eq('123') + end + + it 'compiles a template that iterates over a Hash' do + output = compile( + '{% each pairs %}{{0}}={{1}}{% end %}', + 'pairs' => { 'key' => 'value' } + ) + + expect(output).to eq('key=value') + end + + it 'compiles a template that iterates over a Hash of Arrays' do + output = compile( + '{% each values %}{{key}}{% end %}', + 'values' => [{ 'key' => 'value' }] + ) + + expect(output).to eq('value') + end + + it 'compiles a template with a variable path' do + output = compile('{{foo.bar}}', 'foo' => { 'bar' => 10 }) + + expect(output).to eq('10') + end + + it 'compiles a template with a variable path that uses an Array index' do + output = compile('{{foo.values.1}}', 'foo' => { 'values' => [10, 20] }) + + expect(output).to eq('20') + end + + it 'compiles a template with a variable path that uses a Hash and a numeric index' do + output = compile('{{foo.1}}', 'foo' => { 'key' => 'value' }) + + expect(output).to eq('') + end + + it 'compiles a template with a variable path that uses an Array and a String based index' do + output = compile('{{foo.numbers.bla}}', 'foo' => { 'numbers' => [10, 20] }) + + expect(output).to eq('') + end + + it 'ignores ERB tags provided by the user' do + input = '<% exit %> <%= exit %> <%= foo -%>' + + expect(compile(input)).to eq(input) + end + + it 'removes newlines introduced by end statements on their own lines' do + output = compile(<<~TPL, 'foo' => true) + {% if foo %} + foo + {% end %} + TPL + + expect(output).to eq("foo\n") + end + + it 'supports escaping of trailing newlines' do + output = compile(<<~TPL) + foo \ + bar\ + baz + TPL + + expect(output).to eq("foo barbaz\n") + end + + # rubocop: disable Lint/InterpolationCheck + it 'ignores embedded Ruby expressions' do + input = '#{exit}' + + expect(compile(input)).to eq(input) + end + # rubocop: enable Lint/InterpolationCheck + + it 'ignores ERB tags inside variable tags' do + input = '{{<%= exit %>}}' + + expect(compile(input)).to eq(input) + end + + it 'ignores malicious code that tries to escape a variable' do + input = "{{') ::Kernel.exit # '}}" + + expect(compile(input)).to eq(input) + end + end +end -- GitLab