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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8a474f7a151ae2edf2f67c74aa3f8347cef950039b6f87777455206afccdc94
4
- data.tar.gz: 65af6a80141c769edbc30571a818a5af805d6a8e3366a93a8e04c9d1ab5d7d58
3
+ metadata.gz: 04c26bfdaa5d9ee142cdec94f5bcf506de48880b575ec1e7efecfaf82dfcf778
4
+ data.tar.gz: e401123dc4dbaa5024ebde9c0a78b42cd328eef4513998a7d1e99f4b82767c0a
5
5
  SHA512:
6
- metadata.gz: c6664055a79fbc73d73cbd2293cb16ef28f3a8417baad8b97f3c8f97582e82eb0d8a5724ac69d7280d112e90f72a47c24af2d4ed133b3d4e1f45fd9df26e44ff
7
- data.tar.gz: 20b9addb4cbd4bd6d8874edc4509d355e1c2a688ee25d20947b6bb590dd5bd8df8d689725f1c6b25f399bf6e917c92b8cec81f484570f9bee4a110b7ceb836c3
6
+ metadata.gz: e1bc0d9ad81e7c0c6369f72ab44f2f20bfd9d35e108ee0fde46e07fad8952d9357867167e089c2359dd7de84193a767481954fd98d56ec48dbcbd0e464b5dc3f
7
+ data.tar.gz: 6aafc23fe39af0d50153ca914ca0d8f8ec6b07828776e160b5fa7db27c990775b929705f05d167825613030232d5e85e9ffb2f8844cb400d8a1a627cb285a997
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Codepulse
2
2
 
3
- Terminal tool to analyze GitHub pull request pickup times, merge times, and sizes using the `gh` CLI.
3
+ [![Gem Version](https://badge.fury.io/rb/codepulse.svg)](https://badge.fury.io/rb/codepulse)
4
+ ![Build](https://github.com/WorkBright/codepulse/actions/workflows/ci.yml/badge.svg)
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
- bundle install
27
- rake install
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 (5 × business-days) |
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
- bundle exec rubocop
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
- MAX_AUTO_LIMIT = 200
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
- pull_requests = fetch_pull_requests(client, repo)
39
- pull_requests = apply_filters(pull_requests)
40
- metrics = calculate_metrics(client, repo, pull_requests)
41
-
42
- clear_status
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, MAX_AUTO_LIMIT].min
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
- cutoff_time = business_days_cutoff(@options[:business_days_back])
143
- pull_requests = filter_by_business_days(pull_requests, cutoff_time) if cutoff_time
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?
@@ -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 business_days
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
- return "all time" unless business_days
103
-
104
- end_date = Time.now
105
- start_date = calculate_start_date(business_days)
106
- "Last #{business_days} business days (#{format_date(start_date)} - #{format_date(end_date)})"
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
@@ -9,5 +9,5 @@ require_relative "codepulse/formatter"
9
9
  require_relative "codepulse/cli"
10
10
 
11
11
  module Codepulse
12
- VERSION = "0.1.4"
12
+ VERSION = "0.2.1"
13
13
  end
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
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: 2025-12-30 00:00:00.000000000 Z
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.