github-csv-changelog 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/github-csv-changelog +5 -0
- data/lib/github-csv-changelog.rb +276 -0
- metadata +49 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 7254318b75a817eec6afaeb3be41d56fb71597b4
|
4
|
+
data.tar.gz: 6c374760e7d196abc5b11e3b175876f49d9dba35
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 34800daede9e54f57809598c2420416a19dd8a416b323f28e01712f42f4360031915f76d3d2c4ca07dcf12df81268456c2bada7c1090598db210413a31da6de1
|
7
|
+
data.tar.gz: 4213fefe64dd17ea42e22a8c2991a78f3a10aa65cce38359503aac29edc25d481ea7d23dfd0cac2472072a94f3f426d8ae6a7257d151d0bc16ef9bc9b255629d
|
@@ -0,0 +1,276 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'net/https'
|
3
|
+
require 'uri'
|
4
|
+
require 'json'
|
5
|
+
require 'csv'
|
6
|
+
require 'io/console'
|
7
|
+
require 'optparse'
|
8
|
+
|
9
|
+
# /// Constants. ///
|
10
|
+
|
11
|
+
GITHUB_API_NOTE = "Github commit parser."
|
12
|
+
COMMIT_PARSER_API_TOKEN_ENV_KEY = "COMMIT_PARSER_API_TOKEN"
|
13
|
+
|
14
|
+
# /// Helper functions for network requests. ///
|
15
|
+
|
16
|
+
# Makes POST request with default SSL usage.
|
17
|
+
def post(url, username, password, body_JSON)
|
18
|
+
uri = URI.parse(url)
|
19
|
+
request = Net::HTTP::Post.new(uri)
|
20
|
+
request.basic_auth username, password
|
21
|
+
request.body = JSON.dump(body_JSON)
|
22
|
+
req_options = {
|
23
|
+
use_ssl: uri.scheme == "https",
|
24
|
+
}
|
25
|
+
response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
|
26
|
+
http.request(request)
|
27
|
+
end
|
28
|
+
return response
|
29
|
+
end
|
30
|
+
|
31
|
+
# Makes GET request for Github API request.
|
32
|
+
def get(url, access_token)
|
33
|
+
uri = URI.parse(url)
|
34
|
+
request = Net::HTTP::Get.new(uri)
|
35
|
+
request["Authorization"] = "token #{access_token}"
|
36
|
+
|
37
|
+
req_options = {
|
38
|
+
use_ssl: uri.scheme == "https",
|
39
|
+
}
|
40
|
+
|
41
|
+
response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
|
42
|
+
http.request(request)
|
43
|
+
end
|
44
|
+
|
45
|
+
return response
|
46
|
+
end
|
47
|
+
|
48
|
+
# /// Network requests. ///
|
49
|
+
|
50
|
+
# Pings for 2-factor one-time password.
|
51
|
+
def request_2factor_passcode(username, password)
|
52
|
+
url = 'https://api.github.com/authorizations'
|
53
|
+
body_JSON = JSON.dump({
|
54
|
+
"scopes" => [
|
55
|
+
"repo",
|
56
|
+
"user"
|
57
|
+
],
|
58
|
+
"note" => GITHUB_API_NOTE
|
59
|
+
})
|
60
|
+
response = post(url, username, password, body_JSON)
|
61
|
+
return !response['x-github-otp'].nil? && response.code === "401"
|
62
|
+
end
|
63
|
+
|
64
|
+
# Gets access token with one-time password for 2 factor authentication.
|
65
|
+
# This does not call `post` helper due to its special handling for one-time password (`otp`).
|
66
|
+
def get_token_with_2factor_otp(username, password, otp)
|
67
|
+
uri = URI.parse("https://api.github.com/authorizations")
|
68
|
+
body_JSON = JSON.dump({
|
69
|
+
"scopes" => [
|
70
|
+
"repo",
|
71
|
+
"user"
|
72
|
+
],
|
73
|
+
"note" => GITHUB_API_NOTE
|
74
|
+
})
|
75
|
+
|
76
|
+
req_options = {
|
77
|
+
use_ssl: uri.scheme == "https",
|
78
|
+
}
|
79
|
+
|
80
|
+
request = Net::HTTP::Post.new(uri)
|
81
|
+
request["X-Github-Otp"] = otp
|
82
|
+
request.basic_auth username, password
|
83
|
+
request.body = body_JSON
|
84
|
+
response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
|
85
|
+
http.request(request)
|
86
|
+
end
|
87
|
+
body = parse_http_response(response)
|
88
|
+
return body['token']
|
89
|
+
end
|
90
|
+
|
91
|
+
# Fetches commits between two branches.
|
92
|
+
def get_commits_between_two_branches(repo_owner, repo, branch_1, branch_2, access_token)
|
93
|
+
url = "https://api.github.com/repos/#{repo_owner}/#{repo}/compare/#{branch_1}...#{branch_2}"
|
94
|
+
response = get(url, access_token)
|
95
|
+
body = parse_http_response(response)
|
96
|
+
return body['commits']
|
97
|
+
end
|
98
|
+
|
99
|
+
# Fetches pull request and returns text content of pull request description.
|
100
|
+
def get_pull_request(pull_request_number, repo_owner, repo, access_token)
|
101
|
+
url = "https://api.github.com/repos/#{repo_owner}/#{repo}/pulls/#{pull_request_number}"
|
102
|
+
response = get(url, access_token)
|
103
|
+
pull_request_body = parse_http_response(response)['body']
|
104
|
+
return pull_request_body
|
105
|
+
end
|
106
|
+
|
107
|
+
# Extracts text from a Github pull request given regex.
|
108
|
+
def extract_text_from_pull_request_number(regex, pull_request_body)
|
109
|
+
match_results = pull_request_body.scan(regex)
|
110
|
+
if !match_results.nil? && match_results.length > 0
|
111
|
+
text = match_results[0][0]
|
112
|
+
puts text
|
113
|
+
return text.gsub!(/^[\r\n]*/, '').gsub!(/[\r\n\-]*$/, '')
|
114
|
+
else
|
115
|
+
return ""
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Exports commits with pull request information to CSV.
|
120
|
+
# pull_request_regex_by_field: optional hash from CSV field name to pull request description regex.
|
121
|
+
def export_commits_to_CSV(commits, repo_owner, repo, access_token, pull_request_regex_by_field, export_CSV_path)
|
122
|
+
pull_request_fields = pull_request_regex_by_field.nil? ? [] : pull_request_regex_by_field.keys
|
123
|
+
CSV.open(export_CSV_path, "wb") do |csv|
|
124
|
+
fields = ["Author", "Date", "Commit message"] + pull_request_fields + ["Pull request url", "Commit url", "SHA"]
|
125
|
+
csv << fields
|
126
|
+
commits.each do |commit|
|
127
|
+
author = commit['commit']['author']['name']
|
128
|
+
date = commit['commit']['author']['date']
|
129
|
+
commit_message = commit['commit']['message'].split("\n").first
|
130
|
+
commit_url = commit['html_url']
|
131
|
+
sha = commit['sha']
|
132
|
+
|
133
|
+
values = [author, date, commit_message]
|
134
|
+
|
135
|
+
# Extracts pull request number.
|
136
|
+
match_results = commit_message.scan(/\(#([0-9]+)\)\n?$/)
|
137
|
+
if !match_results.nil? && match_results.length > 0
|
138
|
+
pull_request_number = match_results[0][0]
|
139
|
+
pull_request_url = "https://github.com/#{repo_owner}/#{repo}/pull/#{pull_request_number}"
|
140
|
+
|
141
|
+
pull_request_body = get_pull_request(pull_request_number, repo_owner, repo, access_token)
|
142
|
+
puts pull_request_body
|
143
|
+
|
144
|
+
pull_request_fields.each do |field|
|
145
|
+
pull_request_regex = pull_request_regex_by_field[field]
|
146
|
+
regex = Regexp.new(pull_request_regex, Regexp::MULTILINE)
|
147
|
+
text = extract_text_from_pull_request_number(regex, pull_request_body)
|
148
|
+
values = values + [text]
|
149
|
+
end
|
150
|
+
# Pull request url.
|
151
|
+
values = values + [pull_request_url]
|
152
|
+
else
|
153
|
+
# No pull request can be deduced from commit message.
|
154
|
+
pull_request_fields.each do |_|
|
155
|
+
values = values + ['n/a']
|
156
|
+
end
|
157
|
+
# Pull request url.
|
158
|
+
values = values + ['n/a']
|
159
|
+
end
|
160
|
+
values = values + [commit_url, sha]
|
161
|
+
csv << values
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Parses http response as JSON and returns body field.
|
167
|
+
def parse_http_response(response)
|
168
|
+
puts JSON.parse(response.body)
|
169
|
+
raise "Network error: #{response.code}" unless response.code == "200"
|
170
|
+
body = JSON.parse(response.body)
|
171
|
+
return body
|
172
|
+
end
|
173
|
+
|
174
|
+
# Prompts for user input.
|
175
|
+
def prompt(*args)
|
176
|
+
print(*args)
|
177
|
+
gets.chomp
|
178
|
+
end
|
179
|
+
|
180
|
+
# Prompts for user input on sensitive information (e.g. password, access token).
|
181
|
+
def prompt_sensitive_info(*args)
|
182
|
+
print(*args)
|
183
|
+
STDIN.noecho(&:gets).chomp
|
184
|
+
end
|
185
|
+
|
186
|
+
# Reads value from environment variable given key.
|
187
|
+
def get_token_from_environment_variable(key)
|
188
|
+
return ENV[key]
|
189
|
+
end
|
190
|
+
|
191
|
+
# /// Beginning of script. ///
|
192
|
+
def main
|
193
|
+
options = {}
|
194
|
+
OptionParser.new do |opt|
|
195
|
+
opt.on('--api_token TOKEN') { |o| options[:access_token] = o }
|
196
|
+
opt.on('--repo_owner REPO_OWNER') { |o| options[:repo_owner] = o }
|
197
|
+
opt.on('--repo REPO') { |o| options[:repo] = o }
|
198
|
+
opt.on('--export_CSV_path EXPORT_CSV_PATH') { |o| options[:export_CSV_path] = o }
|
199
|
+
opt.on('--branch_1 BRANCH_1') { |o| options[:branch_1] = o }
|
200
|
+
opt.on('--branch_2 BRANCH_2') { |o| options[:branch_2] = o }
|
201
|
+
opt.on('--pull_request_regex_by_field PR_REGEX_BY_FIELD') { |o| options[:pull_request_regex_by_field] = o }
|
202
|
+
end.parse!
|
203
|
+
|
204
|
+
# Reads from command options.
|
205
|
+
access_token = options[:access_token]
|
206
|
+
repo_owner = options[:repo_owner]
|
207
|
+
repo = options[:repo]
|
208
|
+
export_CSV_path = options[:export_CSV_path]
|
209
|
+
branch_1 = options[:branch_1]
|
210
|
+
branch_2 = options[:branch_2]
|
211
|
+
if !options[:pull_request_regex_by_field].nil?
|
212
|
+
pull_request_regex_by_field = JSON.parse(options[:pull_request_regex_by_field])
|
213
|
+
end
|
214
|
+
|
215
|
+
# If user did not provide access token via command options, try a few things in order:
|
216
|
+
# 1) try reading from environment variable if user chose to set it before.
|
217
|
+
# 2) user probably calls script for the first time. In this case, start asking for basic
|
218
|
+
# auth (username, password), and requesting One-Time Password (OTP) for 2-factor auth.
|
219
|
+
# After a GitHub API token is generated, prompt user to save token safely:
|
220
|
+
# - save to 1Password and provide token via command options `--api_token=#{token}`
|
221
|
+
# - provide instruction to save token to local environment variable to `COMMIT_PARSER_API_TOKEN_ENV_KEY`
|
222
|
+
|
223
|
+
# 1) Tries reading environment variable.
|
224
|
+
if access_token.nil? || access_token.empty?
|
225
|
+
access_token = get_token_from_environment_variable(COMMIT_PARSER_API_TOKEN_ENV_KEY)
|
226
|
+
end
|
227
|
+
|
228
|
+
# 2) Starts requesting for API token.
|
229
|
+
if access_token.nil? || access_token.empty?
|
230
|
+
username = prompt "Your GitHub username: "
|
231
|
+
password = prompt_sensitive_info "Your GitHub password: "
|
232
|
+
print "\n"
|
233
|
+
otp_requested = request_2factor_passcode(username, password)
|
234
|
+
if otp_requested
|
235
|
+
one_time_passcode = prompt "Your GitHub One-Time Password: "
|
236
|
+
access_token = get_token_with_2factor_otp(username, password, one_time_passcode)
|
237
|
+
if !(access_token.nil? || access_token.empty?)
|
238
|
+
puts "🔑 Token fetched! Your token is: #{access_token}"
|
239
|
+
end
|
240
|
+
if access_token.nil? || access_token.empty?
|
241
|
+
puts "Please check for access token for entry with '#{GITHUB_API_NOTE}' at https://github.com/settings/tokens and regenerate access token if already exists."
|
242
|
+
access_token = prompt_sensitive_info "Your personal token for GithubCommitParser: "
|
243
|
+
end
|
244
|
+
if !(access_token.nil? || access_token.empty?)
|
245
|
+
puts "This is like a password and please save it safely like in 1Password for future access to Github API."
|
246
|
+
puts "Next time running this, you can provide this token via --api_token option, or you can save it to your environment variable via command line by running:"
|
247
|
+
puts "export COMMIT_PARSER_API_TOKEN=#{access_token}"
|
248
|
+
puts "(If using zsh, add this export to the zshrc file.)"
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
if access_token.nil? || access_token.empty?
|
254
|
+
abort("Sorry, we need a token to proceed. Please try again.")
|
255
|
+
end
|
256
|
+
|
257
|
+
# Asks user for export path, repo owner, repo, and branches info if not provided in options.
|
258
|
+
if repo_owner.nil? || repo_owner.empty?
|
259
|
+
repo_owner = prompt "Repo owner (repository url is 'repo_owner/repo'): "
|
260
|
+
end
|
261
|
+
if repo.nil? || repo.empty?
|
262
|
+
repo = prompt "Repo (repository url is 'repo_owner/repo'): "
|
263
|
+
end
|
264
|
+
if export_CSV_path.nil? || export_CSV_path.empty?
|
265
|
+
export_CSV_path = prompt "Path to export CSV (e.g. ~/Desktop): "
|
266
|
+
end
|
267
|
+
if branch_1.nil? || branch_1.empty?
|
268
|
+
branch_1 = prompt "From branch: "
|
269
|
+
end
|
270
|
+
if branch_2.nil? || branch_2.empty?
|
271
|
+
branch_2 = prompt "To branch: "
|
272
|
+
end
|
273
|
+
|
274
|
+
commits = get_commits_between_two_branches(repo_owner, repo, branch_1, branch_2, access_token)
|
275
|
+
export_commits_to_CSV(commits, repo_owner, repo, access_token, pull_request_regex_by_field, export_CSV_path)
|
276
|
+
end
|
metadata
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: github-csv-changelog
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jaclyn Chen
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-04-05 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Most useful for squash merge commits on Github - this gem links pull
|
14
|
+
request from each commit between two branches, and extracts information from pull
|
15
|
+
request.
|
16
|
+
email: jaclyn.y.chen+ruby@gmail.com
|
17
|
+
executables:
|
18
|
+
- github-csv-changelog
|
19
|
+
extensions: []
|
20
|
+
extra_rdoc_files: []
|
21
|
+
files:
|
22
|
+
- bin/github-csv-changelog
|
23
|
+
- lib/github-csv-changelog.rb
|
24
|
+
homepage: https://github.com/jaclync/github-csv-changelog
|
25
|
+
licenses:
|
26
|
+
- MIT
|
27
|
+
metadata: {}
|
28
|
+
post_install_message:
|
29
|
+
rdoc_options: []
|
30
|
+
require_paths:
|
31
|
+
- lib
|
32
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
33
|
+
requirements:
|
34
|
+
- - ">="
|
35
|
+
- !ruby/object:Gem::Version
|
36
|
+
version: '0'
|
37
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
38
|
+
requirements:
|
39
|
+
- - ">="
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
version: '0'
|
42
|
+
requirements: []
|
43
|
+
rubyforge_project:
|
44
|
+
rubygems_version: 2.6.10
|
45
|
+
signing_key:
|
46
|
+
specification_version: 4
|
47
|
+
summary: Parses Github commits and exports to CSV.
|
48
|
+
test_files: []
|
49
|
+
has_rdoc:
|