codepulse 0.1.4 → 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: a8a474f7a151ae2edf2f67c74aa3f8347cef950039b6f87777455206afccdc94
4
- data.tar.gz: 65af6a80141c769edbc30571a818a5af805d6a8e3366a93a8e04c9d1ab5d7d58
3
+ metadata.gz: abda4d25af0e7f676c6d343518ebdb358a8fe6452858bfabdfa618ff7822d25f
4
+ data.tar.gz: dbd76b1a6eb56bf62590547efd31a8acfaba98ad1544f8b58bffee66f0e8e2b1
5
5
  SHA512:
6
- metadata.gz: c6664055a79fbc73d73cbd2293cb16ef28f3a8417baad8b97f3c8f97582e82eb0d8a5724ac69d7280d112e90f72a47c24af2d4ed133b3d4e1f45fd9df26e44ff
7
- data.tar.gz: 20b9addb4cbd4bd6d8874edc4509d355e1c2a688ee25d20947b6bb590dd5bd8df8d689725f1c6b25f399bf6e917c92b8cec81f484570f9bee4a110b7ceb836c3
6
+ metadata.gz: ebeb4512532343b4aba2ec8128ce39d9fa4a6732b4b6b3a815c31f4cbc2c56cb372b8b9da8a2cf4cb2861521c270c6ccbde63c9b885f6eef870106e0f9e9d254
7
+ data.tar.gz: 9c2a4071d6fa1280f08e24b7aa38f05c9932df4e278d30c4c9dd0122155b062174b628b1b271e168059a7d67b7e59c6d554414d8750c62353ddc388670e0fe8c
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,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
 
@@ -124,7 +189,8 @@ module Codepulse
124
189
  limit = effective_limit
125
190
  business_days = @options.fetch(:business_days_back)
126
191
  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)
192
+ prs = client.pull_requests_with_activity(repo, state: @options.fetch(:state), limit: limit)
193
+ [prs, limit]
128
194
  end
129
195
 
130
196
  def effective_limit
@@ -132,16 +198,22 @@ module Codepulse
132
198
 
133
199
  business_days = @options.fetch(:business_days_back)
134
200
  calculated = business_days * PRS_PER_BUSINESS_DAY
135
- [calculated, MAX_AUTO_LIMIT].min
201
+ [calculated, MAX_BUSINESS_DAYS_LIMIT].min
136
202
  end
137
203
 
138
- def apply_filters(pull_requests)
204
+ def apply_filters(pull_requests, start_time:, end_time: nil)
139
205
  status "Filtering #{pull_requests.length} pull requests..."
140
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
141
214
 
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
215
+ end_time.nil? || created_at <= end_time
216
+ end
145
217
  end
146
218
 
147
219
  def calculate_metrics(_client, _repo, pull_requests)
@@ -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?
@@ -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.0"
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.0
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.