dev_metrics 0.1.2 → 0.2.5
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.
- checksums.yaml +4 -4
- data/bin/dev_metrics +6 -0
- data/lib/dev_metrics/client.rb +78 -116
- data/lib/dev_metrics/csv.rb +27 -0
- data/lib/dev_metrics/format_base.rb +35 -0
- data/lib/dev_metrics/json.rb +28 -0
- data/lib/dev_metrics/markdown.rb +18 -16
- data/lib/dev_metrics/metrics_calc.rb +218 -0
- data/lib/dev_metrics/pull_request_wrapper.rb +112 -0
- data/lib/dev_metrics/query_builder.rb +63 -44
- data/lib/dev_metrics/version.rb +3 -0
- data/lib/dev_metrics.rb +100 -29
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34f8fdcb4bcfa8353303ddc44a30dc227df6ecbf92d87b1f967c927d56ba2480
|
|
4
|
+
data.tar.gz: e77b9e47b4bd91487c0a4deea93df599cd1b31f778d4bcba32b8de8448d18b9b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b3cb0b8caec22116264a81ef34b3d62ca1c616852dc45eece9cfeead345f71901be7f91d3b87cc1d69cf66b30118fe37e90b4c82f9c6d95baf8128375fc1f9d
|
|
7
|
+
data.tar.gz: 0b0f53174f2f572538cac10c90c607c65fbdb421a1bac95ebf5839541ef31f218a0a955ccee0788a5e35cc7eb47755ff79ef4bbaa789b7b78f31d2649b591f63
|
data/bin/dev_metrics
ADDED
data/lib/dev_metrics/client.rb
CHANGED
|
@@ -1,116 +1,78 @@
|
|
|
1
|
-
require 'net/http'
|
|
2
|
-
require 'uri'
|
|
3
|
-
require 'date'
|
|
4
|
-
require 'json'
|
|
5
|
-
require 'time'
|
|
6
|
-
require_relative 'query_builder'
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@
|
|
17
|
-
@
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def calculate_lead_time(prs)
|
|
81
|
-
return "0d 00:00:00" if prs.empty?
|
|
82
|
-
|
|
83
|
-
times = prs.map do |pr|
|
|
84
|
-
merged_at = Time.parse(pr.dig('node', 'mergedAt'))
|
|
85
|
-
created_at = Time.parse(pr.dig('node', 'publishedAt'))
|
|
86
|
-
merged_at - created_at
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
average_time = times.sum.fdiv(times.size)
|
|
90
|
-
format_time(average_time)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def format_time(seconds)
|
|
94
|
-
return "0d 00:00:00" if seconds.nan? || seconds.infinite?
|
|
95
|
-
|
|
96
|
-
days, remaining = seconds.divmod(86_400)
|
|
97
|
-
Time.at(remaining).utc.strftime("#{days}d %H:%M:%S")
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def output_metrics(period, prs, correction_pr_count)
|
|
101
|
-
formatted_data = format_data(period, prs, correction_pr_count)
|
|
102
|
-
|
|
103
|
-
File.open(output_filename, 'a') do |file|
|
|
104
|
-
file.write(formatted_data)
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def format_data(period, prs, correction_pr_count)
|
|
109
|
-
raise NotImplementedError, "This method must be implemented by subclasses."
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def output_filename
|
|
113
|
-
raise NotImplementedError, "This method must be implemented by subclasses."
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
require_relative 'query_builder'
|
|
7
|
+
require_relative 'metrics_calc'
|
|
8
|
+
require_relative 'pull_request_wrapper'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
module DevMetrics
|
|
12
|
+
class Client
|
|
13
|
+
GITHUB_GRAPHQL_API = 'https://api.github.com/graphql'.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@repo_name = config.repo_name
|
|
17
|
+
@access_token = config.access_token
|
|
18
|
+
@excluded_accounts = config.excluded_accounts || []
|
|
19
|
+
@rollback_branch_prefixes = config.rollback_branch_prefixes || %w(hotfix fix rollback)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch(period: Date.today)
|
|
23
|
+
uri = URI.parse(GITHUB_GRAPHQL_API)
|
|
24
|
+
builder = DevMetrics::QueryBuilder.new(@repo_name)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
token = @access_token
|
|
28
|
+
|
|
29
|
+
auth_request = build_request(uri, builder.access_auth_check, token)
|
|
30
|
+
auth_response = execute_request(uri, auth_request)
|
|
31
|
+
check_auth_errors!(auth_response)
|
|
32
|
+
|
|
33
|
+
pr_request = build_request(uri, builder.pull_requests_for(period), token)
|
|
34
|
+
pr_response = execute_request(uri, pr_request)
|
|
35
|
+
|
|
36
|
+
pr_data = parse_response(pr_response)
|
|
37
|
+
parsed_pr_data = pr_data.map { |row| DevMetrics::PullRequestWrapper.new(row) }
|
|
38
|
+
|
|
39
|
+
DevMetrics::MetricsCalc.new(
|
|
40
|
+
parsed_pr_data,
|
|
41
|
+
period,
|
|
42
|
+
excluded_accounts: @excluded_accounts,
|
|
43
|
+
rollback_branch_prefixes: @rollback_branch_prefixes
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build_request(uri, body, token)
|
|
50
|
+
request = Net::HTTP::Post.new(uri)
|
|
51
|
+
request['Authorization'] = "Bearer #{token}"
|
|
52
|
+
request.body = body
|
|
53
|
+
request
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def execute_request(uri, request)
|
|
57
|
+
options = { use_ssl: uri.scheme == 'https' }
|
|
58
|
+
Net::HTTP.start(uri.hostname, uri.port, options) { |http| http.request(request) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def check_auth_errors!(response)
|
|
62
|
+
if response.code.to_i == 403 || response.body.include?('FORBIDDEN')
|
|
63
|
+
raise "Access denied: ensure the access token has permission to access #{@repo_name}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_response(response)
|
|
68
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
69
|
+
raise "Failed to fetch data: #{response.message} (#{response.code})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
data = JSON.parse(response.body)
|
|
73
|
+
raise "GraphQL error: #{data['errors']}" if data['errors']
|
|
74
|
+
|
|
75
|
+
data.dig('data', 'search', 'edges') || []
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require_relative 'format_base'
|
|
2
|
+
|
|
3
|
+
module DevMetrics
|
|
4
|
+
class Csv < FormatBase
|
|
5
|
+
|
|
6
|
+
def call
|
|
7
|
+
write
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def file_name
|
|
11
|
+
"dev_metrics.csv"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def format
|
|
15
|
+
columns.map { |col| col['label'] || col }.join(',') + "\n" +
|
|
16
|
+
build_row.join(',')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write
|
|
20
|
+
File.open(file_name, 'a') do |f| # ← append mode
|
|
21
|
+
f.puts columns.map { |col| col['label'] || col }.join(',') if File.size(file_name).zero?
|
|
22
|
+
f.puts build_row.join(',')
|
|
23
|
+
end
|
|
24
|
+
puts "CSV data written to #{file_name}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
3
|
+
module DevMetrics
|
|
4
|
+
class FormatBase
|
|
5
|
+
|
|
6
|
+
attr_reader :metrics_calc
|
|
7
|
+
|
|
8
|
+
def initialize(metrics_calc, config_path = "dev_metrics_config.yml")
|
|
9
|
+
@metrics_calc = metrics_calc
|
|
10
|
+
@format_config = YAML.load_file(config_path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def write; end
|
|
16
|
+
|
|
17
|
+
def file_name; end
|
|
18
|
+
|
|
19
|
+
def columns
|
|
20
|
+
@format_config['columns'] || []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build_row
|
|
24
|
+
columns.map do |column|
|
|
25
|
+
key = column['key'] || column
|
|
26
|
+
begin
|
|
27
|
+
value = @metrics_calc.send(key)
|
|
28
|
+
rescue NoMethodError
|
|
29
|
+
value = "No defined for #{key}"
|
|
30
|
+
end
|
|
31
|
+
value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require_relative 'format_base'
|
|
3
|
+
|
|
4
|
+
module DevMetrics
|
|
5
|
+
class Json < FormatBase
|
|
6
|
+
|
|
7
|
+
def call
|
|
8
|
+
write
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def write
|
|
14
|
+
data = {}
|
|
15
|
+
columns.each_with_index do |col, idx|
|
|
16
|
+
key = col['label'] || col['key'] || col
|
|
17
|
+
data[key] = build_row[idx]
|
|
18
|
+
end
|
|
19
|
+
File.open(file_name, "w") do |file|
|
|
20
|
+
file.puts JSON.pretty_generate(data)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def file_name
|
|
25
|
+
"output.json"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/dev_metrics/markdown.rb
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
require_relative '
|
|
1
|
+
require_relative 'format_base'
|
|
2
2
|
|
|
3
3
|
module DevMetrics
|
|
4
|
-
class Markdown <
|
|
4
|
+
class Markdown < FormatBase
|
|
5
|
+
|
|
6
|
+
def call
|
|
7
|
+
write
|
|
8
|
+
end
|
|
5
9
|
|
|
6
10
|
private
|
|
7
11
|
|
|
8
|
-
def
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
def write
|
|
13
|
+
is_new_file = !File.exist?(file_name) || File.size(file_name).zero?
|
|
14
|
+
File.open(file_name, "a") do |file|
|
|
15
|
+
if is_new_file
|
|
16
|
+
file.puts "| " + columns.map { |col| col['label'] || col }.join(' | ') + " |"
|
|
17
|
+
file.puts "| " + (["---"] * columns.size).join(' | ') + " |"
|
|
18
|
+
end
|
|
19
|
+
file.puts "| " + build_row.join(' | ') + " |"
|
|
20
|
+
end
|
|
15
21
|
end
|
|
16
22
|
|
|
17
|
-
def
|
|
18
|
-
|
|
19
|
-
((correction_pr_count.to_f / prs.count) * 100).round(2)
|
|
20
|
-
end
|
|
21
|
-
def output_filename
|
|
22
|
-
"metrics_report.md"
|
|
23
|
+
def file_name
|
|
24
|
+
"dev_metrics.md"
|
|
23
25
|
end
|
|
24
26
|
end
|
|
25
|
-
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
require 'pry'
|
|
2
|
+
|
|
3
|
+
module DevMetrics
|
|
4
|
+
class MetricsCalc
|
|
5
|
+
attr_reader :period
|
|
6
|
+
|
|
7
|
+
def initialize(prs, period, excluded_accounts:, rollback_branch_prefixes:)
|
|
8
|
+
@prs = prs
|
|
9
|
+
@period = period
|
|
10
|
+
@excluded_accounts = excluded_accounts || []
|
|
11
|
+
@rollback_branch_prefixes = rollback_branch_prefixes
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Returns the number of PRs excluding those created by bot accounts.
|
|
15
|
+
#
|
|
16
|
+
# @return [Integer] Number of PRs excluding bots.
|
|
17
|
+
def prs_length
|
|
18
|
+
count_prs_with_excluded_account(@prs, @excluded_accounts).length
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns the number of rollback PRs (branch name matches fix patterns).
|
|
22
|
+
#
|
|
23
|
+
# @return [Integer] Number of rollback PRs.
|
|
24
|
+
def rollback_prs_length
|
|
25
|
+
return 0 if @rollback_branch_prefixes.nil? || @rollback_branch_prefixes.empty?
|
|
26
|
+
regex = /^#{@rollback_branch_prefixes.join('|')}/
|
|
27
|
+
@prs.count { |pr| pr.head_ref_name&.match?(regex) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the average lead time for PRs (from creation to merge) as a formatted string.
|
|
31
|
+
#
|
|
32
|
+
# @return [String] Average lead time formatted as "Xd HH:MM:SS".
|
|
33
|
+
def lead_time
|
|
34
|
+
return "0d 00:00:00" if @prs.empty?
|
|
35
|
+
|
|
36
|
+
times = @prs.map do |pr|
|
|
37
|
+
merged_at = pr.merged_at
|
|
38
|
+
created_at = pr.created_at
|
|
39
|
+
merged_at && created_at ? merged_at - created_at : 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
average_time = times.sum.fdiv(times.size)
|
|
43
|
+
format_time(average_time)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the failure rate (percentage of rollback PRs).
|
|
47
|
+
#
|
|
48
|
+
# @return [String, Float] Percentage of rollback PRs or "-" if no PRs.
|
|
49
|
+
def failure_rate
|
|
50
|
+
return "-" if @prs.empty?
|
|
51
|
+
((rollback_prs_length.to_f / @prs.count) * 100).round(2)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns the average PR size (additions + deletions).
|
|
55
|
+
#
|
|
56
|
+
# @return [Float] Average PR size.
|
|
57
|
+
def average_changed_line_size
|
|
58
|
+
return 0 if @prs.empty?
|
|
59
|
+
sizes = @prs.map { |pr| pr.additions + pr.deletions }
|
|
60
|
+
(sizes.sum.to_f / sizes.size).round(2)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns the average number of changed files per PR.
|
|
64
|
+
#
|
|
65
|
+
# @return [Float] Average number of changed files.
|
|
66
|
+
def average_changed_file
|
|
67
|
+
return 0 if @prs.empty?
|
|
68
|
+
files = @prs.map(&:changed_files)
|
|
69
|
+
(files.sum.to_f / files.size).round(2)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the maximum PR size (additions + deletions).
|
|
73
|
+
#
|
|
74
|
+
# @return [Integer] Maximum PR size.
|
|
75
|
+
def max_pr_size
|
|
76
|
+
return 0 if @prs.empty?
|
|
77
|
+
@prs.map { |pr| pr.additions + pr.deletions }.max
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the minimum PR size (additions + deletions).
|
|
81
|
+
#
|
|
82
|
+
# @return [Integer] Minimum PR size.
|
|
83
|
+
def min_pr_size
|
|
84
|
+
return 0 if @prs.empty?
|
|
85
|
+
@prs.map { |pr| pr.additions + pr.deletions }.min
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns the average number of commits per PR.
|
|
89
|
+
#
|
|
90
|
+
# @return [Float] Average number of commits.
|
|
91
|
+
def average_commits_count
|
|
92
|
+
return 0 if @prs.empty?
|
|
93
|
+
counts = @prs.map(&:commits_count)
|
|
94
|
+
(counts.sum.to_f / counts.size).round(2)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns the average number of reviews per PR.
|
|
98
|
+
#
|
|
99
|
+
# @return [Float] Average number of reviews.
|
|
100
|
+
def average_reviews_count
|
|
101
|
+
return 0 if @prs.empty?
|
|
102
|
+
counts = @prs.map(&:reviews_count)
|
|
103
|
+
(counts.sum.to_f / counts.size).round(2)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns the average number of review requests per PR.
|
|
107
|
+
#
|
|
108
|
+
# @return [Float] Average number of review requests.
|
|
109
|
+
def average_review_requests_count
|
|
110
|
+
return 0 if @prs.empty?
|
|
111
|
+
counts = @prs.map(&:review_requests_count)
|
|
112
|
+
(counts.sum.to_f / counts.size).round(2)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns the percentage of PRs that are drafts.
|
|
116
|
+
#
|
|
117
|
+
# @return [Float] Percentage of draft PRs.
|
|
118
|
+
def draft_pr_rate
|
|
119
|
+
return 0 if @prs.empty?
|
|
120
|
+
draft_count = @prs.count(&:draft?)
|
|
121
|
+
((draft_count.to_f / @prs.size) * 100).round(2)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns a hash with label names as keys and their counts as values.
|
|
125
|
+
#
|
|
126
|
+
# @return [Hash] Label counts.
|
|
127
|
+
def label_counts
|
|
128
|
+
@prs.flat_map(&:labels).compact.tally
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Returns a hash with assignee names as keys and their counts as values.
|
|
132
|
+
#
|
|
133
|
+
# @return [Hash] Assignee counts.
|
|
134
|
+
def assignee_counts
|
|
135
|
+
@prs.flat_map(&:assignees).compact.tally
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returns a hash with milestone titles as keys and their counts as values.
|
|
139
|
+
#
|
|
140
|
+
# @return [Hash] Milestone counts.
|
|
141
|
+
def milestone_counts
|
|
142
|
+
@prs.map(&:milestone).compact.tally
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Returns the average PR age in days (from creation to close, or now if open).
|
|
146
|
+
#
|
|
147
|
+
# @return [Float] Average PR age in days.
|
|
148
|
+
def average_pr_age
|
|
149
|
+
return 0 if @prs.empty?
|
|
150
|
+
ages = @prs.map do |pr|
|
|
151
|
+
closed = pr.closed_at || Time.now
|
|
152
|
+
created = pr.created_at
|
|
153
|
+
created && closed ? (closed - created) / 86400.0 : 0
|
|
154
|
+
end
|
|
155
|
+
(ages.sum / ages.size).round(2)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Returns the maximum PR age in days.
|
|
159
|
+
#
|
|
160
|
+
# @return [Float] Maximum PR age in days.
|
|
161
|
+
def max_pr_age
|
|
162
|
+
return 0 if @prs.empty?
|
|
163
|
+
ages = @prs.map do |pr|
|
|
164
|
+
closed = pr.closed_at || Time.now
|
|
165
|
+
created = pr.created_at
|
|
166
|
+
created && closed ? (closed - created) / 86400.0 : 0
|
|
167
|
+
end
|
|
168
|
+
ages.max.round(2)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Returns the minimum PR age in days.
|
|
172
|
+
#
|
|
173
|
+
# @return [Float] Minimum PR age in days.
|
|
174
|
+
def min_pr_age
|
|
175
|
+
return 0 if @prs.empty?
|
|
176
|
+
ages = @prs.map do |pr|
|
|
177
|
+
closed = pr.closed_at || Time.now
|
|
178
|
+
created = pr.created_at
|
|
179
|
+
created && closed ? (closed - created) / 86400.0 : 0
|
|
180
|
+
end
|
|
181
|
+
ages.min.round(2)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Returns the number of draft PRs.
|
|
185
|
+
#
|
|
186
|
+
# @return [Integer] Number of draft PRs.
|
|
187
|
+
def draft_pr_count
|
|
188
|
+
@prs.count(&:draft?)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Returns the number of merged PRs.
|
|
192
|
+
#
|
|
193
|
+
# @return [Integer] Number of merged PRs.
|
|
194
|
+
def merged_pr_count
|
|
195
|
+
@prs.count { |pr| !pr.merged_at.nil? }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Returns the number of closed but unmerged PRs.
|
|
199
|
+
#
|
|
200
|
+
# @return [Integer] Number of closed but unmerged PRs.
|
|
201
|
+
def closed_unmerged_pr_count
|
|
202
|
+
@prs.count { |pr| pr.closed_at && pr.merged_at.nil? }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def count_prs_with_excluded_account(prs, excluded_accounts)
|
|
208
|
+
return prs if excluded_accounts.empty?
|
|
209
|
+
prs.reject { |pr| excluded_accounts.include?(pr.author) }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def format_time(seconds)
|
|
213
|
+
return "0d 00:00:00" if seconds.nan? || seconds.infinite?
|
|
214
|
+
days, remaining = seconds.divmod(86_400)
|
|
215
|
+
Time.at(remaining).utc.strftime("#{days}d %H:%M:%S")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module DevMetrics
|
|
2
|
+
class PullRequestWrapper
|
|
3
|
+
|
|
4
|
+
def initialize(row)
|
|
5
|
+
@raw = row['node']
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def url
|
|
9
|
+
@raw['url']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def number
|
|
13
|
+
@raw['number']
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def title
|
|
17
|
+
@raw['title']
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def body
|
|
21
|
+
@raw['body']
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def state
|
|
25
|
+
@raw['state']
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def created_at
|
|
29
|
+
Time.parse(@raw['createdAt'] || @raw['publishedAt']) rescue nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def updated_at
|
|
33
|
+
Time.parse(@raw['updatedAt']) rescue nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def closed_at
|
|
37
|
+
Time.parse(@raw['closedAt']) rescue nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def merged_at
|
|
41
|
+
Time.parse(@raw['mergedAt']) rescue nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def published_at
|
|
45
|
+
Time.parse(@raw['publishedAt']) rescue nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def author
|
|
49
|
+
@raw.dig('author', 'login')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def assignees
|
|
53
|
+
Array(@raw.dig('assignees', 'nodes')).map { |a| a['login'] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def labels
|
|
57
|
+
Array(@raw.dig('labels', 'nodes')).map { |l| l['name'] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def head_ref_name
|
|
61
|
+
@raw['headRefName']
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def base_ref_name
|
|
65
|
+
@raw['baseRefName']
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def additions
|
|
69
|
+
@raw['additions'].to_i
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deletions
|
|
73
|
+
@raw['deletions'].to_i
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def changed_files
|
|
77
|
+
@raw['changedFiles'].to_i
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def commits_count
|
|
81
|
+
@raw.dig('commits', 'totalCount').to_i
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reviews_count
|
|
85
|
+
@raw.dig('reviews', 'totalCount').to_i
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def review_requests_count
|
|
89
|
+
@raw.dig('reviewRequests', 'totalCount').to_i
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def merged_by
|
|
93
|
+
@raw.dig('mergedBy', 'login')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def milestone
|
|
97
|
+
@raw.dig('milestone', 'title')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def draft?
|
|
101
|
+
@raw['isDraft']
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def mergeable
|
|
105
|
+
@raw['mergeable']
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def merge_commit_oid
|
|
109
|
+
@raw.dig('mergeCommit', 'oid')
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -1,44 +1,63 @@
|
|
|
1
|
-
module DevMetrics
|
|
2
|
-
class QueryBuilder
|
|
3
|
-
|
|
4
|
-
def initialize(repo)
|
|
5
|
-
@repo = repo
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1
|
+
module DevMetrics
|
|
2
|
+
class QueryBuilder
|
|
3
|
+
|
|
4
|
+
def initialize(repo)
|
|
5
|
+
@repo = repo
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def access_auth_check
|
|
9
|
+
to_body <<~GRAPHQL
|
|
10
|
+
{
|
|
11
|
+
repository(owner: "#{@repo.split('/')[0]}", name: "#{@repo.split('/')[1]}"){
|
|
12
|
+
id
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
GRAPHQL
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def pull_requests_for(period)
|
|
19
|
+
to_body <<~GRAPHQL
|
|
20
|
+
{
|
|
21
|
+
search(query: "repo:#{@repo} is:pr merged:#{period}", type: ISSUE, first: 100) {
|
|
22
|
+
edges {
|
|
23
|
+
node {
|
|
24
|
+
... on PullRequest {
|
|
25
|
+
url
|
|
26
|
+
number
|
|
27
|
+
title
|
|
28
|
+
body
|
|
29
|
+
state
|
|
30
|
+
createdAt
|
|
31
|
+
updatedAt
|
|
32
|
+
closedAt
|
|
33
|
+
mergedAt
|
|
34
|
+
author { login }
|
|
35
|
+
assignees(first: 10) { nodes { login } }
|
|
36
|
+
labels(first: 10) { nodes { name } }
|
|
37
|
+
headRefName
|
|
38
|
+
baseRefName
|
|
39
|
+
additions
|
|
40
|
+
deletions
|
|
41
|
+
changedFiles
|
|
42
|
+
commits { totalCount }
|
|
43
|
+
reviews { totalCount }
|
|
44
|
+
reviewRequests { totalCount }
|
|
45
|
+
mergedBy { login }
|
|
46
|
+
milestone { title }
|
|
47
|
+
isDraft
|
|
48
|
+
mergeable
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
GRAPHQL
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def to_body(query_string)
|
|
60
|
+
{ "query" => query_string.strip }.to_json
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/dev_metrics.rb
CHANGED
|
@@ -1,29 +1,100 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
require 'optparse'
|
|
2
|
+
require 'yaml'
|
|
3
|
+
require_relative 'dev_metrics/markdown'
|
|
4
|
+
require_relative 'dev_metrics/version'
|
|
5
|
+
require_relative 'dev_metrics/client'
|
|
6
|
+
require_relative 'dev_metrics/metrics_calc'
|
|
7
|
+
require_relative 'dev_metrics/csv'
|
|
8
|
+
require_relative 'dev_metrics/json'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
module DevMetrics
|
|
12
|
+
class Config
|
|
13
|
+
attr_accessor :access_token, :repo_name, :excluded_accounts, :rollback_branch_prefixes
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@access_token = nil
|
|
17
|
+
@repo_name = nil
|
|
18
|
+
@excluded_accounts = []
|
|
19
|
+
@rollback_branch_prefixes = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load_from_yaml(path = "dev_metrics_config.yml")
|
|
23
|
+
unless File.exist?(path)
|
|
24
|
+
warn "Config file '#{path}' not found. Please create it before running this tool."
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
config = YAML.load_file(path)
|
|
29
|
+
if config["access_token"].nil? || config["repo_name"].nil?
|
|
30
|
+
warn "Config file must include 'access_token' and 'repo_name'."
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@access_token = config["access_token"]
|
|
35
|
+
@repo_name = config["repo_name"]
|
|
36
|
+
@excluded_accounts = config["excluded_accounts"] || []
|
|
37
|
+
@rollback_branch_prefixes = config["rollback_branch_prefixes"] || []
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@configuration = Config.new
|
|
42
|
+
|
|
43
|
+
def self.configuration
|
|
44
|
+
@configuration
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.option_parse(argv)
|
|
48
|
+
options = {
|
|
49
|
+
period: (Date.today << 1).strftime("%Y-%m"),
|
|
50
|
+
format: "csv",
|
|
51
|
+
config: "dev_metrics_config.yml"
|
|
52
|
+
}
|
|
53
|
+
OptionParser.new do |opts|
|
|
54
|
+
opts.banner = "Usage: dev_metrics [options]"
|
|
55
|
+
|
|
56
|
+
opts.on("-c", "--config FILE", "Specify config YAML file (default: dev_metrics_config.yml)") do |file|
|
|
57
|
+
options[:config] = file
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
opts.on("-p", "--period PERIOD", "Specify the period for metrics (e.g., '2025-05' or '2025-05-01..2025-05-31')") do |period|
|
|
61
|
+
options[:period] = period
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
opts.on("-f", "--format FORMAT", "Specify the output format (e.g., 'csv', 'json', 'markdown')") do |format|
|
|
65
|
+
options[:format] = format
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
opts.on("-h", "--help", "Display this help message") do
|
|
69
|
+
puts opts
|
|
70
|
+
exit
|
|
71
|
+
end
|
|
72
|
+
end.parse!(argv)
|
|
73
|
+
options
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.run(argv: nil)
|
|
77
|
+
argv ||= ARGV
|
|
78
|
+
options = option_parse(argv)
|
|
79
|
+
config_file = options[:config] || "dev_metrics_config.yml"
|
|
80
|
+
@configuration.load_from_yaml(config_file)
|
|
81
|
+
period = options[:period]
|
|
82
|
+
format = options[:format]
|
|
83
|
+
|
|
84
|
+
client = DevMetrics::Client.new(@configuration)
|
|
85
|
+
prs = client.fetch(period: period)
|
|
86
|
+
|
|
87
|
+
case format
|
|
88
|
+
when "csv"
|
|
89
|
+
DevMetrics::Csv.new(prs).call
|
|
90
|
+
when "markdown"
|
|
91
|
+
DevMetrics::Markdown.new(prs).call
|
|
92
|
+
when "json"
|
|
93
|
+
DevMetrics::Json.new(prs).call
|
|
94
|
+
else
|
|
95
|
+
warn "Unknown format: #{format}"
|
|
96
|
+
warn "Available formats: csv, markdown, json"
|
|
97
|
+
exit 1
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
metadata
CHANGED
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dev_metrics
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- sean2121
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-
|
|
11
|
+
date: 2025-06-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description:
|
|
14
14
|
email:
|
|
15
|
-
executables:
|
|
15
|
+
executables:
|
|
16
|
+
- dev_metrics
|
|
16
17
|
extensions: []
|
|
17
18
|
extra_rdoc_files: []
|
|
18
19
|
files:
|
|
20
|
+
- bin/dev_metrics
|
|
19
21
|
- lib/dev_metrics.rb
|
|
20
22
|
- lib/dev_metrics/client.rb
|
|
23
|
+
- lib/dev_metrics/csv.rb
|
|
24
|
+
- lib/dev_metrics/format_base.rb
|
|
25
|
+
- lib/dev_metrics/json.rb
|
|
21
26
|
- lib/dev_metrics/markdown.rb
|
|
27
|
+
- lib/dev_metrics/metrics_calc.rb
|
|
28
|
+
- lib/dev_metrics/pull_request_wrapper.rb
|
|
22
29
|
- lib/dev_metrics/query_builder.rb
|
|
30
|
+
- lib/dev_metrics/version.rb
|
|
23
31
|
homepage:
|
|
24
32
|
licenses:
|
|
25
33
|
- MIT
|