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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18955085e7be6c6a43726926c4b77a93039d26ffb1433dc9d7487d30c0a345ff
4
- data.tar.gz: 5e9b1bbe91561e5643399645f24f438f447ef060d85a5499e1a068c51e05d577
3
+ metadata.gz: c9932a3bb8544a01ac52f90c4a5217161e5ec4152741302f27e073d551c7c70b
4
+ data.tar.gz: de933ff7150d18fbdc896ae51783bfd1955b4cdd0f2fd3b4caed1b4ca3d5c108
5
5
  SHA512:
6
- metadata.gz: fa1b4fd17cf2720ce19b49026e4f726252ae386cf153b6d9140c41f24da2efd56108eed7d8905b7d4b47891d111f8e2c28f768426d3b6a75ddeafdb37ad37668
7
- data.tar.gz: 30c988f1a026536d4eff28d9365aa9e3d335fe3d70b7033a2b6101d40a8091aaa2816c9c30667cc050f98c2201befbcbeb4ddabbdee3d5189239eec9a75e53bf
6
+ metadata.gz: 235b658af21204f6c748e14049b4d65016dba87e72f798c799a58f51b225eba9c31fb0b228504bd40f3c88e13a220e0ebaf0d600d3775bdfec0b79de90c39b65
7
+ data.tar.gz: f0cd5727a59f0b3259527539a8b22f196434941a2a65807ae3b1b3af8559c98b9c514f77283ca1e4c1698886a58a79569cea6a0b149ddfee07a4df62446ccb16
@@ -1,124 +1,77 @@
1
- require 'net/http'
2
- require 'uri'
3
- require 'date'
4
- require 'json'
5
- require 'time'
6
-
7
- module DevMetrics
8
- class Client
9
- GITHUB_GRAPHQL_API = 'https://api.github.com/graphql'.freeze
10
- ACCESS_TOKEN = ENV.fetch('GITHUB_ACCESS_TOKEN', nil)
11
-
12
- def initialize(config)
13
- @repo_name = config.repo_name
14
- @bot_accounts = config.bot_accounts || []
15
- @access_token = config.access_token || ENV.fetch('GITHUB_ACCESS_TOKEN', nil)
16
- @fix_branch_names = config.fix_branch_names || %w(hotfix fix rollback)
17
- end
18
-
19
- def process(period: Date.today)
20
- uri = URI.parse(GITHUB_GRAPHQL_API)
21
- request = build_request(uri, period)
22
- response = execute_request(uri, request)
23
-
24
- pr_data = parse_response(response)
25
- filtered_prs = exclude_bots(pr_data)
26
- correction_pr_count = count_correction_prs(filtered_prs)
27
-
28
- output_metrics(period, filtered_prs, correction_pr_count)
29
- puts "Done. Please check the file #{output_filename}"
30
- end
31
-
32
- private
33
-
34
- def build_request(uri, period)
35
- request = Net::HTTP::Post.new(uri)
36
- request["Authorization"] = "Bearer #{@access_token}"
37
- request.body = graphql_query(period)
38
-
39
- request
40
- end
41
-
42
- def execute_request(uri, request)
43
- options = { use_ssl: uri.scheme == 'https' }
44
- Net::HTTP.start(uri.hostname, uri.port, options) { |http| http.request(request) }
45
- end
46
-
47
- def parse_response(response)
48
- unless response.is_a?(Net::HTTPSuccess)
49
- raise "Failed to fetch data: #{response.message} (#{response.code})"
50
- end
51
-
52
- JSON.parse(response.body).dig('data', 'search', 'edges')
53
- end
54
-
55
- def exclude_bots(prs)
56
- return prs if @bot_accounts.empty?
57
- prs.reject { |pr| @bot_accounts.include?(pr.dig('node', 'author', 'login')) }
58
- end
59
-
60
- def count_correction_prs(prs)
61
- prs.count { |pr| pr.dig('node', 'headRefName')&.match?(/^#{@fix_branch_names}/) }
62
- end
63
-
64
- def calculate_lead_time(prs)
65
- return "0d 00:00:00" if prs.empty?
66
-
67
- times = prs.map do |pr|
68
- merged_at = Time.parse(pr.dig('node', 'mergedAt'))
69
- created_at = Time.parse(pr.dig('node', 'publishedAt'))
70
- merged_at - created_at
71
- end
72
-
73
- average_time = times.sum.fdiv(times.size)
74
- format_time(average_time)
75
- end
76
-
77
- def format_time(seconds)
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
@@ -1,21 +1,34 @@
1
- require_relative 'client'
2
-
3
- module DevMetrics
4
- class Markdown < Client
5
-
6
- private
7
-
8
- def format_data(period, prs, correction_pr_count)
9
- output = ""
10
- output << "| #{period} | #{prs.count} | #{correction_pr_count} | "
11
- output << "#{((correction_pr_count.to_f / prs.count) * 100).round(2)}% | "
12
- output << "#{calculate_lead_time(prs)} | "
13
- output << "[PRs for #{period}](https://github.com/#{@repo_name}/pulls?q=is%3Apr+merged%3A#{period}) |\n"
14
- output
15
- end
16
-
17
- def output_filename
18
- "metrics_report.md"
19
- end
20
- end
21
- end
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
@@ -0,0 +1,3 @@
1
+ module DevMetrics
2
+ VERSION = "0.2.0"
3
+ end
data/lib/dev_metrics.rb CHANGED
@@ -1,29 +1,35 @@
1
- require_relative 'dev_metrics/markdown'
2
-
3
- module DevMetrics
4
- class Config
5
- attr_accessor :access_token, :repo_name, :bot_accounts, :fix_branch_names
6
-
7
- def initialize
8
- @access_token = nil
9
- @repo_name = nil
10
- @bot_accounts = nil
11
- @fix_branch_names = nil
12
- end
13
- end
14
-
15
- @configuration = Config.new
16
-
17
- def self.configuration
18
- @configuration
19
- end
20
-
21
- def self.configure
22
- yield(configuration)
23
- end
24
-
25
- def self.run(period:, format:)
26
- markdown_processor = format.new(@configuration)
27
- markdown_processor.process(period: period)
28
- end
29
- end
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
@@ -0,0 +1,3 @@
1
+ require 'rspec'
2
+
3
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
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.1.1
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: 2024-09-04 00:00:00.000000000 Z
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
- homepage:
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.2.33
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: []