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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfc914ce32e60fc464e2563c3da95b72889ac156e927f70ff7c0082254e5aaf2
4
- data.tar.gz: c872a7873251365f85bce4713134c60b514d1854ebe56ea888cab1031bd923b8
3
+ metadata.gz: abda4d25af0e7f676c6d343518ebdb358a8fe6452858bfabdfa618ff7822d25f
4
+ data.tar.gz: dbd76b1a6eb56bf62590547efd31a8acfaba98ad1544f8b58bffee66f0e8e2b1
5
5
  SHA512:
6
- metadata.gz: 531c38b79a0b09e73f427cf3d860ca16822df1b7797435375618d19feea280a8318791b3cf943dd3342b0675e5cf67cbc7c847bb35beddafb0e8faedfd9509df
7
- data.tar.gz: 0be7777e2c2640f7a2ed9f3b5939cad157af98de7f825bc64e39cf27ffde1aa0324cd1e6cc8b7b1a7234eb0dadbfeb6330037f4df4b03db978a941f9662bb6bd
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
@@ -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 logic for fetching PRs, reviews, and comments.
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
- 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
 
@@ -122,8 +187,10 @@ module Codepulse
122
187
 
123
188
  def fetch_pull_requests(client, repo)
124
189
  limit = effective_limit
125
- status "Fetching pull requests from #{repo}..."
126
- client.pull_requests(repo, state: @options.fetch(:state), limit: limit)
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, MAX_AUTO_LIMIT].min
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
- cutoff_time = business_days_cutoff(@options[:business_days_back])
142
- pull_requests = filter_by_business_days(pull_requests, cutoff_time) if cutoff_time
143
- pull_requests
215
+ end_time.nil? || created_at <= end_time
216
+ end
144
217
  end
145
218
 
146
- def calculate_metrics(client, repo, pull_requests)
219
+ def calculate_metrics(_client, _repo, pull_requests)
147
220
  status "Calculating metrics for #{pull_requests.length} pull requests..."
148
- calculator = MetricsCalculator.new(client: client)
221
+ calculator = MetricsCalculator.new
149
222
 
150
- pull_requests.each_with_index.map do |pull_request, index|
151
- status " Analyzing PR ##{pull_request["number"]} (#{index + 1}/#{pull_requests.length})..."
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?
@@ -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)
@@ -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 api_get(path, query_params = {})
19
- full_path = "#{path}#{encode_query(query_params)}"
20
- stdout, stderr, status = Open3.capture3(@command, "api", full_path)
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, "gh api #{full_path} failed: #{message}"
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(client:)
73
- @client = client
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
- def metrics_for_pull_request(repository, pull_request)
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(repository, pull_request, created_at)
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
- def find_pickup_event(repository, pull_request, created_at)
105
- pull_number = pull_request["number"]
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
- @client.pull_request_reviews(repository, pull_number),
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
- @client.pull_request_comments(repository, pull_number),
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
- @client.issue_comments(repository, pull_number),
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
@@ -9,5 +9,5 @@ require_relative "codepulse/formatter"
9
9
  require_relative "codepulse/cli"
10
10
 
11
11
  module Codepulse
12
- VERSION = "0.1.3"
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.3
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-27 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.