diff --git a/exe/decidim-action-backporter b/exe/decidim-action-backporter new file mode 100755 index 0000000..068ae91 --- /dev/null +++ b/exe/decidim-action-backporter @@ -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) diff --git a/exe/decidim-backporter b/exe/decidim-backporter index 568f6a0..d868443 100755 --- a/exe/decidim-backporter +++ b/exe/decidim-backporter @@ -19,6 +19,9 @@ 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 @@ -26,7 +29,8 @@ class BackporterCLI < Thor 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." diff --git a/lib/decidim/maintainers_toolbox/action_backporter.rb b/lib/decidim/maintainers_toolbox/action_backporter.rb new file mode 100644 index 0000000..e7f063a --- /dev/null +++ b/lib/decidim/maintainers_toolbox/action_backporter.rb @@ -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 \ No newline at end of file diff --git a/lib/decidim/maintainers_toolbox/backporter.rb b/lib/decidim/maintainers_toolbox/backporter.rb index 8a39023..a1c4051 100644 --- a/lib/decidim/maintainers_toolbox/backporter.rb +++ b/lib/decidim/maintainers_toolbox/backporter.rb @@ -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: @@ -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 # @@ -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 diff --git a/lib/decidim/maintainers_toolbox/git_backport_manager.rb b/lib/decidim/maintainers_toolbox/git_backport_manager.rb index fb1f751..5547745 100644 --- a/lib/decidim/maintainers_toolbox/git_backport_manager.rb +++ b/lib/decidim/maintainers_toolbox/git_backport_manager.rb @@ -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 @@ -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 @@ -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 diff --git a/lib/decidim/maintainers_toolbox/github_manager/querier/by_issue_id.rb b/lib/decidim/maintainers_toolbox/github_manager/querier/by_issue_id.rb index 18bc675..b4313ff 100644 --- a/lib/decidim/maintainers_toolbox/github_manager/querier/by_issue_id.rb +++ b/lib/decidim/maintainers_toolbox/github_manager/querier/by_issue_id.rb @@ -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" }, diff --git a/spec/lib/decidim/maintainers_toolbox/action_backporter_spec.rb b/spec/lib/decidim/maintainers_toolbox/action_backporter_spec.rb new file mode 100644 index 0000000..2be8014 --- /dev/null +++ b/spec/lib/decidim/maintainers_toolbox/action_backporter_spec.rb @@ -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 diff --git a/spec/lib/decidim/maintainers_toolbox/backporter_spec.rb b/spec/lib/decidim/maintainers_toolbox/backporter_spec.rb index f747640..dfd78a5 100644 --- a/spec/lib/decidim/maintainers_toolbox/backporter_spec.rb +++ b/spec/lib/decidim/maintainers_toolbox/backporter_spec.rb @@ -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" } diff --git a/spec/lib/decidim/maintainers_toolbox/github_manager/querier/by_issue_id_spec.rb b/spec/lib/decidim/maintainers_toolbox/github_manager/querier/by_issue_id_spec.rb index cc39dfd..1ad2b2f 100644 --- a/spec/lib/decidim/maintainers_toolbox/github_manager/querier/by_issue_id_spec.rb +++ b/spec/lib/decidim/maintainers_toolbox/github_manager/querier/by_issue_id_spec.rb @@ -8,22 +8,91 @@ before do stub_request(:get, "https://api.github.com/repos/decidim/decidim/issues/12345") - .to_return(status: 200, body: '{"number": 12345, "title": "Fix whatever", "labels": [{"name": "type: fix"}, {"name": "module: admin"}]}', headers: {}) + .to_return(status: 200, body: body, headers: {}) end describe ".call" do - let(:response) do - { - labels: ["module: admin", "type: fix"], - modules: ["module: admin"], - type: ["type: fix"], - id: 12_345, - title: "Fix whatever" - } + context "when ticket is an issue" do + let(:body) { '{"number": 12345, "title": "Fix whatever", "labels": [{"name": "type: fix"}, {"name": "module: admin"}]}' } + let(:response) do + { + labels: ["module: admin", "type: fix"], + modules: ["module: admin"], + type: ["type: fix"], + id: 12_345, + state: nil, + is_merged: false, + is_pull_request: false, + title: "Fix whatever" + } + end + + it "returns a valid response" do + expect(querier.call).to eq response + end end - it "returns a valid response" do - expect(querier.call).to eq response + context "when issue is a PR" do + context "when merged" do + let(:body) { '{"number": 12345, "pull_request": {"merged_at": "2024-09-19T08:08:50Z" }, "title": "Fix whatever", "labels": [{"name": "type: fix"}, {"name": "module: admin"}]}' } + let(:response) do + { + labels: ["module: admin", "type: fix"], + modules: ["module: admin"], + type: ["type: fix"], + id: 12_345, + state: nil, + is_merged: true, + is_pull_request: true, + title: "Fix whatever" + } + end + + it "returns a valid response" do + expect(querier.call).to eq response + end + end + + context "when closed" do + let(:body) { '{"number": 12345, "pull_request": {"merged_at": "" }, "title": "Fix whatever", "labels": [{"name": "type: fix"}, {"name": "module: admin"}]}' } + let(:response) do + { + labels: ["module: admin", "type: fix"], + modules: ["module: admin"], + type: ["type: fix"], + id: 12_345, + state: nil, + is_merged: false, + is_pull_request: true, + title: "Fix whatever" + } + end + + it "returns a valid response" do + expect(querier.call).to eq response + end + end + + context "when active" do + let(:body) { '{"number": 12345, "pull_request": {"merged_at": "" }, "title": "Fix whatever", "labels": [{"name": "type: fix"}, {"name": "module: admin"}]}' } + let(:response) do + { + labels: ["module: admin", "type: fix"], + modules: ["module: admin"], + type: ["type: fix"], + id: 12_345, + state: nil, + is_merged: false, + is_pull_request: true, + title: "Fix whatever" + } + end + + it "returns a valid response" do + expect(querier.call).to eq response + end + end end + end end