dev_metrics 0.1.1 → 0.2.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.
- checksums.yaml +4 -4
- data/lib/dev_metrics/client.rb +77 -124
- data/lib/dev_metrics/format_base.rb +25 -0
- data/lib/dev_metrics/markdown.rb +34 -21
- data/lib/dev_metrics/metrics_calc.rb +214 -0
- data/lib/dev_metrics/pull_request_wrapper.rb +112 -0
- data/lib/dev_metrics/query_builder.rb +47 -0
- data/lib/dev_metrics/version.rb +3 -0
- data/lib/dev_metrics.rb +35 -29
- data/lib/spec/dev_metrics/metrics_calc_spec.rb +107 -0
- data/lib/spec/spec_helper.rb +3 -0
- metadata +16 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9932a3bb8544a01ac52f90c4a5217161e5ec4152741302f27e073d551c7c70b
|
|
4
|
+
data.tar.gz: de933ff7150d18fbdc896ae51783bfd1955b4cdd0f2fd3b4caed1b4ca3d5c108
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 235b658af21204f6c748e14049b4d65016dba87e72f798c799a58f51b225eba9c31fb0b228504bd40f3c88e13a220e0ebaf0d600d3775bdfec0b79de90c39b65
|
|
7
|
+
data.tar.gz: f0cd5727a59f0b3259527539a8b22f196434941a2a65807ae3b1b3af8559c98b9c514f77283ca1e4c1698886a58a79569cea6a0b149ddfee07a4df62446ccb16
|
data/lib/dev_metrics/client.rb
CHANGED
|
@@ -1,124 +1,77 @@
|
|
|
1
|
-
require 'net/http'
|
|
2
|
-
require 'uri'
|
|
3
|
-
require 'date'
|
|
4
|
-
require 'json'
|
|
5
|
-
require 'time'
|
|
6
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return "0d 00:00:00" if seconds.nan? || seconds.infinite?
|
|
79
|
-
|
|
80
|
-
days, remaining = seconds.divmod(86_400)
|
|
81
|
-
Time.at(remaining).utc.strftime("#{days}d %H:%M:%S")
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def graphql_query(period)
|
|
85
|
-
query_string = <<-GRAPHQL
|
|
86
|
-
{
|
|
87
|
-
search(query: "repo:#{@repo_name} is:pr merged:#{period}", type: ISSUE, first: 100) {
|
|
88
|
-
edges {
|
|
89
|
-
node {
|
|
90
|
-
... on PullRequest {
|
|
91
|
-
url
|
|
92
|
-
title
|
|
93
|
-
author {
|
|
94
|
-
login
|
|
95
|
-
}
|
|
96
|
-
mergedAt
|
|
97
|
-
headRefName
|
|
98
|
-
publishedAt
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
GRAPHQL
|
|
105
|
-
{ "query" => query_string.strip }.to_json
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def output_metrics(period, prs, correction_pr_count)
|
|
109
|
-
formatted_data = format_data(period, prs, correction_pr_count)
|
|
110
|
-
|
|
111
|
-
File.open(output_filename, 'a') do |file|
|
|
112
|
-
file.write(formatted_data)
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def format_data(period, prs, correction_pr_count)
|
|
117
|
-
raise NotImplementedError, "This method must be implemented by subclasses."
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def output_filename
|
|
121
|
-
raise NotImplementedError, "This method must be implemented by subclasses."
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
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
|
+
module DevMetrics
|
|
11
|
+
class Client
|
|
12
|
+
GITHUB_GRAPHQL_API = 'https://api.github.com/graphql'.freeze
|
|
13
|
+
ACCESS_TOKEN = ENV.fetch('GITHUB_ACCESS_TOKEN', nil)
|
|
14
|
+
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@repo_name = config.repo_name
|
|
17
|
+
@access_token = config.access_token || ENV.fetch('GITHUB_ACCESS_TOKEN', nil)
|
|
18
|
+
@bot_accounts = config.bot_accounts || []
|
|
19
|
+
@fix_branch_names = config.fix_branch_names || %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
|
+
token = @access_token
|
|
27
|
+
|
|
28
|
+
auth_request = build_request(uri, builder.access_auth_check, token)
|
|
29
|
+
auth_response = execute_request(uri, auth_request)
|
|
30
|
+
check_auth_errors!(auth_response)
|
|
31
|
+
|
|
32
|
+
pr_request = build_request(uri, builder.pull_requests_for(period), token)
|
|
33
|
+
pr_response = execute_request(uri, pr_request)
|
|
34
|
+
|
|
35
|
+
pr_data = parse_response(pr_response)
|
|
36
|
+
parsed_pr_data = pr_data.map { |row| DevMetrics::PullRequestWrapper.new(row) }
|
|
37
|
+
|
|
38
|
+
DevMetrics::MetricsCalc.new(
|
|
39
|
+
parsed_pr_data,
|
|
40
|
+
period,
|
|
41
|
+
bot_accounts: @bot_accounts,
|
|
42
|
+
fix_branch_names: @fix_branch_names
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def build_request(uri, body, token)
|
|
49
|
+
request = Net::HTTP::Post.new(uri)
|
|
50
|
+
request['Authorization'] = "Bearer #{token}"
|
|
51
|
+
request.body = body
|
|
52
|
+
request
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def execute_request(uri, request)
|
|
56
|
+
options = { use_ssl: uri.scheme == 'https' }
|
|
57
|
+
Net::HTTP.start(uri.hostname, uri.port, options) { |http| http.request(request) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_auth_errors!(response)
|
|
61
|
+
if response.code.to_i == 403 || response.body.include?('FORBIDDEN')
|
|
62
|
+
raise "Access denied: ensure the access token has permission to access #{@repo_name}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_response(response)
|
|
67
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
68
|
+
raise "Failed to fetch data: #{response.message} (#{response.code})"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
data = JSON.parse(response.body)
|
|
72
|
+
raise "GraphQL error: #{data['errors']}" if data['errors']
|
|
73
|
+
|
|
74
|
+
data.dig('data', 'search', 'edges') || []
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module DevMetrics
|
|
2
|
+
class FormatBase
|
|
3
|
+
|
|
4
|
+
attr_reader :metrics_calc
|
|
5
|
+
|
|
6
|
+
def initialize(metrics_calc)
|
|
7
|
+
@metrics_calc = metrics_calc
|
|
8
|
+
write
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def data_format
|
|
14
|
+
raise NotImplementedError, "You must implement the data_format method in a subclass"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def output_filename
|
|
18
|
+
raise NotImplementedError, "You must implement the output_filename method in a subclass"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write
|
|
22
|
+
raise NotImplementedError, "You must implement the write method in a subclass"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/dev_metrics/markdown.rb
CHANGED
|
@@ -1,21 +1,34 @@
|
|
|
1
|
-
require_relative '
|
|
2
|
-
|
|
3
|
-
module DevMetrics
|
|
4
|
-
class Markdown <
|
|
5
|
-
|
|
6
|
-
private
|
|
7
|
-
|
|
8
|
-
def
|
|
9
|
-
output = ""
|
|
10
|
-
|
|
11
|
-
output << "#{
|
|
12
|
-
output << "#{
|
|
13
|
-
output << "
|
|
14
|
-
output
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
require_relative 'format_base'
|
|
2
|
+
|
|
3
|
+
module DevMetrics
|
|
4
|
+
class Markdown < FormatBase
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def data_format
|
|
9
|
+
output = ""
|
|
10
|
+
|
|
11
|
+
output << "| #{metrics_calc.period} |"
|
|
12
|
+
output << " #{metrics_calc.prs_length} |"
|
|
13
|
+
output << " #{metrics_calc.rollback_prs_length} |"
|
|
14
|
+
output << " #{metrics_calc.failure_rate}% |"
|
|
15
|
+
output << " #{metrics_calc.lead_time} |"
|
|
16
|
+
output << " #{metrics_calc.average_changed_line_size} |"
|
|
17
|
+
output << " #{metrics_calc.average_changed_file} |"
|
|
18
|
+
output << " [PRs for #{metrics_calc.period}](https://github.com/#{@repo_name}/pulls?q=is%3Apr+merged%3A#{metrics_calc.period}) |\n"
|
|
19
|
+
output
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write
|
|
23
|
+
formatted_data = data_format
|
|
24
|
+
File.open(output_filename, 'a') do |file|
|
|
25
|
+
file.write(formatted_data)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def output_filename
|
|
31
|
+
"metrics_report.md"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
module DevMetrics
|
|
2
|
+
class MetricsCalc
|
|
3
|
+
attr_reader :period
|
|
4
|
+
|
|
5
|
+
def initialize(prs, period, bot_accounts:, fix_branch_names:)
|
|
6
|
+
@prs = prs
|
|
7
|
+
@period = period
|
|
8
|
+
@bot_accounts = bot_accounts || []
|
|
9
|
+
@fix_branch_names = fix_branch_names
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Returns the number of PRs excluding those created by bot accounts.
|
|
13
|
+
#
|
|
14
|
+
# @return [Integer] Number of PRs excluding bots.
|
|
15
|
+
def prs_length
|
|
16
|
+
count_prs_with_excluded_account(@prs, @bot_accounts).length
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns the number of rollback PRs (branch name matches fix patterns).
|
|
20
|
+
#
|
|
21
|
+
# @return [Integer] Number of rollback PRs.
|
|
22
|
+
def rollback_prs_length
|
|
23
|
+
@prs.count { |pr| pr.head_ref_name&.match?(/^#{@fix_branch_names.join('|')}/) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the average lead time for PRs (from creation to merge) as a formatted string.
|
|
27
|
+
#
|
|
28
|
+
# @return [String] Average lead time formatted as "Xd HH:MM:SS".
|
|
29
|
+
def lead_time
|
|
30
|
+
return "0d 00:00:00" if @prs.empty?
|
|
31
|
+
|
|
32
|
+
times = @prs.map do |pr|
|
|
33
|
+
merged_at = pr.merged_at
|
|
34
|
+
created_at = pr.created_at
|
|
35
|
+
merged_at && created_at ? merged_at - created_at : 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
average_time = times.sum.fdiv(times.size)
|
|
39
|
+
format_time(average_time)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the failure rate (percentage of rollback PRs).
|
|
43
|
+
#
|
|
44
|
+
# @return [String, Float] Percentage of rollback PRs or "-" if no PRs.
|
|
45
|
+
def failure_rate
|
|
46
|
+
return "-" if @prs.empty?
|
|
47
|
+
((rollback_prs_length.to_f / @prs.count) * 100).round(2)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the average PR size (additions + deletions).
|
|
51
|
+
#
|
|
52
|
+
# @return [Float] Average PR size.
|
|
53
|
+
def average_changed_line_size
|
|
54
|
+
return 0 if @prs.empty?
|
|
55
|
+
sizes = @prs.map { |pr| pr.additions + pr.deletions }
|
|
56
|
+
(sizes.sum.to_f / sizes.size).round(2)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the average number of changed files per PR.
|
|
60
|
+
#
|
|
61
|
+
# @return [Float] Average number of changed files.
|
|
62
|
+
def average_changed_file
|
|
63
|
+
return 0 if @prs.empty?
|
|
64
|
+
files = @prs.map(&:changed_files)
|
|
65
|
+
(files.sum.to_f / files.size).round(2)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns the maximum PR size (additions + deletions).
|
|
69
|
+
#
|
|
70
|
+
# @return [Integer] Maximum PR size.
|
|
71
|
+
def max_pr_size
|
|
72
|
+
return 0 if @prs.empty?
|
|
73
|
+
@prs.map { |pr| pr.additions + pr.deletions }.max
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the minimum PR size (additions + deletions).
|
|
77
|
+
#
|
|
78
|
+
# @return [Integer] Minimum PR size.
|
|
79
|
+
def min_pr_size
|
|
80
|
+
return 0 if @prs.empty?
|
|
81
|
+
@prs.map { |pr| pr.additions + pr.deletions }.min
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns the average number of commits per PR.
|
|
85
|
+
#
|
|
86
|
+
# @return [Float] Average number of commits.
|
|
87
|
+
def average_commits_count
|
|
88
|
+
return 0 if @prs.empty?
|
|
89
|
+
counts = @prs.map(&:commits_count)
|
|
90
|
+
(counts.sum.to_f / counts.size).round(2)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns the average number of reviews per PR.
|
|
94
|
+
#
|
|
95
|
+
# @return [Float] Average number of reviews.
|
|
96
|
+
def average_reviews_count
|
|
97
|
+
return 0 if @prs.empty?
|
|
98
|
+
counts = @prs.map(&:reviews_count)
|
|
99
|
+
(counts.sum.to_f / counts.size).round(2)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns the average number of review requests per PR.
|
|
103
|
+
#
|
|
104
|
+
# @return [Float] Average number of review requests.
|
|
105
|
+
def average_review_requests_count
|
|
106
|
+
return 0 if @prs.empty?
|
|
107
|
+
counts = @prs.map(&:review_requests_count)
|
|
108
|
+
(counts.sum.to_f / counts.size).round(2)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns the percentage of PRs that are drafts.
|
|
112
|
+
#
|
|
113
|
+
# @return [Float] Percentage of draft PRs.
|
|
114
|
+
def draft_pr_rate
|
|
115
|
+
return 0 if @prs.empty?
|
|
116
|
+
draft_count = @prs.count(&:draft?)
|
|
117
|
+
((draft_count.to_f / @prs.size) * 100).round(2)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Returns a hash with label names as keys and their counts as values.
|
|
121
|
+
#
|
|
122
|
+
# @return [Hash] Label counts.
|
|
123
|
+
def label_counts
|
|
124
|
+
@prs.flat_map(&:labels).compact.tally
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Returns a hash with assignee names as keys and their counts as values.
|
|
128
|
+
#
|
|
129
|
+
# @return [Hash] Assignee counts.
|
|
130
|
+
def assignee_counts
|
|
131
|
+
@prs.flat_map(&:assignees).compact.tally
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Returns a hash with milestone titles as keys and their counts as values.
|
|
135
|
+
#
|
|
136
|
+
# @return [Hash] Milestone counts.
|
|
137
|
+
def milestone_counts
|
|
138
|
+
@prs.map(&:milestone).compact.tally
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Returns the average PR age in days (from creation to close, or now if open).
|
|
142
|
+
#
|
|
143
|
+
# @return [Float] Average PR age in days.
|
|
144
|
+
def average_pr_age
|
|
145
|
+
return 0 if @prs.empty?
|
|
146
|
+
ages = @prs.map do |pr|
|
|
147
|
+
closed = pr.closed_at || Time.now
|
|
148
|
+
created = pr.created_at
|
|
149
|
+
created && closed ? (closed - created) / 86400.0 : 0
|
|
150
|
+
end
|
|
151
|
+
(ages.sum / ages.size).round(2)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns the maximum PR age in days.
|
|
155
|
+
#
|
|
156
|
+
# @return [Float] Maximum PR age in days.
|
|
157
|
+
def max_pr_age
|
|
158
|
+
return 0 if @prs.empty?
|
|
159
|
+
ages = @prs.map do |pr|
|
|
160
|
+
closed = pr.closed_at || Time.now
|
|
161
|
+
created = pr.created_at
|
|
162
|
+
created && closed ? (closed - created) / 86400.0 : 0
|
|
163
|
+
end
|
|
164
|
+
ages.max.round(2)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Returns the minimum PR age in days.
|
|
168
|
+
#
|
|
169
|
+
# @return [Float] Minimum PR age in days.
|
|
170
|
+
def min_pr_age
|
|
171
|
+
return 0 if @prs.empty?
|
|
172
|
+
ages = @prs.map do |pr|
|
|
173
|
+
closed = pr.closed_at || Time.now
|
|
174
|
+
created = pr.created_at
|
|
175
|
+
created && closed ? (closed - created) / 86400.0 : 0
|
|
176
|
+
end
|
|
177
|
+
ages.min.round(2)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Returns the number of draft PRs.
|
|
181
|
+
#
|
|
182
|
+
# @return [Integer] Number of draft PRs.
|
|
183
|
+
def draft_pr_count
|
|
184
|
+
@prs.count(&:draft?)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Returns the number of merged PRs.
|
|
188
|
+
#
|
|
189
|
+
# @return [Integer] Number of merged PRs.
|
|
190
|
+
def merged_pr_count
|
|
191
|
+
@prs.count { |pr| !pr.merged_at.nil? }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Returns the number of closed but unmerged PRs.
|
|
195
|
+
#
|
|
196
|
+
# @return [Integer] Number of closed but unmerged PRs.
|
|
197
|
+
def closed_unmerged_pr_count
|
|
198
|
+
@prs.count { |pr| pr.closed_at && pr.merged_at.nil? }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
def count_prs_with_excluded_account(prs, bot_accounts)
|
|
204
|
+
return prs if bot_accounts.empty?
|
|
205
|
+
prs.reject { |pr| bot_accounts.include?(pr.author) }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def format_time(seconds)
|
|
209
|
+
return "0d 00:00:00" if seconds.nan? || seconds.infinite?
|
|
210
|
+
days, remaining = seconds.divmod(86_400)
|
|
211
|
+
Time.at(remaining).utc.strftime("#{days}d %H:%M:%S")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
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
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module DevMetrics
|
|
2
|
+
class QueryBuilder
|
|
3
|
+
|
|
4
|
+
def initialize(repo)
|
|
5
|
+
@repo = repo
|
|
6
|
+
end
|
|
7
|
+
def access_auth_check
|
|
8
|
+
to_body <<~GRAPHQL
|
|
9
|
+
{
|
|
10
|
+
repository(owner: "#{@repo.split('/')[0]}", name: "#{@repo.split('/')[1]}"){
|
|
11
|
+
id
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
GRAPHQL
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def pull_requests_for(period)
|
|
18
|
+
to_body <<~GRAPHQL
|
|
19
|
+
{
|
|
20
|
+
search(query: "repo:#{@repo} is:pr merged:#{period}", type: ISSUE, first: 100) {
|
|
21
|
+
edges {
|
|
22
|
+
node {
|
|
23
|
+
... on PullRequest {
|
|
24
|
+
url
|
|
25
|
+
title
|
|
26
|
+
author { login }
|
|
27
|
+
mergedAt
|
|
28
|
+
headRefName
|
|
29
|
+
publishedAt
|
|
30
|
+
additions
|
|
31
|
+
deletions
|
|
32
|
+
changedFiles
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
GRAPHQL
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def to_body(query_string)
|
|
44
|
+
{ "query" => query_string.strip }.to_json
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/dev_metrics.rb
CHANGED
|
@@ -1,29 +1,35 @@
|
|
|
1
|
-
require_relative 'dev_metrics/markdown'
|
|
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_relative 'dev_metrics/markdown'
|
|
2
|
+
require_relative 'dev_metrics/version'
|
|
3
|
+
require_relative 'dev_metrics/client'
|
|
4
|
+
require_relative 'dev_metrics/metrics_calc'
|
|
5
|
+
|
|
6
|
+
module DevMetrics
|
|
7
|
+
class Config
|
|
8
|
+
attr_accessor :access_token, :repo_name, :bot_accounts, :fix_branch_names
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@access_token = nil
|
|
12
|
+
@repo_name = nil
|
|
13
|
+
@bot_accounts = nil
|
|
14
|
+
@fix_branch_names = nil
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@configuration = Config.new
|
|
19
|
+
|
|
20
|
+
def self.configuration
|
|
21
|
+
@configuration
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.configure
|
|
25
|
+
yield(configuration)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Run: fetch PRs, calculate metrics, and pass to formatter
|
|
29
|
+
def self.run(period:, format:)
|
|
30
|
+
client = DevMetrics::Client.new(@configuration)
|
|
31
|
+
prs = client.fetch(period: period)
|
|
32
|
+
|
|
33
|
+
format.new(prs)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require_relative '../spec_helper'
|
|
2
|
+
require 'dev_metrics/metrics_calc'
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
RSpec.describe DevMetrics::MetricsCalc do
|
|
6
|
+
let(:period) { "2025-04" }
|
|
7
|
+
let(:config) { { bot_accounts: [], fix_branch_names: %w(hotfix fix rollback) } }
|
|
8
|
+
|
|
9
|
+
let(:pr1) do
|
|
10
|
+
double(
|
|
11
|
+
additions: 10,
|
|
12
|
+
deletions: 2,
|
|
13
|
+
changed_files: 1,
|
|
14
|
+
commits_count: 3,
|
|
15
|
+
reviews_count: 2,
|
|
16
|
+
review_requests_count: 1,
|
|
17
|
+
draft?: false,
|
|
18
|
+
labels: ["bug"],
|
|
19
|
+
assignees: ["alice"],
|
|
20
|
+
milestone: "v1.0",
|
|
21
|
+
created_at: Time.parse("2025-04-01T10:00:00Z"),
|
|
22
|
+
closed_at: Time.parse("2025-04-02T10:00:00Z"),
|
|
23
|
+
merged_at: Time.parse("2025-04-02T10:00:00Z"),
|
|
24
|
+
head_ref_name: "feature/abc",
|
|
25
|
+
author: "alice"
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
let(:pr2) do
|
|
30
|
+
double(
|
|
31
|
+
additions: 5,
|
|
32
|
+
deletions: 5,
|
|
33
|
+
changed_files: 2,
|
|
34
|
+
commits_count: 2,
|
|
35
|
+
reviews_count: 1,
|
|
36
|
+
review_requests_count: 0,
|
|
37
|
+
draft?: true,
|
|
38
|
+
labels: ["enhancement"],
|
|
39
|
+
assignees: ["bob"],
|
|
40
|
+
milestone: "v1.0",
|
|
41
|
+
created_at: Time.parse("2025-04-03T10:00:00Z"),
|
|
42
|
+
closed_at: Time.parse("2025-04-04T10:00:00Z"),
|
|
43
|
+
merged_at: nil,
|
|
44
|
+
head_ref_name: "fix/xyz",
|
|
45
|
+
author: "bob"
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
let(:prs) { [pr1, pr2] }
|
|
50
|
+
|
|
51
|
+
subject do
|
|
52
|
+
described_class.new(
|
|
53
|
+
prs,
|
|
54
|
+
period,
|
|
55
|
+
bot_accounts: config[:bot_accounts],
|
|
56
|
+
fix_branch_names: config[:fix_branch_names]
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "calculates prs_length" do
|
|
61
|
+
expect(subject.prs_length).to eq 2
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "calculates rollback_prs_length" do
|
|
65
|
+
expect(subject.rollback_prs_length).to eq 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "calculates average_changed_line_size" do
|
|
69
|
+
expect(subject.average_changed_line_size).to eq 11.0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "calculates average_commits_count" do
|
|
73
|
+
expect(subject.average_commits_count).to eq 2.5
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "calculates draft_pr_rate" do
|
|
77
|
+
expect(subject.draft_pr_rate).to eq 50.0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "calculates label_counts" do
|
|
81
|
+
expect(subject.label_counts).to eq({ "bug" => 1, "enhancement" => 1 })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "calculates assignee_counts" do
|
|
85
|
+
expect(subject.assignee_counts).to eq({ "alice" => 1, "bob" => 1 })
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "calculates milestone_counts" do
|
|
89
|
+
expect(subject.milestone_counts).to eq({ "v1.0" => 2 })
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "calculates average_pr_age" do
|
|
93
|
+
expect(subject.average_pr_age).to be_a(Float)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "calculates draft_pr_count" do
|
|
97
|
+
expect(subject.draft_pr_count).to eq 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "calculates merged_pr_count" do
|
|
101
|
+
expect(subject.merged_pr_count).to eq 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "calculates closed_unmerged_pr_count" do
|
|
105
|
+
expect(subject.closed_unmerged_pr_count).to eq 1
|
|
106
|
+
end
|
|
107
|
+
end
|
metadata
CHANGED
|
@@ -1,29 +1,36 @@
|
|
|
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.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- sean2121
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2025-06-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
|
-
description:
|
|
14
|
-
email:
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
15
|
executables: []
|
|
16
16
|
extensions: []
|
|
17
17
|
extra_rdoc_files: []
|
|
18
18
|
files:
|
|
19
19
|
- lib/dev_metrics.rb
|
|
20
20
|
- lib/dev_metrics/client.rb
|
|
21
|
+
- lib/dev_metrics/format_base.rb
|
|
21
22
|
- lib/dev_metrics/markdown.rb
|
|
22
|
-
|
|
23
|
+
- lib/dev_metrics/metrics_calc.rb
|
|
24
|
+
- lib/dev_metrics/pull_request_wrapper.rb
|
|
25
|
+
- lib/dev_metrics/query_builder.rb
|
|
26
|
+
- lib/dev_metrics/version.rb
|
|
27
|
+
- lib/spec/dev_metrics/metrics_calc_spec.rb
|
|
28
|
+
- lib/spec/spec_helper.rb
|
|
29
|
+
homepage:
|
|
23
30
|
licenses:
|
|
24
31
|
- MIT
|
|
25
32
|
metadata: {}
|
|
26
|
-
post_install_message:
|
|
33
|
+
post_install_message:
|
|
27
34
|
rdoc_options: []
|
|
28
35
|
require_paths:
|
|
29
36
|
- lib
|
|
@@ -38,8 +45,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
38
45
|
- !ruby/object:Gem::Version
|
|
39
46
|
version: '0'
|
|
40
47
|
requirements: []
|
|
41
|
-
rubygems_version: 3.
|
|
42
|
-
signing_key:
|
|
48
|
+
rubygems_version: 3.3.5
|
|
49
|
+
signing_key:
|
|
43
50
|
specification_version: 4
|
|
44
51
|
summary: A RubyGem for tracking and analyzing development metrics from various repositories.
|
|
45
52
|
test_files: []
|