codepulse 0.1.3 → 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/README.md +17 -5
- data/lib/codepulse/base_client.rb +1 -67
- data/lib/codepulse/cli.rb +97 -32
- data/lib/codepulse/formatter.rb +23 -14
- data/lib/codepulse/gh_cli_client.rb +112 -4
- data/lib/codepulse/metrics_calculator.rb +10 -9
- data/lib/codepulse.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: abda4d25af0e7f676c6d343518ebdb358a8fe6452858bfabdfa618ff7822d25f
|
|
4
|
+
data.tar.gz: dbd76b1a6eb56bf62590547efd31a8acfaba98ad1544f8b58bffee66f0e8e2b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebeb4512532343b4aba2ec8128ce39d9fa4a6732b4b6b3a815c31f4cbc2c56cb372b8b9da8a2cf4cb2861521c270c6ccbde63c9b885f6eef870106e0f9e9d254
|
|
7
|
+
data.tar.gz: 9c2a4071d6fa1280f08e24b7aa38f05c9932df4e278d30c4c9dd0122155b062174b628b1b271e168059a7d67b7e59c6d554414d8750c62353ddc388670e0fe8c
|
data/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Codepulse
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/codepulse)
|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Measure how fast your team picks up and merges pull requests. Powered by the GitHub CLI.
|
|
4
8
|
|
|
5
9
|
## Installation
|
|
6
10
|
|
|
@@ -23,8 +27,8 @@ gem install codepulse
|
|
|
23
27
|
```sh
|
|
24
28
|
git clone https://github.com/WorkBright/codepulse.git
|
|
25
29
|
cd codepulse
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
gem build codepulse.gemspec
|
|
31
|
+
gem install codepulse-*.gem
|
|
28
32
|
```
|
|
29
33
|
|
|
30
34
|
## Usage
|
|
@@ -35,6 +39,7 @@ codepulse
|
|
|
35
39
|
|
|
36
40
|
# Or specify explicitly
|
|
37
41
|
codepulse owner/repo
|
|
42
|
+
|
|
38
43
|
```
|
|
39
44
|
|
|
40
45
|
### Options
|
|
@@ -42,8 +47,9 @@ codepulse owner/repo
|
|
|
42
47
|
| Option | Description | Default |
|
|
43
48
|
|--------|-------------|---------|
|
|
44
49
|
| `-s`, `--state STATE` | `open`, `closed`, or `all` | `all` |
|
|
45
|
-
| `-l`, `--limit COUNT` | Max PRs to fetch | auto
|
|
50
|
+
| `-l`, `--limit COUNT` | Max PRs to fetch | auto |
|
|
46
51
|
| `--business-days DAYS` | PRs from last N business days | `7` |
|
|
52
|
+
| `--date-range RANGE` | Date range (`YYYY-MM-DD..YYYY-MM-DD`) | — |
|
|
47
53
|
| `--details` | Show individual PR table (sorted by slowest pickup) | off |
|
|
48
54
|
| `--gh-command PATH` | Custom `gh` executable path | `gh` |
|
|
49
55
|
|
|
@@ -61,6 +67,9 @@ codepulse rails/rails --details
|
|
|
61
67
|
|
|
62
68
|
# Last 30 business days, limit 50 PRs
|
|
63
69
|
codepulse rails/rails --business-days 30 --limit 50
|
|
70
|
+
|
|
71
|
+
# Metrics for a specific date range
|
|
72
|
+
codepulse rails/rails --date-range 2025-10-01..2025-12-31
|
|
64
73
|
```
|
|
65
74
|
|
|
66
75
|
## Output
|
|
@@ -105,11 +114,14 @@ codepulse rails/rails --business-days 30 --limit 50
|
|
|
105
114
|
## Development
|
|
106
115
|
|
|
107
116
|
```sh
|
|
117
|
+
# Run local version without installing (picks up your changes immediately)
|
|
118
|
+
bin/codepulse owner/repo
|
|
119
|
+
|
|
108
120
|
# Run tests
|
|
109
121
|
rake test
|
|
110
122
|
|
|
111
123
|
# Lint
|
|
112
|
-
|
|
124
|
+
rubocop
|
|
113
125
|
|
|
114
126
|
# Build and install locally
|
|
115
127
|
rake install
|
|
@@ -1,72 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "uri"
|
|
5
4
|
|
|
6
5
|
module Codepulse
|
|
7
|
-
# Shared GitHub API client
|
|
6
|
+
# Shared GitHub API client utilities.
|
|
8
7
|
module BaseClient
|
|
9
|
-
REPO_FORMAT = %r{\A[^/]+/[^/]+\z}
|
|
10
|
-
|
|
11
|
-
# Fetches pull requests with pagination, then fetches full details for each.
|
|
12
|
-
def pull_requests(repository, state:, limit:)
|
|
13
|
-
ensure_repository_format(repository)
|
|
14
|
-
per_page = [limit, 100].min
|
|
15
|
-
page = 1
|
|
16
|
-
collected = []
|
|
17
|
-
|
|
18
|
-
while collected.length < limit
|
|
19
|
-
response = api_get(
|
|
20
|
-
"/repos/#{repository}/pulls",
|
|
21
|
-
state: state,
|
|
22
|
-
per_page: per_page,
|
|
23
|
-
page: page
|
|
24
|
-
)
|
|
25
|
-
break if response.empty?
|
|
26
|
-
|
|
27
|
-
collected.concat(response)
|
|
28
|
-
break if response.length < per_page
|
|
29
|
-
|
|
30
|
-
page += 1
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
limited = collected.first(limit)
|
|
34
|
-
fetch_pull_request_details(repository, limited)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def pull_request_reviews(repository, number)
|
|
38
|
-
ensure_repository_format(repository)
|
|
39
|
-
api_get("/repos/#{repository}/pulls/#{number}/reviews", per_page: 100)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def pull_request_comments(repository, number)
|
|
43
|
-
ensure_repository_format(repository)
|
|
44
|
-
api_get("/repos/#{repository}/pulls/#{number}/comments", per_page: 100)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def issue_comments(repository, number)
|
|
48
|
-
ensure_repository_format(repository)
|
|
49
|
-
api_get("/repos/#{repository}/issues/#{number}/comments", per_page: 100)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
8
|
private
|
|
53
9
|
|
|
54
|
-
def api_get(_path, _query_params = {})
|
|
55
|
-
raise NotImplementedError, "Subclasses must implement api_get"
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def ensure_repository_format(repository)
|
|
59
|
-
return if repository.to_s.match?(REPO_FORMAT)
|
|
60
|
-
|
|
61
|
-
raise ConfigurationError, "Repository must be in the format owner/name"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def fetch_pull_request_details(repository, pull_requests)
|
|
65
|
-
pull_requests.map do |pull_request|
|
|
66
|
-
api_get("/repos/#{repository}/pulls/#{pull_request["number"]}")
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
10
|
def parse_json(body)
|
|
71
11
|
return {} if body.to_s.strip.empty?
|
|
72
12
|
|
|
@@ -74,11 +14,5 @@ module Codepulse
|
|
|
74
14
|
rescue JSON::ParserError => error
|
|
75
15
|
raise ApiError, "Failed to parse response: #{error.message}"
|
|
76
16
|
end
|
|
77
|
-
|
|
78
|
-
def encode_query(query_params)
|
|
79
|
-
return "" if query_params.empty?
|
|
80
|
-
|
|
81
|
-
"?#{URI.encode_www_form(query_params)}"
|
|
82
|
-
end
|
|
83
17
|
end
|
|
84
18
|
end
|
data/lib/codepulse/cli.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
require "open3"
|
|
5
5
|
require "json"
|
|
6
|
+
require "date"
|
|
6
7
|
|
|
7
8
|
module Codepulse
|
|
8
9
|
class CLI
|
|
@@ -11,7 +12,8 @@ module Codepulse
|
|
|
11
12
|
DEFAULT_STATE = "all"
|
|
12
13
|
DEFAULT_BUSINESS_DAYS = 7
|
|
13
14
|
PRS_PER_BUSINESS_DAY = 5
|
|
14
|
-
|
|
15
|
+
MAX_BUSINESS_DAYS_LIMIT = 500
|
|
16
|
+
MAX_DATE_RANGE_LIMIT = 1000
|
|
15
17
|
|
|
16
18
|
def self.start(argument_list = ARGV)
|
|
17
19
|
new(argument_list).run
|
|
@@ -24,7 +26,8 @@ module Codepulse
|
|
|
24
26
|
limit: nil,
|
|
25
27
|
gh_command: GhCliClient::DEFAULT_COMMAND,
|
|
26
28
|
business_days_back: DEFAULT_BUSINESS_DAYS,
|
|
27
|
-
details: false
|
|
29
|
+
details: false,
|
|
30
|
+
date_range: nil
|
|
28
31
|
}
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -35,17 +38,11 @@ module Codepulse
|
|
|
35
38
|
repo = @options.fetch(:repo)
|
|
36
39
|
client = GhCliClient.new(command: @options.fetch(:gh_command))
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
Formatter.new.output(
|
|
44
|
-
metrics,
|
|
45
|
-
repo: repo,
|
|
46
|
-
detailed: @options.fetch(:details),
|
|
47
|
-
business_days: @options.fetch(:business_days_back)
|
|
48
|
-
)
|
|
41
|
+
if @options[:date_range]
|
|
42
|
+
run_date_range_mode(client, repo)
|
|
43
|
+
else
|
|
44
|
+
run_business_days_mode(client, repo)
|
|
45
|
+
end
|
|
49
46
|
rescue OptionParser::ParseError => error
|
|
50
47
|
$stderr.puts "Error: #{error.message}"
|
|
51
48
|
$stderr.puts
|
|
@@ -61,6 +58,59 @@ module Codepulse
|
|
|
61
58
|
|
|
62
59
|
private
|
|
63
60
|
|
|
61
|
+
def run_business_days_mode(client, repo)
|
|
62
|
+
pull_requests, limit = fetch_pull_requests(client, repo)
|
|
63
|
+
warn_if_limit_reached(pull_requests.length, limit)
|
|
64
|
+
cutoff = business_days_cutoff(@options[:business_days_back])
|
|
65
|
+
pull_requests = apply_filters(pull_requests, start_time: cutoff)
|
|
66
|
+
metrics = calculate_metrics(client, repo, pull_requests)
|
|
67
|
+
|
|
68
|
+
clear_status
|
|
69
|
+
Formatter.new.output(
|
|
70
|
+
metrics,
|
|
71
|
+
repo: repo,
|
|
72
|
+
detailed: @options.fetch(:details),
|
|
73
|
+
business_days: @options.fetch(:business_days_back)
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run_date_range_mode(client, repo)
|
|
78
|
+
from_date, to_date = @options[:date_range]
|
|
79
|
+
start_time = date_to_time(from_date)
|
|
80
|
+
end_time = end_of_day(date_to_time(to_date))
|
|
81
|
+
pull_requests, limit = fetch_pull_requests_for_range(client, repo, [start_time, end_time])
|
|
82
|
+
warn_if_limit_reached(pull_requests.length, limit)
|
|
83
|
+
pull_requests = apply_filters(pull_requests, start_time: start_time, end_time: end_time)
|
|
84
|
+
metrics = calculate_metrics(client, repo, pull_requests)
|
|
85
|
+
|
|
86
|
+
clear_status
|
|
87
|
+
Formatter.new.output(
|
|
88
|
+
metrics,
|
|
89
|
+
repo: repo,
|
|
90
|
+
detailed: @options.fetch(:details),
|
|
91
|
+
date_range: [start_time, end_time]
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def date_to_time(date)
|
|
96
|
+
Time.new(date.year, date.month, date.day)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def fetch_pull_requests_for_range(client, repo, date_range)
|
|
100
|
+
start_time, end_time = date_range
|
|
101
|
+
status "Fetching pull requests from #{repo} for #{start_time.strftime('%b %Y')} - #{end_time.strftime('%b %Y')}..."
|
|
102
|
+
limit = @options[:limit] || MAX_DATE_RANGE_LIMIT
|
|
103
|
+
prs = client.pull_requests_with_activity(repo, state: @options.fetch(:state), limit: limit)
|
|
104
|
+
[prs, limit]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def warn_if_limit_reached(count, limit)
|
|
108
|
+
return unless count >= limit
|
|
109
|
+
|
|
110
|
+
$stderr.puts "Warning: Fetched #{count} PRs (limit reached). Results may be incomplete. Use --limit to fetch more."
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
|
|
64
114
|
def parse_options
|
|
65
115
|
option_parser.parse!(@argument_list)
|
|
66
116
|
@options[:repo] = @argument_list.shift if @argument_list.any?
|
|
@@ -91,6 +141,10 @@ module Codepulse
|
|
|
91
141
|
@options[:details] = true
|
|
92
142
|
end
|
|
93
143
|
|
|
144
|
+
parser.on("--date-range RANGE", "Date range (YYYY-MM-DD..YYYY-MM-DD)") do |range|
|
|
145
|
+
@options[:date_range] = parse_date_range(range)
|
|
146
|
+
end
|
|
147
|
+
|
|
94
148
|
parser.on("-h", "--help", "Show help") do
|
|
95
149
|
puts parser
|
|
96
150
|
exit
|
|
@@ -106,6 +160,17 @@ module Codepulse
|
|
|
106
160
|
validate_positive_integer(:business_days_back, "business-days")
|
|
107
161
|
end
|
|
108
162
|
|
|
163
|
+
def parse_date_range(range)
|
|
164
|
+
match = range.match(/\A(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})\z/)
|
|
165
|
+
raise OptionParser::InvalidArgument, "invalid date range format, use YYYY-MM-DD..YYYY-MM-DD" unless match
|
|
166
|
+
|
|
167
|
+
from_date = Date.parse(match[1])
|
|
168
|
+
to_date = Date.parse(match[2])
|
|
169
|
+
raise OptionParser::InvalidArgument, "start date must be before end date" if from_date > to_date
|
|
170
|
+
|
|
171
|
+
[from_date, to_date]
|
|
172
|
+
end
|
|
173
|
+
|
|
109
174
|
def validate_state
|
|
110
175
|
return if %w[open closed all].include?(@options[:state])
|
|
111
176
|
|
|
@@ -122,8 +187,10 @@ module Codepulse
|
|
|
122
187
|
|
|
123
188
|
def fetch_pull_requests(client, repo)
|
|
124
189
|
limit = effective_limit
|
|
125
|
-
|
|
126
|
-
|
|
190
|
+
business_days = @options.fetch(:business_days_back)
|
|
191
|
+
status "Fetching pull requests from #{repo} for the last #{business_days} business days..."
|
|
192
|
+
prs = client.pull_requests_with_activity(repo, state: @options.fetch(:state), limit: limit)
|
|
193
|
+
[prs, limit]
|
|
127
194
|
end
|
|
128
195
|
|
|
129
196
|
def effective_limit
|
|
@@ -131,25 +198,30 @@ module Codepulse
|
|
|
131
198
|
|
|
132
199
|
business_days = @options.fetch(:business_days_back)
|
|
133
200
|
calculated = business_days * PRS_PER_BUSINESS_DAY
|
|
134
|
-
[calculated,
|
|
201
|
+
[calculated, MAX_BUSINESS_DAYS_LIMIT].min
|
|
135
202
|
end
|
|
136
203
|
|
|
137
|
-
def apply_filters(pull_requests)
|
|
204
|
+
def apply_filters(pull_requests, start_time:, end_time: nil)
|
|
138
205
|
status "Filtering #{pull_requests.length} pull requests..."
|
|
139
206
|
pull_requests = exclude_closed_unmerged(pull_requests)
|
|
207
|
+
filter_by_time_range(pull_requests, start_time, end_time)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def filter_by_time_range(pull_requests, start_time, end_time)
|
|
211
|
+
pull_requests.select do |pr|
|
|
212
|
+
created_at = parse_time(pr["created_at"])
|
|
213
|
+
next false unless created_at && created_at >= start_time
|
|
140
214
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
pull_requests
|
|
215
|
+
end_time.nil? || created_at <= end_time
|
|
216
|
+
end
|
|
144
217
|
end
|
|
145
218
|
|
|
146
|
-
def calculate_metrics(
|
|
219
|
+
def calculate_metrics(_client, _repo, pull_requests)
|
|
147
220
|
status "Calculating metrics for #{pull_requests.length} pull requests..."
|
|
148
|
-
calculator = MetricsCalculator.new
|
|
221
|
+
calculator = MetricsCalculator.new
|
|
149
222
|
|
|
150
|
-
pull_requests.
|
|
151
|
-
|
|
152
|
-
calculator.metrics_for_pull_request(repo, pull_request)
|
|
223
|
+
pull_requests.map do |pull_request|
|
|
224
|
+
calculator.metrics_for_pull_request(pull_request)
|
|
153
225
|
end
|
|
154
226
|
end
|
|
155
227
|
|
|
@@ -159,13 +231,6 @@ module Codepulse
|
|
|
159
231
|
end
|
|
160
232
|
end
|
|
161
233
|
|
|
162
|
-
def filter_by_business_days(pull_requests, cutoff_time)
|
|
163
|
-
pull_requests.select do |pull_request|
|
|
164
|
-
created_at = parse_time(pull_request["created_at"])
|
|
165
|
-
created_at && created_at >= cutoff_time
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
234
|
def detect_repo_from_git
|
|
170
235
|
stdout, _stderr, status = Open3.capture3(@options[:gh_command], "repo", "view", "--json", "nameWithOwner")
|
|
171
236
|
return nil unless status.success?
|
data/lib/codepulse/formatter.rb
CHANGED
|
@@ -8,9 +8,9 @@ module Codepulse
|
|
|
8
8
|
MIN_FOR_P95 = 50 # Minimum data points to show p95
|
|
9
9
|
|
|
10
10
|
# Main entry point: outputs metrics as a formatted report.
|
|
11
|
-
def output(metrics, repo:, detailed: true, business_days: nil)
|
|
11
|
+
def output(metrics, repo:, detailed: true, business_days: nil, date_range: nil)
|
|
12
12
|
if metrics.empty?
|
|
13
|
-
print_no_pull_requests_message(repo, business_days)
|
|
13
|
+
print_no_pull_requests_message(repo, business_days, date_range)
|
|
14
14
|
return
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -22,14 +22,18 @@ module Codepulse
|
|
|
22
22
|
excluded: without_pickup,
|
|
23
23
|
repo: repo,
|
|
24
24
|
business_days: business_days,
|
|
25
|
+
date_range: date_range,
|
|
25
26
|
detailed: detailed
|
|
26
27
|
)
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
private
|
|
30
31
|
|
|
31
|
-
def print_no_pull_requests_message(repo, business_days)
|
|
32
|
-
if
|
|
32
|
+
def print_no_pull_requests_message(repo, business_days, date_range = nil)
|
|
33
|
+
if date_range
|
|
34
|
+
start_time, end_time = date_range
|
|
35
|
+
puts "No pull requests found for #{repo} from #{format_date(start_time)} to #{format_date(end_time)}."
|
|
36
|
+
elsif business_days
|
|
33
37
|
puts "No pull requests found for #{repo} in the last #{business_days} business days."
|
|
34
38
|
puts "To look further back, use: codepulse --business-days N #{repo}"
|
|
35
39
|
else
|
|
@@ -37,8 +41,8 @@ module Codepulse
|
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
|
|
40
|
-
def output_report(metrics, excluded:, repo:, business_days:, detailed:)
|
|
41
|
-
print_report_header(repo, business_days)
|
|
44
|
+
def output_report(metrics, excluded:, repo:, business_days:, date_range:, detailed:)
|
|
45
|
+
print_report_header(repo, business_days, date_range)
|
|
42
46
|
puts
|
|
43
47
|
print_definitions
|
|
44
48
|
puts
|
|
@@ -90,20 +94,25 @@ module Codepulse
|
|
|
90
94
|
output_excluded_prs(excluded)
|
|
91
95
|
end
|
|
92
96
|
|
|
93
|
-
def print_report_header(repo, business_days)
|
|
94
|
-
time_period = build_time_period(business_days)
|
|
97
|
+
def print_report_header(repo, business_days, date_range = nil)
|
|
98
|
+
time_period = build_time_period(business_days, date_range)
|
|
95
99
|
puts "=" * REPORT_WIDTH
|
|
96
100
|
puts " Codepulse PR Metrics Report | #{time_period}"
|
|
97
101
|
puts " #{repo}"
|
|
98
102
|
puts "=" * REPORT_WIDTH
|
|
99
103
|
end
|
|
100
104
|
|
|
101
|
-
def build_time_period(business_days)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
def build_time_period(business_days, date_range = nil)
|
|
106
|
+
if date_range
|
|
107
|
+
start_time, end_time = date_range
|
|
108
|
+
"#{format_date(start_time)} - #{format_date(end_time)}"
|
|
109
|
+
elsif business_days
|
|
110
|
+
end_date = Time.now
|
|
111
|
+
start_date = calculate_start_date(business_days)
|
|
112
|
+
"Last #{business_days} business days (#{format_date(start_date)} - #{format_date(end_date)})"
|
|
113
|
+
else
|
|
114
|
+
"all time"
|
|
115
|
+
end
|
|
107
116
|
end
|
|
108
117
|
|
|
109
118
|
def calculate_start_date(business_days)
|
|
@@ -1,27 +1,135 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
+
require "json"
|
|
4
5
|
|
|
5
6
|
module Codepulse
|
|
6
7
|
class GhCliClient
|
|
7
8
|
include BaseClient
|
|
8
9
|
|
|
9
10
|
DEFAULT_COMMAND = "gh"
|
|
11
|
+
GRAPHQL_PAGE_SIZE = 50
|
|
12
|
+
ACTIVITY_PAGE_SIZE = 50
|
|
10
13
|
|
|
11
14
|
def initialize(command: DEFAULT_COMMAND)
|
|
12
15
|
@command = command
|
|
13
16
|
verify_cli_available
|
|
14
17
|
end
|
|
15
18
|
|
|
19
|
+
# Fetches PRs with reviews and comments in a single GraphQL query.
|
|
20
|
+
# Returns array of PR hashes with embedded :reviews, :review_comments, :issue_comments.
|
|
21
|
+
def pull_requests_with_activity(repository, state:, limit:)
|
|
22
|
+
owner, name = repository.split("/", 2)
|
|
23
|
+
raise ConfigurationError, "Repository must be in the format owner/name" unless owner && name
|
|
24
|
+
|
|
25
|
+
fetch_all_pull_requests(owner, name, graphql_states(state), limit)
|
|
26
|
+
end
|
|
27
|
+
|
|
16
28
|
private
|
|
17
29
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
30
|
+
def fetch_all_pull_requests(owner, name, states, limit)
|
|
31
|
+
pull_requests = []
|
|
32
|
+
cursor = nil
|
|
33
|
+
|
|
34
|
+
loop do
|
|
35
|
+
batch_size = [GRAPHQL_PAGE_SIZE, limit - pull_requests.length].min
|
|
36
|
+
response = graphql_query(build_query(owner, name, states, batch_size, cursor))
|
|
37
|
+
nodes, page_info = extract_pr_data(response)
|
|
38
|
+
break if nodes.empty?
|
|
39
|
+
|
|
40
|
+
pull_requests.concat(nodes.map { |node| transform_graphql_pr(node) })
|
|
41
|
+
break if pull_requests.length >= limit || !page_info["hasNextPage"]
|
|
42
|
+
|
|
43
|
+
cursor = page_info["endCursor"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
pull_requests.first(limit)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def extract_pr_data(response)
|
|
50
|
+
pr_data = response.dig("data", "repository", "pullRequests") || {}
|
|
51
|
+
nodes = pr_data["nodes"] || []
|
|
52
|
+
page_info = pr_data["pageInfo"] || {}
|
|
53
|
+
[nodes, page_info]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def graphql_states(state)
|
|
57
|
+
case state
|
|
58
|
+
when "open" then %w[OPEN]
|
|
59
|
+
when "closed" then %w[CLOSED MERGED]
|
|
60
|
+
else %w[OPEN CLOSED MERGED]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_query(owner, name, states, batch_size, cursor)
|
|
65
|
+
after_clause = cursor ? ", after: \"#{escape_graphql(cursor)}\"" : ""
|
|
66
|
+
states_clause = states.join(", ")
|
|
67
|
+
|
|
68
|
+
<<~GRAPHQL
|
|
69
|
+
{
|
|
70
|
+
repository(owner: "#{escape_graphql(owner)}", name: "#{escape_graphql(name)}") {
|
|
71
|
+
pullRequests(first: #{batch_size}, states: [#{states_clause}], orderBy: {field: CREATED_AT, direction: DESC}#{after_clause}) {
|
|
72
|
+
pageInfo { hasNextPage endCursor }
|
|
73
|
+
nodes {
|
|
74
|
+
number title state createdAt mergedAt additions deletions changedFiles
|
|
75
|
+
author { login }
|
|
76
|
+
reviews(first: #{ACTIVITY_PAGE_SIZE}) { nodes { submittedAt author { login } } }
|
|
77
|
+
reviewThreads(first: #{ACTIVITY_PAGE_SIZE}) { nodes { comments(first: #{ACTIVITY_PAGE_SIZE}) { nodes { createdAt author { login } } } } }
|
|
78
|
+
comments(first: #{ACTIVITY_PAGE_SIZE}) { nodes { createdAt author { login } } }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
GRAPHQL
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def transform_graphql_pr(node)
|
|
87
|
+
{
|
|
88
|
+
"number" => node["number"],
|
|
89
|
+
"title" => node["title"],
|
|
90
|
+
"state" => node["state"]&.downcase,
|
|
91
|
+
"created_at" => node["createdAt"],
|
|
92
|
+
"merged_at" => node["mergedAt"],
|
|
93
|
+
"additions" => node["additions"],
|
|
94
|
+
"deletions" => node["deletions"],
|
|
95
|
+
"changed_files" => node["changedFiles"],
|
|
96
|
+
"user" => { "login" => node.dig("author", "login") },
|
|
97
|
+
"reviews" => transform_reviews(node),
|
|
98
|
+
"review_comments" => transform_review_comments(node),
|
|
99
|
+
"issue_comments" => transform_issue_comments(node)
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def transform_reviews(node)
|
|
104
|
+
(node.dig("reviews", "nodes") || []).map do |review|
|
|
105
|
+
{ "submitted_at" => review["submittedAt"], "user" => { "login" => review.dig("author", "login") } }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def transform_review_comments(node)
|
|
110
|
+
(node.dig("reviewThreads", "nodes") || []).flat_map do |thread|
|
|
111
|
+
(thread.dig("comments", "nodes") || []).map do |comment|
|
|
112
|
+
{ "created_at" => comment["createdAt"], "user" => { "login" => comment.dig("author", "login") } }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def transform_issue_comments(node)
|
|
118
|
+
(node.dig("comments", "nodes") || []).map do |comment|
|
|
119
|
+
{ "created_at" => comment["createdAt"], "user" => { "login" => comment.dig("author", "login") } }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def escape_graphql(value)
|
|
124
|
+
value.to_s.gsub("\\", "\\\\").gsub('"', '\\"')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def graphql_query(query)
|
|
128
|
+
stdout, stderr, status = Open3.capture3(@command, "api", "graphql", "-f", "query=#{query}")
|
|
21
129
|
|
|
22
130
|
unless status.success?
|
|
23
131
|
message = stderr.to_s.strip.empty? ? stdout.to_s.strip : stderr.to_s.strip
|
|
24
|
-
raise ApiError, "
|
|
132
|
+
raise ApiError, "GraphQL query failed: #{message}"
|
|
25
133
|
end
|
|
26
134
|
|
|
27
135
|
parse_json(stdout)
|
|
@@ -69,15 +69,16 @@ module Codepulse
|
|
|
69
69
|
"allstar[bot]"
|
|
70
70
|
].freeze
|
|
71
71
|
|
|
72
|
-
def initialize
|
|
73
|
-
|
|
72
|
+
def initialize
|
|
73
|
+
# No client needed - data is pre-fetched via GraphQL
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
# Returns a hash of metrics for a single PR.
|
|
77
|
-
|
|
77
|
+
# Expects pull_request to include :reviews, :review_comments, :issue_comments from GraphQL.
|
|
78
|
+
def metrics_for_pull_request(pull_request)
|
|
78
79
|
created_at = parse_time(pull_request["created_at"])
|
|
79
80
|
merged_at = parse_time(pull_request["merged_at"])
|
|
80
|
-
pickup_event = find_pickup_event(
|
|
81
|
+
pickup_event = find_pickup_event(pull_request, created_at)
|
|
81
82
|
pickup_seconds = pickup_event ? business_seconds_between(created_at, pickup_event.fetch(:timestamp)) : nil
|
|
82
83
|
merge_seconds = merged_at && created_at ? business_seconds_between(created_at, merged_at) : nil
|
|
83
84
|
|
|
@@ -101,12 +102,12 @@ module Codepulse
|
|
|
101
102
|
private
|
|
102
103
|
|
|
103
104
|
# Finds the first non-author, non-bot response (review, comment, or issue comment).
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
# Uses pre-fetched data from GraphQL query.
|
|
106
|
+
def find_pickup_event(pull_request, created_at)
|
|
106
107
|
author_login = pull_request.dig("user", "login")
|
|
107
108
|
|
|
108
109
|
review_event = earliest_event(
|
|
109
|
-
|
|
110
|
+
pull_request["reviews"] || [],
|
|
110
111
|
author_login: author_login,
|
|
111
112
|
time_key: "submitted_at",
|
|
112
113
|
actor_path: %w[user login],
|
|
@@ -114,7 +115,7 @@ module Codepulse
|
|
|
114
115
|
)
|
|
115
116
|
|
|
116
117
|
review_comment_event = earliest_event(
|
|
117
|
-
|
|
118
|
+
pull_request["review_comments"] || [],
|
|
118
119
|
author_login: author_login,
|
|
119
120
|
time_key: "created_at",
|
|
120
121
|
actor_path: %w[user login],
|
|
@@ -122,7 +123,7 @@ module Codepulse
|
|
|
122
123
|
)
|
|
123
124
|
|
|
124
125
|
issue_comment_event = earliest_event(
|
|
125
|
-
|
|
126
|
+
pull_request["issue_comments"] || [],
|
|
126
127
|
author_login: author_login,
|
|
127
128
|
time_key: "created_at",
|
|
128
129
|
actor_path: %w[user login],
|
data/lib/codepulse.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: codepulse
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick Navarro
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 2026-01-11 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
12
|
description: Terminal tool to analyze GitHub pull request pickup times, merge times,
|
|
13
13
|
and sizes using the gh CLI.
|