Skip to content

Commit

Permalink
Add action backporter (#8)
Browse files Browse the repository at this point in the history
* Add action backporter

* Fix specs

* Fix latest issues

* Apply suggestions from code review

Co-authored-by: Andrés Pereira de Lucena <[email protected]>

* Apply review recommendations

---------

Co-authored-by: Andrés Pereira de Lucena <[email protected]>
  • Loading branch information
alecslupu and andreslucena authored Sep 25, 2024
1 parent e3ed6e8 commit 3ddec41
Show file tree
Hide file tree
Showing 9 changed files with 362 additions and 20 deletions.
40 changes: 40 additions & 0 deletions exe/decidim-action-backporter
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require "thor"

require_relative "../lib/decidim/maintainers_toolbox/github_manager/querier"
require_relative "../lib/decidim/maintainers_toolbox/action_backporter"

class ActionBackporterCLI < Thor
desc "", "Backport a pull request to another branch. This is intended to be run in the GitHub Action environment, for automating the backport processes."
option :github_token, required: true, desc: <<~HELP
Required. Github Personal Access Token (PAT). It can be obtained from https://github.com/settings/tokens/new. You will need to create one with `public_repo` access.
Alternatively, you can use the `gh` CLI tool to authenticate with `gh auth token` (i.e. --github-token=$(gh auth token))
HELP
option :pull_request_id, required: true, desc: "Required. The ID of the pull request that you want to make the backport from. It should have the \"type: fix\" label."
option :exit_with_unstaged_changes, type: :boolean, default: true, desc: <<~HELP
Optional. Whether the script should exit with an error if there are unstaged changes in the current project.
HELP
default_task :backport

def backport
Decidim::MaintainersToolbox::ActionBackporter.new(
token: options[:github_token],
pull_request_id: options[:pull_request_id],
exit_with_unstaged_changes: options[:exit_with_unstaged_changes]
).call
rescue Decidim::MaintainersToolbox::GithubManager::Querier::Base::InvalidMetadataError
puts "Metadata was not returned from the server. Please check that the provided pull request ID and GitHub token are correct."
end

def help
super("backport")
end

def self.exit_on_failure?
true
end
end

ActionBackporterCLI.start(ARGV)
6 changes: 5 additions & 1 deletion exe/decidim-backporter
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ class BackporterCLI < Thor
option :exit_with_unstaged_changes, type: :boolean, default: true, desc: <<~HELP
Optional. Whether the script should exit with an error if there are unstaged changes in the current project.
HELP
option :with_console, required: false, type: :boolean, default: true, desc: <<~HELP
Optional. Disables the shell dropout
HELP
default_task :backport

def backport
Decidim::MaintainersToolbox::Backporter.new(
token: options[:github_token],
pull_request_id: options[:pull_request_id],
version_number: options[:version_number],
exit_with_unstaged_changes: options[:exit_with_unstaged_changes]
exit_with_unstaged_changes: options[:exit_with_unstaged_changes],
with_console: options[:with_console]
).call
rescue Decidim::MaintainersToolbox::GithubManager::Querier::Base::InvalidMetadataError
puts "Metadata was not returned from the server. Please check that the provided pull request ID and GitHub token are correct."
Expand Down
98 changes: 98 additions & 0 deletions lib/decidim/maintainers_toolbox/action_backporter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require_relative "github_manager/querier"
require_relative "github_manager/poster"
require_relative "git_backport_manager"

module Decidim
module MaintainersToolbox
class ActionBackporter
class InvalidMetadataError < StandardError; end

DECIDIM_MAINTAINERS = ["alecslupu", "andreslucena"]

# @param token [String] token for GitHub authentication
# @param pull_request_id [String] the ID of the pull request that we want to backport
# @param exit_with_unstaged_changes [Boolean] wheter we should exit cowardly if there is any unstaged change
def initialize(token: , pull_request_id: ,exit_with_unstaged_changes: )
@token = token
@pull_request_id = pull_request_id
@exit_with_unstaged_changes = exit_with_unstaged_changes
end

def call
exit_with_errors("The requested PR #{pull_request_id} does not contain `type: fix`") unless pull_request_metadata[:labels].include?("type: fix")
exit_with_errors("The requested PR #{pull_request_id} is not merged") unless pull_request_metadata[:is_merged]
exit_with_errors("The requested PR #{pull_request_id} cannot be backported") if pull_request_metadata[:labels].include?("no-backport")

extract_versions.each do |version|
next if extract_backport_pull_request_for_version(related_issues, version)
system("decidim-backporter --github_token=#{token} --pull_request_id=#{pull_request_id} --version_number=#{version} --exit_with_unstaged_changes=#{exit_with_unstaged_changes} --with-console=false", exception: true)
rescue RuntimeError => e
puts e.message
create_backport_issue(version)
end
end

private

attr_reader :token, :pull_request_id, :exit_with_unstaged_changes

def create_backport_issue(version)
some_params = {
title: "Fail: automatic backport of \"#{pull_request_metadata[:title]}\"",
body: "Automatic backport of ##{pull_request_id} has failed for version #{version}. Please do this action manually.",
assignee: DECIDIM_MAINTAINERS,
labels: pull_request_metadata[:labels]
}

uri = "https://api.github.com/repos/decidim/decidim/issues"
Faraday.post(uri, some_params.to_json, { Authorization: "token #{token}" })
end

# same method exists in lib/decidim/maintainers_toolbox/backports_reporter/report.rb
def extract_backport_pull_request_for_version(related_issues, version)
related_issues = related_issues.select do |pull_request|
pull_request[:title].start_with?("Backport") && pull_request[:title].include?(version)
end
return if related_issues.empty?

related_issues.first
end

def extract_versions
return [] unless pull_request_metadata[:labels]

pull_request_metadata[:labels].map do |item|
item.match(/release: v(\d+\.\d+)/) { |m| m[1] }
end.compact
end

# Asks the metadata for a given issue or pull request on GitHub API
#
# @return [Faraday::Response] An instance that represents an HTTP response from making an HTTP request
# Same method exists in lib/decidim/maintainers_toolbox/backporter.rb
def pull_request_metadata
@pull_request_metadata ||= Decidim::MaintainersToolbox::GithubManager::Querier::ByIssueId.new(
token: token,
issue_id: pull_request_id
).call
end

def related_issues
@related_issues ||= Decidim::MaintainersToolbox::GithubManager::Querier::RelatedIssues.new(
token: token,
issue_id: pull_request_id
).call
end

# Exit the script execution with a message
#
# @return [void]
def exit_with_errors(message)
puts message
exit 1
end
end
end
end
8 changes: 5 additions & 3 deletions lib/decidim/maintainers_toolbox/backporter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ class InvalidMetadataError < StandardError; end
# @param pull_request_id [String] the ID of the pull request that we want to backport
# @param version_number [String] the version number of the release that we want to make the backport to
# @param exit_with_unstaged_changes [Boolean] wheter we should exit cowardly if there is any unstaged change
def initialize(token:, pull_request_id:, version_number:, exit_with_unstaged_changes:)
def initialize(token:, pull_request_id:, version_number:, exit_with_unstaged_changes:, with_console:)
@token = token
@pull_request_id = pull_request_id
@version_number = version_number
@exit_with_unstaged_changes = exit_with_unstaged_changes
@with_console = with_console
end

# Handles the different tasks to create a backport:
Expand All @@ -32,7 +33,7 @@ def call

private

attr_reader :token, :pull_request_id, :version_number, :exit_with_unstaged_changes
attr_reader :token, :pull_request_id, :version_number, :exit_with_unstaged_changes, :with_console

# Asks the metadata for a given issue or pull request on GitHub API
#
Expand All @@ -52,7 +53,8 @@ def make_cherrypick_and_branch(metadata)
pull_request_id: pull_request_id,
release_branch: release_branch,
backport_branch: backport_branch(metadata[:title]),
exit_with_unstaged_changes: exit_with_unstaged_changes
exit_with_unstaged_changes: exit_with_unstaged_changes,
with_console: with_console
).call
end

Expand Down
15 changes: 11 additions & 4 deletions lib/decidim/maintainers_toolbox/git_backport_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ class GitBackportManager
# @param backport_branch [String] the name of the branch that we want to create
# @param working_dir [String] current working directory. Useful for testing purposes
# @param exit_with_unstaged_changes [Boolean] wheter we should exit cowardly if there is any unstaged change
def initialize(pull_request_id:, release_branch:, backport_branch:, working_dir: Dir.pwd, exit_with_unstaged_changes: false)
def initialize(pull_request_id:, release_branch:, backport_branch:, working_dir: Dir.pwd, exit_with_unstaged_changes: false, with_console: true)
@pull_request_id = pull_request_id
@release_branch = release_branch
@backport_branch = sanitize_branch(backport_branch)
@working_dir = working_dir
@exit_with_unstaged_changes = exit_with_unstaged_changes
@with_console = with_console
end

# Handles all the different tasks involved on a backport with the git command line utility
Expand Down Expand Up @@ -64,7 +65,7 @@ def self.checkout_develop

private

attr_reader :pull_request_id, :release_branch, :backport_branch, :working_dir
attr_reader :pull_request_id, :release_branch, :backport_branch, :working_dir, :with_console

# Create the backport branch based on a release branch
# Checks that this branch does not exist already, if it does then exits
Expand Down Expand Up @@ -96,8 +97,14 @@ def cherrypick_commit!(sha_commit)
`git cherry-pick #{sha_commit}`

unless $CHILD_STATUS.exitstatus.zero?
puts "Resolve the cherrypick conflict manually and exit your shell to keep with the process."
system ENV.fetch("SHELL")
error_message = "Resolve the cherrypick conflict manually and exit your shell to keep with the process."

if with_console
puts error_message
system ENV.fetch("SHELL")
else
exit_with_errors(error_message)
end
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ def parse(metadata)

{
id: metadata["number"],
state: metadata["state"],
is_pull_request: metadata["pull_request"].present?,
is_merged: (metadata["pull_request"]["merged_at"].present? rescue false),
title: metadata["title"],
labels: labels,
type: labels.select { |l| l.match(/^type: /) || l == "target: developer-experience" },
Expand Down
118 changes: 118 additions & 0 deletions spec/lib/decidim/maintainers_toolbox/action_backporter_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# frozen_string_literal: true

require "decidim/maintainers_toolbox/action_backporter"
require "webmock/rspec"

describe Decidim::MaintainersToolbox::ActionBackporter do

subject { described_class.new(token: token, pull_request_id: pull_request_id, exit_with_unstaged_changes: exit_with_unstaged_changes) }

let(:token) { "1234" }
let(:pull_request_id) { 123 }
let(:exit_with_unstaged_changes) { true }

before do
stub_request(:get, "https://api.github.com/repos/decidim/decidim/issues/123").
to_return(status: 200, body: '{"number": 12345, "pull_request": {"merged_at": "" }, "title": "Fix whatever", "labels": [{"name": "type: fix"}, {"name": "module: admin"}]}', headers: {})

stub_request(:post, "https://api.github.com/repos/decidim/decidim/issues")
.to_return(status: 200, body: "{}", headers: {})
end

describe ".exit_with_errors" do
it "exit with a custom message" do
expect { subject.send(:exit_with_errors, "Bye") }.to raise_error(SystemExit).and output(/Bye/).to_stdout
end
end

describe ".call" do
it "exists when the PR is not a fix" do
allow(subject).to receive(:pull_request_metadata).and_return({labels: ["type: change"], is_merged: true })
expect{ subject.call }.to raise_error(SystemExit).and output(/does not contain `type: fix`/).to_stdout
end

it "exists when the PR is not merged" do
allow(subject).to receive(:pull_request_metadata).and_return({labels: ["type: fix"], is_merged: false })
expect{ subject.call }.to raise_error(SystemExit).and output(/is not merged/).to_stdout
end

it "exists when the PR is not backportable" do
allow(subject).to receive(:pull_request_metadata).and_return({labels: ["type: fix", "no-backport"], is_merged: true })
expect{ subject.call }.to raise_error(SystemExit).and output(/cannot be backported/).to_stdout
end

it "calls extract versions" do
allow(subject).to receive(:pull_request_metadata).and_return({ labels: ["type: fix", "release: v0.28", "release: v0.29"], is_merged: true })
allow(subject).to receive(:extract_versions).and_return(["0.28", "0.29"])
allow(subject).to receive(:related_issues).and_return([])
allow(subject).to receive(:system).and_return(true)

expect(subject).to receive(:extract_versions)

subject.call
end

it "runs the system command" do
allow(subject).to receive(:pull_request_metadata).and_return({ labels: ["type: fix", "release: v0.28", "release: v0.29"], is_merged: true })
allow(subject).to receive(:extract_versions).and_return(["0.28", "0.29"])
allow(subject).to receive(:related_issues).and_return([])

expect(subject).to receive(:system).with("decidim-backporter --github_token=#{token} --pull_request_id=#{pull_request_id} --version_number=0.28 --exit_with_unstaged_changes=#{exit_with_unstaged_changes} --with-console=false", exception: true)
expect(subject).to receive(:system).with("decidim-backporter --github_token=#{token} --pull_request_id=#{pull_request_id} --version_number=0.29 --exit_with_unstaged_changes=#{exit_with_unstaged_changes} --with-console=false", exception: true)

subject.call
end

it "skips the creation" do
allow(subject).to receive(:pull_request_metadata).and_return({ labels: ["type: fix", "release: v0.28", "release: v0.29"], is_merged: true })
allow(subject).to receive(:extract_versions).and_return(["0.28", "0.29"])
allow(subject).to receive(:related_issues).and_return([{title: "Backport 0.28"}, {title: "Backport 0.29"}])

expect(subject).to receive(:extract_backport_pull_request_for_version).with(kind_of(Array), "0.28").and_return({})
expect(subject).to receive(:extract_backport_pull_request_for_version).with(kind_of(Array), "0.29").and_return({})

subject.call
end

it "creates the ticket" do
allow(subject).to receive(:pull_request_metadata).and_return({ labels: ["type: fix", "release: v0.28", "release: v0.29"], is_merged: true })
allow(subject).to receive(:extract_versions).and_return(["0.28", "0.29"])
allow(subject).to receive(:related_issues).and_return([])
allow(subject).to receive(:extract_backport_pull_request_for_version).with(kind_of(Array), "0.28").and_return(nil)
allow(subject).to receive(:extract_backport_pull_request_for_version).with(kind_of(Array), "0.29").and_return(nil)

allow(subject).to receive(:system).and_raise(RuntimeError)

expect(subject).to receive(:create_backport_issue).with("0.28")
expect(subject).to receive(:create_backport_issue).with("0.29")

subject.call
end
end

describe ".extract_versions" do
it "returns the versions" do
allow(subject).to receive(:pull_request_metadata).and_return({ labels: ["type: fix", "release: v0.28", "release: v0.29"], is_merged: true })
expect(subject.send(:extract_versions)).to eq(["0.28", "0.29"])
expect(subject.send(:extract_versions).size).to eq(2)
end

it "returns empty array" do
allow(subject).to receive(:pull_request_metadata).and_return({ labels: ["type: fix", "team: documentation", "module: initiatives"], is_merged: true })
expect(subject.send(:extract_versions)).to eq([])
expect(subject.send(:extract_versions).size).to eq(0)
end
end

describe ".create_backport_task" do

before do
allow(subject).to receive(:pull_request_metadata).and_return({ title: "Foo Bar", labels: ["type: fix", "release: v0.28"]})
end

it "returns the respose from the server" do
expect(subject.send(:create_backport_issue, "0.29")).to be_a Faraday::Response
end
end

end
3 changes: 2 additions & 1 deletion spec/lib/decidim/maintainers_toolbox/backporter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
require "decidim/maintainers_toolbox/backporter"

describe Decidim::MaintainersToolbox::Backporter do
subject { described_class.new(token: token, pull_request_id: pull_request_id, version_number: version_number, exit_with_unstaged_changes: exit_with_unstaged_changes) }
subject { described_class.new(token: token, pull_request_id: pull_request_id, version_number: version_number, exit_with_unstaged_changes: exit_with_unstaged_changes, with_console: with_console) }

let(:token) { "1234" }
let(:pull_request_id) { 123 }
let(:version_number) { "0.1" }
let(:exit_with_unstaged_changes) { true }
let(:with_console) { true }

describe ".backport_branch" do
let(:pull_request_title) { "Hello world" }
Expand Down
Loading

0 comments on commit 3ddec41

Please sign in to comment.