decidim-maintainers_toolbox 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "github_manager/querier"
4
+
5
+ require "json"
6
+ require "ruby-progressbar"
7
+
8
+ module Decidim
9
+ module MaintainersToolbox
10
+ class ChangeLogGenerator
11
+ class InvalidMetadataError < StandardError; end
12
+
13
+ TYPES = {
14
+ "Added" => {
15
+ label: "type: feature",
16
+ skip_modules: false
17
+ },
18
+ "Changed" => {
19
+ label: "type: change",
20
+ skip_modules: false
21
+ },
22
+ "Fixed" => {
23
+ label: "type: fix",
24
+ skip_modules: false
25
+ },
26
+ "Removed" => {
27
+ label: "type: removal",
28
+ skip_modules: false
29
+ },
30
+ "Developer improvements" => {
31
+ label: "target: developer-experience",
32
+ skip_modules: true
33
+ },
34
+ "Internal" => {
35
+ label: "type: internal",
36
+ skip_modules: false
37
+ }
38
+ }.freeze
39
+
40
+ def initialize(token:, since_sha:)
41
+ @token = token
42
+ @since_sha = since_sha
43
+ @output_file = []
44
+ @handled_ids = []
45
+
46
+ @progress_bar = ProgressBar.create(title: "PRs", total: list_of_commits.count)
47
+ end
48
+
49
+ def call
50
+ raise InvalidMetadataError if token.nil?
51
+
52
+ TYPES.each do |type_title, type_data|
53
+ type_prs = prs.select do |_commit_title, data|
54
+ next unless data
55
+
56
+ data[:type].include?(type_data[:label])
57
+ end
58
+
59
+ output "### #{type_title}"
60
+ output ""
61
+
62
+ if type_prs.any?
63
+ type_prs.each do |_pr_title, data|
64
+ process_single_pr(data, type_data)
65
+ end
66
+ else
67
+ output "Nothing."
68
+ end
69
+
70
+ output ""
71
+ end
72
+
73
+ process_unsorted_prs
74
+ write_data_file!
75
+ end
76
+
77
+ private
78
+
79
+ def write_data_file!
80
+ File.write("temporary_changelog.md", @output_file.join("\n"))
81
+ puts "Written file: temporary_changelog.md"
82
+ end
83
+
84
+ def process_single_pr(data, type_data)
85
+ modules_list = data[:modules].map { |l| "**decidim-#{l.delete_prefix("module: ")}**" }
86
+ id = data[:id]
87
+ title = data[:title]
88
+
89
+ @handled_ids << id
90
+
91
+ if type_data[:skip_modules] || modules_list.empty?
92
+ output "- #{title} #{pr_link(id)}"
93
+ else
94
+ output "- #{modules_list.join(", ")}: #{title} #{pr_link(id)}"
95
+ end
96
+ end
97
+
98
+ def process_unsorted_prs
99
+ return unless unsorted_prs.any?
100
+
101
+ output "### Unsorted"
102
+ output ""
103
+
104
+ unsorted_prs.map do |title, data|
105
+ pr_data = data || {}
106
+ output "- #{title} #{pr_link(pr_data[:id])} || #{data}"
107
+ end
108
+ end
109
+
110
+ def unsorted_prs
111
+ @unsorted_prs ||= prs.reject do |_commit_title, data|
112
+ pr_data = data || {}
113
+
114
+ @handled_ids.include?(pr_data[:id])
115
+ end
116
+ end
117
+
118
+ attr_reader :token, :since_sha
119
+
120
+ def prs
121
+ @prs ||= list_of_commits.inject({}) do |acc, commit|
122
+ next acc if crowdin?(commit)
123
+
124
+ acc.update(commit => get_pr_data(commit))
125
+ end
126
+ end
127
+
128
+ def list_of_commits
129
+ @list_of_commits ||= `git log #{since_sha}..HEAD --oneline`.split("\n").reverse
130
+ end
131
+
132
+ def crowdin?(commit)
133
+ !commit.match(/New Crowdin updates/).nil?
134
+ end
135
+
136
+ def get_pr_id(commit)
137
+ id = commit.scan(/#\d+/).last
138
+ return unless id
139
+
140
+ id.delete_prefix("#")
141
+ end
142
+
143
+ def get_pr_data(commit)
144
+ @progress_bar.increment
145
+
146
+ id = get_pr_id(commit)
147
+ return nil unless id
148
+
149
+ Decidim::MaintainersToolbox::GithubManager::Querier::ByIssueId.new(
150
+ token: token,
151
+ issue_id: id
152
+ ).call
153
+ end
154
+
155
+ def pr_link(id)
156
+ # We need to do this so that it generates the expected Markdown format.
157
+ # String interpolation messes with the format.
158
+ "[#{"\\#" + id.to_s}](https://github.com/decidim/decidim/pull/#{id})" # rubocop:disable Style/StringConcatenation
159
+ end
160
+
161
+ def output(str)
162
+ @output_file << str
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby-progressbar"
4
+
5
+ require_relative "github_manager/querier/by_label"
6
+ require_relative "github_manager/querier/related_issues"
7
+ require_relative "backports_reporter/csv_report"
8
+ require_relative "backports_reporter/cli_report"
9
+
10
+ module Decidim
11
+ module MaintainersToolbox
12
+ # Extracts the status of the Pull Requests on the decidim repository
13
+ # with the label "type: fix" and shows the status of the related Pull Requests,
14
+ # so we can check which PRs have pending backports
15
+ class GitBackportChecker
16
+ # @param token [String] token for GitHub authentication
17
+ # @param days_to_check_from [Integer] the number of days since the pull requests were merged from when we will start the check
18
+ # @param last_version_number [String] the version number of the last release that we want to make the backport to
19
+ def initialize(token:, days_to_check_from:, last_version_number:)
20
+ @token = token
21
+ @days_to_check_from = days_to_check_from
22
+ @last_version_number = last_version_number
23
+ end
24
+
25
+ def call
26
+ pull_requests_with_labels = by_label(
27
+ label: "type: fix",
28
+ exclude_labels: ["backport", "no-backport"],
29
+ days_to_check_from: @days_to_check_from
30
+ )
31
+
32
+ progress_bar = ProgressBar.create(title: "PRs", total: pull_requests_with_labels.count)
33
+ @report = []
34
+
35
+ pull_requests_with_labels.each do |pull_request|
36
+ progress_bar.increment
37
+
38
+ @report << {
39
+ id: pull_request[:id],
40
+ title: pull_request[:title],
41
+ related_issues: related_issues(pull_request[:id])
42
+ }
43
+ end
44
+ end
45
+
46
+ def csv_report
47
+ Decidim::MaintainersToolbox::BackportsReporter::CSVReport.new(
48
+ report: @report,
49
+ last_version_number: @last_version_number
50
+ ).call
51
+ end
52
+
53
+ def cli_report
54
+ Decidim::MaintainersToolbox::BackportsReporter::CLIReport.new(
55
+ report: @report,
56
+ last_version_number: @last_version_number
57
+ ).call
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :token
63
+
64
+ def by_label(label:, exclude_labels:, days_to_check_from:)
65
+ Decidim::MaintainersToolbox::GithubManager::Querier::ByLabel.new(
66
+ token: token,
67
+ label: label,
68
+ exclude_labels: exclude_labels,
69
+ days_to_check_from: days_to_check_from
70
+ ).call
71
+ end
72
+
73
+ def related_issues(issue_id)
74
+ Decidim::MaintainersToolbox::GithubManager::Querier::RelatedIssues.new(
75
+ token: token,
76
+ issue_id: issue_id
77
+ ).call
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "English"
5
+
6
+ module Decidim
7
+ module MaintainersToolbox
8
+ # Handles the backport of a given pull request to a branch
9
+ # Uses the git commnad line client
10
+ # rubocop:disable Rails/Output
11
+ class GitBackportManager
12
+ # @param pull_request_id [String] the ID of the pull request that we want to backport
13
+ # @param release_branch [String] the name of the branch that we want to backport to
14
+ # @param backport_branch [String] the name of the branch that we want to create
15
+ # @param working_dir [String] current working directory. Useful for testing purposes
16
+ # @param exit_with_unstaged_changes [Boolean] wheter we should exit cowardly if there is any unstaged change
17
+ def initialize(pull_request_id:, release_branch:, backport_branch:, working_dir: Dir.pwd, exit_with_unstaged_changes: false)
18
+ @pull_request_id = pull_request_id
19
+ @release_branch = release_branch
20
+ @backport_branch = sanitize_branch(backport_branch)
21
+ @working_dir = working_dir
22
+ @exit_with_unstaged_changes = exit_with_unstaged_changes
23
+ end
24
+
25
+ # Handles all the different tasks involved on a backport with the git command line utility
26
+ # It does the following tasks:
27
+ # * Creates a branch based on a release branch
28
+ # * Apply a commit to this branch
29
+ # * Push it to the remote repository
30
+ #
31
+ # @return [void]
32
+ def call
33
+ Dir.chdir(working_dir) do
34
+ exit_if_unstaged_changes if @exit_with_unstaged_changes
35
+ self.class.checkout_develop
36
+ sha_commit = sha_commit_to_backport
37
+
38
+ error_message = <<-EOERROR
39
+ Could not find commit for pull request #{pull_request_id}.
40
+ Please make sure you have pulled the latest changes.
41
+ EOERROR
42
+ exit_with_errors(error_message) unless sha_commit
43
+
44
+ create_backport_branch!
45
+ cherrypick_commit!(sha_commit)
46
+ clean_commit_message!
47
+ push_backport_branch!
48
+ end
49
+ end
50
+
51
+ # Switch to the develop branch
52
+ # In case that it cannot do that, exits
53
+ #
54
+ # @return [void]
55
+ def self.checkout_develop
56
+ `git checkout develop`
57
+
58
+ error_message = <<-EOERROR
59
+ Could not checkout the develop branch.
60
+ Please make sure you do not have any uncommitted changes in the current branch.
61
+ EOERROR
62
+ exit_with_errors(error_message) unless $CHILD_STATUS.exitstatus.zero?
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :pull_request_id, :release_branch, :backport_branch, :working_dir
68
+
69
+ # Create the backport branch based on a release branch
70
+ # Checks that this branch does not exist already, if it does then exits
71
+ #
72
+ # @return [void]
73
+ def create_backport_branch!
74
+ `git checkout #{release_branch}`
75
+
76
+ diff_count = `git rev-list HEAD..#{remote}/#{release_branch} --count`.strip.to_i
77
+ `git pull #{remote} #{release_branch}` if diff_count.positive?
78
+ `git checkout -b #{backport_branch}`
79
+
80
+ error_message = <<-EOERROR
81
+ Branch already exists locally.
82
+ Delete it with 'git branch -D #{backport_branch}' and rerun the script.
83
+ EOERROR
84
+ exit_with_errors(error_message) unless $CHILD_STATUS.exitstatus.zero?
85
+ end
86
+
87
+ # Cherrypick a commit from another branch
88
+ # Apply the changes introduced by some existing commits
89
+ # Drops to a shell in case that it needs a manual conflict resolution
90
+ #
91
+ # @return [void]
92
+ def cherrypick_commit!(sha_commit)
93
+ return unless sha_commit
94
+
95
+ puts "Cherrypicking commit #{sha_commit}"
96
+ `git cherry-pick #{sha_commit}`
97
+
98
+ unless $CHILD_STATUS.exitstatus.zero?
99
+ puts "Resolve the cherrypick conflict manually and exit your shell to keep with the process."
100
+ system ENV.fetch("SHELL")
101
+ end
102
+ end
103
+
104
+ # Clean the commit message to remove the pull request ID of the last commit
105
+ # This is mostly cosmetic, but if we don´t do this, we will have the two IDs on the final commit:
106
+ # the ID of the original PR and the id of the backported PR.
107
+ #
108
+ # @return [void]
109
+ def clean_commit_message!
110
+ message = `git log --pretty=format:"%B" -1`
111
+ message = message.lines[0].gsub!(/ \(#[0-9]+\)$/, "").concat(*message.lines[1..-1])
112
+ message.gsub!('"', '"\""') # Escape the double quotes for bash as they are the message delimiters
113
+
114
+ `git commit --amend -m "#{message}"`
115
+ end
116
+
117
+ # Push the branch to a git remote repository
118
+ # Checks that there is actually something to push first, if not then it exits.
119
+ #
120
+ # @return [void]
121
+ def push_backport_branch!
122
+ if `git diff #{backport_branch}..#{release_branch}`.empty?
123
+ self.class.checkout_develop
124
+
125
+ error_message = <<-EOERROR
126
+ Nothing to push to remote server.
127
+ It was probably merged already or the cherry-pick was aborted.
128
+ EOERROR
129
+ exit_with_errors(error_message)
130
+ else
131
+ puts "Pushing branch #{backport_branch} to #{remote}"
132
+ `git push #{remote} #{backport_branch}`
133
+ end
134
+ end
135
+
136
+ # The name of the git remote repository in the local git repository configuration
137
+ # Most of the times this would be origin or upstream.
138
+ #
139
+ # @return [String] the name of the git repository
140
+ def remote
141
+ `git remote -v | grep -e 'decidim/decidim\\([^ ]*\\) (push)' | sed 's/\\s.*//'`.strip
142
+ end
143
+
144
+ # The SHA1 commit to backport
145
+ # It needs to have a pull_request_id associated in the commit message
146
+ #
147
+ # @return [String] the SHA1 commit
148
+ def sha_commit_to_backport
149
+ `git log --format=oneline | grep "(##{pull_request_id})"`.split.first
150
+ end
151
+
152
+ # Replace all the characters from the user supplied input that are uncontrolled
153
+ # and could generate a command line injection
154
+ #
155
+ # @return [String] the sanitized branch name
156
+ def sanitize_branch(branch_name)
157
+ Shellwords.escape(branch_name.gsub("`", ""))
158
+ end
159
+
160
+ # Exit the script execution if there are any unstaged changes
161
+ #
162
+ # @return [void]
163
+ def exit_if_unstaged_changes
164
+ return if `git diff`.empty?
165
+
166
+ error_message = <<-EOERROR
167
+ There are changes not staged in your project.
168
+ Please commit your changes or stash them.
169
+ EOERROR
170
+ exit_with_errors(error_message)
171
+ end
172
+
173
+ # Exit the script execution with a message
174
+ #
175
+ # @return [void]
176
+ def exit_with_errors(message)
177
+ puts message
178
+ exit 1
179
+ end
180
+ end
181
+
182
+ # rubocop:enable Rails/Output
183
+ end
184
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/except"
4
+ require "faraday"
5
+ require "json"
6
+
7
+ module Decidim
8
+ module MaintainersToolbox
9
+ module GithubManager
10
+ # Allows to make POST requests to GitHub Rest API about Pull Requests
11
+ # @see https://docs.github.com/en/rest
12
+ # rubocop:disable Rails/Output
13
+ class Poster
14
+ # @param token [String] token for GitHub authentication
15
+ # @param params [Hash] Parameters accepted by the GitHub API
16
+ def initialize(token:, params:)
17
+ @token = token
18
+ @params = params
19
+ end
20
+
21
+ # Create the pull request or give error messages
22
+ #
23
+ # @return [Faraday::Response] An instance that represents an HTTP response from making an HTTP request
24
+ def call
25
+ response = create_pull_request!
26
+ pull_request_id = JSON.parse(response.body)["number"]
27
+ unless pull_request_id
28
+ puts "Pull request could not be created!"
29
+ puts "Please make sure you have enabled the 'public_repo' scope for the access token"
30
+ return
31
+ end
32
+ puts "Pull request created at https://github.com/decidim/decidim/pull/#{pull_request_id}"
33
+
34
+ add_labels_to_issue!(pull_request_id)
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :token, :params
40
+
41
+ # Make the POST request to GitHub API of the decidim repository
42
+ #
43
+ # @param path [String] The path to do the request to the GitHub API
44
+ # @param some_params [Hash] The parameters of the request
45
+ # @return [Faraday::Response] An instance that represents an HTTP response from making an HTTP request
46
+ def post!(path, some_params)
47
+ uri = "https://api.github.com/repos/decidim/decidim/#{path}"
48
+ Faraday.post(uri, some_params.to_json, { Authorization: "token #{token}" })
49
+ end
50
+
51
+ # Create a pull request using the GitHub API
52
+ #
53
+ # @see https://docs.github.com/en/rest/pulls/pulls#create-a-pull-request GitHub API documentation
54
+ # @return [Faraday::Response] An instance that represents an HTTP response from making an HTTP request
55
+ def create_pull_request!
56
+ puts "Creating the PR in GitHub"
57
+ post!("pulls", params.except(:labels))
58
+ end
59
+
60
+ # Add labels to an issue or a pull request
61
+ # GitHub does not support adding labels while creating the PR, so we need to do it afterwards
62
+ #
63
+ # @see https://docs.github.com/en/rest/issues/labels#add-labels-to-an-issue GitHub API documentation
64
+ # @param issue_id [String] String of the issue to add the labels to
65
+ # @return [Faraday::Response] An instance that represents an HTTP response from making an HTTP request
66
+ def add_labels_to_issue!(issue_id)
67
+ puts "Adding the labels to the PR"
68
+ post!("issues/#{issue_id}/labels", params.slice(:labels))
69
+ end
70
+ end
71
+
72
+ # rubocop:enable Rails/Output
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+ require "uri"
6
+
7
+ module Decidim
8
+ module MaintainersToolbox
9
+ module GithubManager
10
+ module Querier
11
+ # Base class that allows making GET requests to GitHub Rest API about Issues and Pull Requests
12
+ # This must be inherited from other class with the following methods:
13
+ # - call
14
+ # - uri
15
+ # @see https://docs.github.com/en/rest
16
+ class Base
17
+ class InvalidMetadataError < StandardError; end
18
+
19
+ def initialize(token:)
20
+ @token = token
21
+ end
22
+
23
+ def call
24
+ raise "Not implemented"
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :token
30
+
31
+ def headers
32
+ nil
33
+ end
34
+
35
+ def authorization_header
36
+ { Authorization: "token #{token}" }
37
+ end
38
+
39
+ def request(uri)
40
+ response = Faraday.get(uri, headers, authorization_header)
41
+
42
+ { body: response.body, headers: response.headers }
43
+ end
44
+
45
+ # Get's the JSON response from a URI
46
+ # Supports pagination
47
+ #
48
+ # @param uri {String} - The URL that we want to get the JSON response from
49
+ # @param old_json {Array} - The Array with the old_json or an empty Array if it is the first time that we are calling this method
50
+ def json_response(uri, old_json = [])
51
+ body, headers = request(uri).values_at(:body, :headers)
52
+ json = JSON.parse(body)
53
+ json.concat(old_json) if json.is_a?(Array)
54
+ raise InvalidMetadataError if json.is_a?(Hash) && json["message"] == "Bad credentials"
55
+
56
+ # If there are more pages, then we call ourselves redundantly to fetch the next page
57
+ next_json = more_pages?(headers) ? json_response(next_page(headers), json) : []
58
+
59
+ if json.is_a?(Array)
60
+ # For some reason we have duplicated values, so we deduplicate them
61
+ json.concat(next_json).uniq { |issue| issue.has_key?("number") ? issue["number"] : issue }
62
+ else
63
+ json
64
+ end
65
+ end
66
+
67
+ def more_pages?(headers)
68
+ return false if headers["link"].nil?
69
+
70
+ headers["link"].include?('rel="next"')
71
+ end
72
+
73
+ def next_page(headers)
74
+ URI.extract(headers["link"].split(",").select { |url| url.end_with?('rel="next"') }[0])[0]
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Decidim
6
+ module MaintainersToolbox
7
+ module GithubManager
8
+ module Querier
9
+ # Makes a GET request for the metadata of an Issue or Pull Request in GitHub
10
+ #
11
+ # @see https://docs.github.com/en/rest/issues/issues#get-an-issue GitHub API documentation
12
+ class ByIssueId < Decidim::MaintainersToolbox::GithubManager::Querier::Base
13
+ def initialize(issue_id:, token:)
14
+ @issue_id = issue_id
15
+ @token = token
16
+ end
17
+
18
+ # Makes the GET request and parses the response of an Issue or Pull Request in GitHub
19
+ #
20
+ # @return [Hash]
21
+ def call
22
+ data = json_response("https://api.github.com/repos/decidim/decidim/issues/#{@issue_id}")
23
+ return unless data["number"]
24
+
25
+ parse(data)
26
+ end
27
+
28
+ private
29
+
30
+ # Parses the response of an Issue or Pull Request in GitHub
31
+ #
32
+ # @return [Hash]
33
+ def parse(metadata)
34
+ labels = metadata["labels"].map { |l| l["name"] }.sort
35
+
36
+ {
37
+ id: metadata["number"],
38
+ title: metadata["title"],
39
+ labels: labels,
40
+ type: labels.select { |l| l.match(/^type: /) || l == "target: developer-experience" },
41
+ modules: labels.select { |l| l.match(/^module: /) }
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end