diff --git a/app/models/repository.rb b/app/models/repository.rb index c646f567b949644f1b3badc584934e940ad65511..ef204170732c90b9768dc0616d19d6979732e2a4 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 0000000000000000000000000000000000000000..d2563590bed7b4984306594a3b5e5ef9e603552e --- /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 0000000000000000000000000000000000000000..ac62572576e2bacfa62ccafd67e26a27c0545cc7 --- /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 0000000000000000000000000000000000000000..a80ca0728f98595b48949653f4d2b570b9a0960f --- /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 0000000000000000000000000000000000000000..4c78eb5080c7ce673945cf281ede3caa6625946a --- /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 0000000000000000000000000000000000000000..838b7080f6841140c8dea81b2ebb031f29267682 --- /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 0000000000000000000000000000000000000000..f67bab0f29fa0b31fb17f0ac095d3959c15ff501 --- /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 0000000000000000000000000000000000000000..8a0796d767e2b14ff8d327c5a7ff1acc3a448eb5 --- /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 0000000000000000000000000000000000000000..0ff2852d6d486db0acb998373194a90c9f94a9be --- /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 0000000000000000000000000000000000000000..71a80264f29d4974baa71ea076e3388b9b9363df --- /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 0000000000000000000000000000000000000000..adf82fa3ac201aa26f52f0050a213c45d191af85 --- /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 0000000000000000000000000000000000000000..bc4a7c5dd6b93264d39aade57208f0e556e00a18 --- /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 0000000000000000000000000000000000000000..50a23d23299300c6b7c2930ce141c5586a436e4e --- /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 0000000000000000000000000000000000000000..d940fbaec89e5230ed371d2306ee45dafd1849a9 --- /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