Commit f4482d2e authored by Yorick Peterse's avatar Yorick Peterse

Simplify the project structure

This simplifies the project structure by making the following changes:

1. The file/directory hierarchy now matches the pattern used for most
   Ruby projects. This means no more init.rb, and all code now resides
   in lib/release_tools/ instead of lib/.

2. All classes and modules are namespaced to the ReleaseTools module,
   instead of them being defined at the top level.

3. Dependencies are now loaded using regular require calls in
   lib/release_tools.rb, instead of relying on a mixture of require,
   require_relative, and autoload.

4. The colorize Gem is updated to support the use of frozen strings, and
   because the used version was from 2009.
parent d17f8cb4
......@@ -18,7 +18,7 @@ GEM
byebug (9.0.5)
climate_control (0.2.0)
coderay (1.1.2)
colorize (0.5.8)
colorize (0.8.1)
concord (0.1.5)
adamantium (~> 0.2.0)
equalizer (~> 0.0.9)
......
require_relative 'init'
require_relative 'lib/support/tasks_helper'
require_relative 'lib/local_repository'
require_relative 'lib/release_tools'
require_relative 'lib/release_tools/support/tasks_helper'
begin
require 'rspec/core/rake_task'
......
require 'colorize'
require 'dotenv'
Dotenv.load
$LOAD_PATH.unshift(File.expand_path('./lib', __dir__))
require 'shared_status'
unless ENV['TEST']
require 'sentry-raven'
Raven.user_context(
git_user: SharedStatus.user,
release_user: ENV['RELEASE_USER']
)
end
require 'version'
require 'project'
require 'pick_into_label'
require 'cherry_pick'
require 'monthly_issue'
require 'patch_issue'
require 'packages'
require 'qa'
require 'qa/services/build_qa_issue_service'
require 'qa/issue_closer'
require 'branch'
require 'preparation_merge_request'
require 'merge_request'
require 'security_patch_issue'
require 'release/gitlab_ce_release'
require 'release/gitlab_ee_release'
require 'release/helm_gitlab_release'
require 'release_managers'
require 'services/upstream_merge_service'
require 'slack'
require 'sync'
require 'upstream_merge'
require 'upstream_merge_request'
class Branch
attr_reader :name, :project
def initialize(name:, project:)
@name = name
@project = project
end
def create(ref:)
GitlabClient.create_branch(name, ref, project)
end
end
require_relative 'changelog/manager'
module Changelog
class NoChangelogError < StandardError
attr_reader :changelog_path
def initialize(changelog_path)
@changelog_path = changelog_path
end
end
end
module Changelog
class Config
def self.log(ee: false)
ee ? ee_log : ce_log
end
def self.ce_log
'CHANGELOG.md'
end
def self.ee_log
'CHANGELOG-EE.md'
end
def self.paths(ee: false)
ee ? ee_paths : [ce_path]
end
# Relative path to unreleased CE changelog entries
def self.ce_path
File.join(root_path, 'unreleased')
end
# Relative path to unreleased EE changelog entries
def self.ee_paths
[
File.join('ee', ce_path),
"#{ce_path}-ee" # Legacy path, for back-compatibility
]
end
def self.extension
'.yml'
end
def self.root_path
'changelogs'
end
end
end
require 'yaml'
require 'active_support/core_ext/object/blank'
module Changelog
# Represents a Rugged::Blob and its changelog entry
class Entry
attr_reader :author, :blob, :id, :path, :title, :type
# Types are not sorted by ABC intentionally.
# The markdown output is generated going through this array down.
TYPES = %w[
security
removed
fixed
deprecated
changed
performance
added
other
].freeze
# path - Path to the blob, relative to the Repository root
# blob - Underlying Rugged::Blob object
def initialize(path, blob)
@path = path
@blob = blob
parse_blob(blob.content)
end
def to_s
str = ""
str << "#{title}.".gsub(/\.{2,}$/, '.')
str << " !#{id}" if id.present?
str << " (#{author})" if author.present?
str
end
def valid?
title.present?
end
private
def parse_blob(content)
yaml = YAML.safe_load(content)
@author = yaml['author']
@id = parse_id(yaml)
@title = yaml['title']
@type = parse_type(yaml)
rescue StandardError # rubocop:disable Lint/HandleExceptions
# noop
end
def parse_id(yaml)
id = yaml['merge_request'] || yaml['id']
id.to_s.gsub!(/[^\d]/, '')
# We don't want `nil` to become `0`
id.present? ? id.to_i : id
end
# Any type (including invalid ones) which are not included in the `TYPES` constant
# we define as `other`.
def parse_type(yaml)
type = yaml['type']&.downcase
TYPES.include?(type) ? type : 'other'
end
end
end
require 'rugged'
require_relative 'config'
require_relative 'entry'
require_relative 'markdown_generator'
require_relative 'updater'
module Changelog
# Manager collects the unreleased changelog entries from a Version's stable
# branch, and then performs the following actions:
#
# 1. Compiles their contents into Markdown, updating the overall changelog
# document(s).
# 2. Removes them from the repository.
# 3. Commits the changes.
#
# These steps are performed on both the stable _and_ the `master` branch,
# keeping them in sync.
#
# Because `master` is never merged into a `stable` branch, we aren't concerned
# with the commits differing.
#
# In the case of an EE release, things get slightly more complex. We perform
# the same steps above with the EE paths (e.g., `CHANGELOG-EE.md` and
# `changes/unreleased-ee/`), then perform them _again_ but with the CE paths
# (e.g., `CHANGELOG.md` and `changes/unreleased/`).
#
# This is necessary because by the time this process is performed, CE has
# already been merged into EE without the consolidated `CHANGELOG.md`.
class Manager
attr_reader :repository, :version, :changelog_file
# repository - Rugged::Repository object or String path to repository
def initialize(repository, changelog_file = nil)
case repository
when String
@repository = Rugged::Repository.new(repository)
when Rugged::Repository
@repository = repository
else
raise "Invalid repository: #{repository}"
end
@changelog_file = changelog_file
end
def release(version, stable_branch: version.stable_branch)
@unreleased_entries = nil
@version = version
perform_release(stable_branch)
perform_release('master')
# Recurse to perform the CE release if we're on EE
if version.ee?
# NOTE: We pass the EE stable branch, but use the CE configuration!
release(version.to_ce, stable_branch: version.stable_branch)
end
end
private
attr_reader :ref, :commit, :tree, :index
def changelog_file
@changelog_file || Config.log(ee: version.ee?)
end
def unreleased_paths
Config.paths(ee: version.ee?)
end
def perform_release(branch_name)
checkout(branch_name)
update_changelog
remove_processed_entries
create_commit
end
# Checkout the specified branch and update `ref`, `commit`, `tree`, and
# `index` with the current state of the repository.
#
# branch_name - Branch name to checkout
def checkout(branch_name)
@ref = repository.checkout(branch_name)
@commit = @ref.target.target
@tree = @commit.tree
@index = repository.index
@index.read_tree(commit.tree)
end
# Updates `changelog_file` with the Markdown built from the individual
# unreleased changelog entries.
#
# Raises `NoChangelogError` if the changelog blob does not exist.
def update_changelog
blob = repository.blob_at(repository.head.target_id, changelog_file)
raise ::Changelog::NoChangelogError.new(changelog_file) if blob.nil?
updater = Updater.new(blob.content, version)
markdown = MarkdownGenerator.new(version, unreleased_entries).to_s
changelog_oid = repository.write(updater.insert(markdown), :blob)
index.add(path: changelog_file, oid: changelog_oid, mode: 0o100644)
end
def remove_processed_entries
return if unreleased_entries.empty?
index.remove_all(unreleased_entries.collect(&:path))
end
def create_commit
Rugged::Commit.create(
repository,
tree: index.write_tree(repository),
message: "Update #{changelog_file} for #{version}\n\n[ci skip]",
parents: [commit],
update_ref: 'HEAD'
)
repository.checkout_head(strategy: :force)
end
# Build an Array of `Changelog::Entry` objects
#
# Raises `RuntimeError` if the `HEAD` is not a stable branch, or if the
# repository tree could not be read.
#
# Returns an Array
def unreleased_entries
return @unreleased_entries if @unreleased_entries
raise "Cannot gather changelog blobs on a non-stable branch." unless on_stable?
raise "Cannot gather changelog blobs. Check out the stable branch first!" unless tree
@unreleased_entries = []
tree.walk(:preorder) do |root, entry|
next unless unreleased_paths.any? { |path| root.start_with?("#{path}/") }
next unless entry[:name].end_with?(Config.extension)
@unreleased_entries << Entry.new(
File.join(root, entry[:name]),
repository.lookup(entry[:oid])
)
end
@unreleased_entries
end
def on_stable?
repository.head.canonical_name.end_with?('-stable', '-stable-ee')
end
end
end
require 'date'
require_relative '../release'
module Changelog
class MarkdownGenerator
attr_reader :version, :entries
def initialize(version, entries)
@version = version
@entries = entries.select(&:valid?)
end
def to_s
markdown = StringIO.new
markdown.puts header
markdown.puts
if entries.empty?
markdown.puts "- No changes."
else
markdown.puts formatted_entries
end
markdown.puts
markdown.string
end
private
def header
"## #{version.to_patch} (#{date})"
end
def date
if version.monthly?
Date.today.strftime("%Y-%m-22")
else
Date.today.strftime("%Y-%m-%d")
end
end
# Select entries where the `author` field is not empty.
# This field is filled in only by community contributors.
def entries_from_community(entries)
entries.select(&:author)
end
# Select entries for given type.
def entries_grouped_by_type(type)
entries_sorted_by_id.select { |entry| entry.type == type }
end
# Sort entries in ascending order by ID
#
# Entries without an ID are placed last
def entries_sorted_by_id
entries.sort do |a, b|
(a.id || 999_999).to_i <=> (b.id || 999_999).to_i
end
end
# Group entries by type found in the `Changelog::Entry::TYPES`.
# Output example:
#
# ### Fixed (52 changes, 16 of them are from community)
# - Fix 404 errors in API caused when the branch name had a dot. !14462 (gvieira37)
def formatted_entries
result = ''
Changelog::Entry::TYPES.each do |type|
grouped_entries = entries_grouped_by_type(type)
changes_count = grouped_entries.size
# Do nothing if no changes are presented for the current type.
next unless changes_count.positive?
community_entries_count = entries_from_community(grouped_entries).size
# Prepare the group header.
# Example:
# ### Added (54 changes, 15 of them are from the community)
changes = [changes_count, 'change'.pluralize(changes_count)].join("\s")
if community_entries_count.positive?
verb = community_entries_count > 1 ? 'are' : 'is'
changes << ", #{community_entries_count} of them #{verb} from the community"
end
result << "### #{type.capitalize} (#{changes})\n\n"
# Add entries to the group.
grouped_entries.each { |entry| result << "- #{entry}\n" }
result << "\n"
end
result
end
end
end
require_relative '../version'
module Changelog
# Updates a Markdown changelog String by inserting new Markdown for a
# specified version above the appropriate previous version.
#
# This class expects that the provided Markdown String contains a changelog in
# the following format:
#
# ## 8.10.1 (2016-07-25)
#
# - Entries
#
# ## 8.10.0 (2016-07-22)
#
# - Entries
#
# ## 8.9.6 (2016-07-11)
#
# - Entries
#
# When given a new minor version, for example 8.11.0, a changelog entry will
# be added above the `## 8.10.1` entry. When given a new patch version for a
# previous minor release, for example 8.9.7, the entry will be placed _above_
# `## 8.9.6` but _below_ `## 8.10.0`.
class Updater
attr_reader :contents, :version
# contents - Existing changelog content String
# version - Version object
def initialize(contents, version)
@contents = contents.lines
@version = version
end
# Insert some Markdown into an existing changelog based on the current
# version and the version headers already present in the changelog.
#
# markdown - Markdown String to insert
#
# Returns the updated Markdown String
def insert(markdown)
contents.each_with_index do |line, index|
if line =~ /\A## (\d+\.\d+\.\d+)/
header = Version.new($1)
if version.to_ce == header
entries = markdown.lines
entries.shift(2) # Remove the header and the blank line
entries.pop # Remove the trailing blank line
# Insert the entries below the existing header and its blank line
contents.insert(index + 2, entries)
break
elsif version >= header
contents.insert(index, *markdown.lines)
break
end
end
end
contents
.flatten
.map { |line| line.force_encoding(Encoding::UTF_8) }
.join
end
end
end
require 'cherry_pick/comment_notifier'
require 'cherry_pick/console_notifier'
require 'cherry_pick/result'
require 'cherry_pick/service'
module CherryPick
end
require 'active_support/core_ext/string/inflections'
module CherryPick
class CommentNotifier
attr_reader :version
attr_reader :prep_mr
def initialize(version, prep_mr)
@version = version
@prep_mr = prep_mr
end
def comment(pick_result)
if pick_result.success?
successful_comment(pick_result)
else
failure_comment(pick_result)
end
end
# Post a summary comment in the preparation merge request with a list of
# picked and unpicked merge requests
#
# picked - Array of successful Results
# unpicked - Array of failure Results
def summary(picked, unpicked)
return if picked.empty? && unpicked.empty?
message = []
if picked.any?
message << <<~MSG
Successfully picked the following merge requests:
#{markdown_list(picked.collect(&:url))}
MSG
end
if unpicked.any?
message << <<~MSG
Failed to pick the following merge requests:
#{markdown_list(unpicked.collect(&:url))}
MSG
end
create_merge_request_comment(prep_mr, message.join("\n"))
end
private
def markdown_list(array)
array.map { |v| "* #{v}" }.join("\n")
end
def successful_comment(pick_result)
comment = <<~MSG
Automatically picked into #{prep_mr.url}, will merge into
`#{version.stable_branch}` ready for `#{version}`.
/unlabel #{PickIntoLabel.reference(version)}
MSG
create_merge_request_comment(pick_result.merge_request, comment)
end
def failure_comment(pick_result)
comment = <<~MSG
This merge request could not automatically be picked into
`#{version.stable_branch}` for `#{version}` and will need manual
intervention.
MSG
create_merge_request_comment(pick_result.merge_request, comment)
end
def client
GitlabClient
end
def create_merge_request_comment(merge_request, comment)
client.create_merge_request_comment(
merge_request.project_id,
merge_request.iid,
comment
)
end
end
end
require 'stringio'
require 'active_support/core_ext/string/indent'
module CherryPick