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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61eeda6933db54b48b7f7ca3941b035da3d04f9f9a8e6d414b8ef9db87a265ea
4
- data.tar.gz: c2477155037f8f078dc7ffcc044c7cba1767915a0334434af365ddf967fcca06
3
+ metadata.gz: 34f8fdcb4bcfa8353303ddc44a30dc227df6ecbf92d87b1f967c927d56ba2480
4
+ data.tar.gz: e77b9e47b4bd91487c0a4deea93df599cd1b31f778d4bcba32b8de8448d18b9b
5
5
  SHA512:
6
- metadata.gz: 3ed77728ffae77e7e4ff2f84affc023902fabbd2c9cd2adce81cc3b40b88eb02d97f1c6ced8e7bf7323452234274b29229f3ea775618ab3324ecc3752f4130e3
7
- data.tar.gz: b7d5c47d5d486014d03ab9829b4f325377aa0f5c07c7e64b5f1692d0d308333352f45ea9eaf3b89f6120a374be8ddfec7fb36d7054b037887ee1d8f9c74a5505
6
+ metadata.gz: 2b3cb0b8caec22116264a81ef34b3d62ca1c616852dc45eece9cfeead345f71901be7f91d3b87cc1d69cf66b30118fe37e90b4c82f9c6d95baf8128375fc1f9d
7
+ data.tar.gz: 0b0f53174f2f572538cac10c90c607c65fbdb421a1bac95ebf5839541ef31f218a0a955ccee0788a5e35cc7eb47755ff79ef4bbaa789b7b78f31d2649b591f63
data/bin/dev_metrics ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # filepath: bin/dev_metrics
3
+
4
+ require_relative "../lib/dev_metrics"
5
+
6
+ DevMetrics.run
@@ -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
- module DevMetrics
9
- class Client
10
- GITHUB_GRAPHQL_API = 'https://api.github.com/graphql'.freeze
11
- ACCESS_TOKEN = ENV.fetch('GITHUB_ACCESS_TOKEN', nil)
12
-
13
- def initialize(config)
14
- @repo_name = config.repo_name
15
- @bot_accounts = config.bot_accounts || []
16
- @access_token = config.access_token || ENV.fetch('GITHUB_ACCESS_TOKEN', nil)
17
- @fix_branch_names = config.fix_branch_names || %w(hotfix fix rollback)
18
- end
19
-
20
- def process(period: Date.today)
21
- uri = URI.parse(GITHUB_GRAPHQL_API)
22
- builder = DevMetrics::QueryBuilder.new(@repo_name)
23
-
24
- auth_request = build_request(uri, builder.access_auth_check)
25
- auth_response = execute_request(uri, auth_request)
26
- check_auth_errors!(auth_response)
27
-
28
- pr_request = build_request(uri, builder.pull_requests_for(period))
29
- pr_response = execute_request(uri, pr_request)
30
-
31
- pr_data = parse_response(pr_response)
32
-
33
- filtered_prs = exclude_bots(pr_data)
34
- correction_pr_count = count_correction_prs(filtered_prs)
35
-
36
- output_metrics(period, filtered_prs, correction_pr_count)
37
- puts "Done. Please check the file #{output_filename}"
38
- end
39
-
40
- private
41
-
42
- def build_request(uri, body)
43
- request = Net::HTTP::Post.new(uri)
44
- request['Authorization'] = "Bearer #{@access_token}"
45
- request.body = body
46
- request
47
- end
48
-
49
- def execute_request(uri, request)
50
- options = { use_ssl: uri.scheme == 'https' }
51
- Net::HTTP.start(uri.hostname, uri.port, options) { |http| http.request(request) }
52
- end
53
-
54
- def check_auth_errors!(response)
55
- if response.code.to_i == 403 || response.body.include?('FORBIDDEN')
56
- raise "Access denied: ensure the access token has permission to access #{@repo_name}"
57
- end
58
- end
59
-
60
- def parse_response(response)
61
- unless response.is_a?(Net::HTTPSuccess)
62
- raise "Failed to fetch data: #{response.message} (#{response.code})"
63
- end
64
-
65
- data = JSON.parse(response.body)
66
- raise "GraphQL error: #{data['errors']}" if data['errors']
67
-
68
- data.dig('data', 'search', 'edges') || []
69
- end
70
-
71
- def exclude_bots(prs)
72
- return prs if @bot_accounts.empty?
73
- prs.reject { |pr| @bot_accounts.include?(pr.dig('node', 'author', 'login')) }
74
- end
75
-
76
- def count_correction_prs(prs)
77
- prs.count { |pr| pr.dig('node', 'headRefName')&.match?(/^#{@fix_branch_names.join('|')}/) }
78
- end
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
@@ -1,25 +1,27 @@
1
- require_relative 'client'
1
+ require_relative 'format_base'
2
2
 
3
3
  module DevMetrics
4
- class Markdown < Client
4
+ class Markdown < FormatBase
5
+
6
+ def call
7
+ write
8
+ end
5
9
 
6
10
  private
7
11
 
8
- def format_data(period, prs, correction_pr_count)
9
- output = ""
10
- output << "| #{period} | #{prs.count} | #{correction_pr_count} | "
11
- output << "#{correction_calc(correction_pr_count, prs)}% | "
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
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 correction_calc(correction_pr_count, prs)
18
- return "-" if prs.empty?
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
- 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
- }
31
- }
32
- }
33
- }
34
- }
35
- GRAPHQL
36
- end
37
-
38
- private
39
-
40
- def to_body(query_string)
41
- { "query" => query_string.strip }.to_json
42
- end
43
- end
44
- end
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
@@ -0,0 +1,3 @@
1
+ module DevMetrics
2
+ VERSION = "0.2.5"
3
+ end
data/lib/dev_metrics.rb CHANGED
@@ -1,29 +1,100 @@
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
- client = format.new(@configuration)
27
- client.process(period: period)
28
- end
29
- end
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.1.2
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-04-17 00:00:00.000000000 Z
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