decidim-maintainers_toolbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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