codepulse 0.1.4 → 0.2.1
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/cli.rb +90 -26
- data/lib/codepulse/formatter.rb +23 -14
- data/lib/codepulse/gh_cli_client.rb +6 -2
- 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: 04c26bfdaa5d9ee142cdec94f5bcf506de48880b575ec1e7efecfaf82dfcf778
|
|
4
|
+
data.tar.gz: e401123dc4dbaa5024ebde9c0a78b42cd328eef4513998a7d1e99f4b82767c0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1bc0d9ad81e7c0c6369f72ab44f2f20bfd9d35e108ee0fde46e07fad8952d9357867167e089c2359dd7de84193a767481954fd98d56ec48dbcbd0e464b5dc3f
|
|
7
|
+
data.tar.gz: 6aafc23fe39af0d50153ca914ca0d8f8ec6b07828776e160b5fa7db27c990775b929705f05d167825613030232d5e85e9ffb2f8844cb400d8a1a627cb285a997
|
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
|
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,58 @@ 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
|
+
|
|
64
113
|
def parse_options
|
|
65
114
|
option_parser.parse!(@argument_list)
|
|
66
115
|
@options[:repo] = @argument_list.shift if @argument_list.any?
|
|
@@ -91,6 +140,10 @@ module Codepulse
|
|
|
91
140
|
@options[:details] = true
|
|
92
141
|
end
|
|
93
142
|
|
|
143
|
+
parser.on("--date-range RANGE", "Date range (YYYY-MM-DD..YYYY-MM-DD)") do |range|
|
|
144
|
+
@options[:date_range] = parse_date_range(range)
|
|
145
|
+
end
|
|
146
|
+
|
|
94
147
|
parser.on("-h", "--help", "Show help") do
|
|
95
148
|
puts parser
|
|
96
149
|
exit
|
|
@@ -106,6 +159,17 @@ module Codepulse
|
|
|
106
159
|
validate_positive_integer(:business_days_back, "business-days")
|
|
107
160
|
end
|
|
108
161
|
|
|
162
|
+
def parse_date_range(range)
|
|
163
|
+
match = range.match(/\A(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})\z/)
|
|
164
|
+
raise OptionParser::InvalidArgument, "invalid date range format, use YYYY-MM-DD..YYYY-MM-DD" unless match
|
|
165
|
+
|
|
166
|
+
from_date = Date.parse(match[1])
|
|
167
|
+
to_date = Date.parse(match[2])
|
|
168
|
+
raise OptionParser::InvalidArgument, "start date must be before end date" if from_date > to_date
|
|
169
|
+
|
|
170
|
+
[from_date, to_date]
|
|
171
|
+
end
|
|
172
|
+
|
|
109
173
|
def validate_state
|
|
110
174
|
return if %w[open closed all].include?(@options[:state])
|
|
111
175
|
|
|
@@ -124,7 +188,8 @@ module Codepulse
|
|
|
124
188
|
limit = effective_limit
|
|
125
189
|
business_days = @options.fetch(:business_days_back)
|
|
126
190
|
status "Fetching pull requests from #{repo} for the last #{business_days} business days..."
|
|
127
|
-
client.pull_requests_with_activity(repo, state: @options.fetch(:state), limit: limit)
|
|
191
|
+
prs = client.pull_requests_with_activity(repo, state: @options.fetch(:state), limit: limit)
|
|
192
|
+
[prs, limit]
|
|
128
193
|
end
|
|
129
194
|
|
|
130
195
|
def effective_limit
|
|
@@ -132,16 +197,22 @@ module Codepulse
|
|
|
132
197
|
|
|
133
198
|
business_days = @options.fetch(:business_days_back)
|
|
134
199
|
calculated = business_days * PRS_PER_BUSINESS_DAY
|
|
135
|
-
[calculated,
|
|
200
|
+
[calculated, MAX_BUSINESS_DAYS_LIMIT].min
|
|
136
201
|
end
|
|
137
202
|
|
|
138
|
-
def apply_filters(pull_requests)
|
|
203
|
+
def apply_filters(pull_requests, start_time:, end_time: nil)
|
|
139
204
|
status "Filtering #{pull_requests.length} pull requests..."
|
|
140
205
|
pull_requests = exclude_closed_unmerged(pull_requests)
|
|
206
|
+
filter_by_time_range(pull_requests, start_time, end_time)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def filter_by_time_range(pull_requests, start_time, end_time)
|
|
210
|
+
pull_requests.select do |pr|
|
|
211
|
+
created_at = parse_time(pr["created_at"])
|
|
212
|
+
next false unless created_at && created_at >= start_time
|
|
141
213
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
pull_requests
|
|
214
|
+
end_time.nil? || created_at <= end_time
|
|
215
|
+
end
|
|
145
216
|
end
|
|
146
217
|
|
|
147
218
|
def calculate_metrics(_client, _repo, pull_requests)
|
|
@@ -159,13 +230,6 @@ module Codepulse
|
|
|
159
230
|
end
|
|
160
231
|
end
|
|
161
232
|
|
|
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
233
|
def detect_repo_from_git
|
|
170
234
|
stdout, _stderr, status = Open3.capture3(@options[:gh_command], "repo", "view", "--json", "nameWithOwner")
|
|
171
235
|
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)
|
|
@@ -62,12 +62,12 @@ module Codepulse
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def build_query(owner, name, states, batch_size, cursor)
|
|
65
|
-
after_clause = cursor ? ", after: \"#{cursor}\"" : ""
|
|
65
|
+
after_clause = cursor ? ", after: \"#{escape_graphql(cursor)}\"" : ""
|
|
66
66
|
states_clause = states.join(", ")
|
|
67
67
|
|
|
68
68
|
<<~GRAPHQL
|
|
69
69
|
{
|
|
70
|
-
repository(owner: "#{owner}", name: "#{name}") {
|
|
70
|
+
repository(owner: "#{escape_graphql(owner)}", name: "#{escape_graphql(name)}") {
|
|
71
71
|
pullRequests(first: #{batch_size}, states: [#{states_clause}], orderBy: {field: CREATED_AT, direction: DESC}#{after_clause}) {
|
|
72
72
|
pageInfo { hasNextPage endCursor }
|
|
73
73
|
nodes {
|
|
@@ -120,6 +120,10 @@ module Codepulse
|
|
|
120
120
|
end
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
def escape_graphql(value)
|
|
124
|
+
value.to_s.gsub("\\", "\\\\").gsub('"', '\\"')
|
|
125
|
+
end
|
|
126
|
+
|
|
123
127
|
def graphql_query(query)
|
|
124
128
|
stdout, stderr, status = Open3.capture3(@command, "api", "graphql", "-f", "query=#{query}")
|
|
125
129
|
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
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.
|