wassup 0.4.1 → 0.5.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: 2164cf9c9102e1abf2ba7703a50018ae66e2b16ca3ff7202f2b9bb34e250e6b3
4
- data.tar.gz: c5178959ef98124fddc2b6af5901b3fe718a4f1c8cc49fa177ec438aff3453e1
3
+ metadata.gz: 23906c1fdf983d063b8201f142a2cdbc5f44b87c9ec5a7f846287bda1d99223c
4
+ data.tar.gz: d3d9223c00be833ba713f3d89c7c1bab2a443b6e0aa838d10372e61f81ba80c7
5
5
  SHA512:
6
- metadata.gz: 28ce6b6d4b756b0e5f90e99947929293a2edbd1a63dbc51deef9c4ab411ed6638eea892c0e86e896a4d746502ef9b880f7ed6d01d004410832def9aaad45762e
7
- data.tar.gz: 3e8053061acc17ad9aa8fee89ca161d5547a05f2ae3d0a79d4871a8f4be384ad2072564fad67ca4bb7d229a6f1c104e0ec2a90718d1b5b95d81a82f972699fde
6
+ metadata.gz: 9cc7cfa1ebcb3c6a489e58e8f4f8f427ac07ae3bff4dc9eed2ac24a032da7833b75040e46deb7de8da3791b32f7611c17ae812ff2dc799e63827d285150bdb32
7
+ data.tar.gz: 4d18e7c3511a01f655ca75d6a457d313848e6e1ef45069a5a7c202c4a446656573a1ae156267c489d671034e9fbc9a4c765dd1ba7c49acea0da35731d85d6715
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ strategy:
14
+ matrix:
15
+ ruby-version: ['3.0', '3.1', '3.2', '3.3']
16
+
17
+ steps:
18
+ - name: Checkout code
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Ruby ${{ matrix.ruby-version }}
22
+ uses: ruby/setup-ruby@v1
23
+ with:
24
+ ruby-version: ${{ matrix.ruby-version }}
25
+ bundler-cache: true
26
+
27
+ - name: Run tests
28
+ run: bundle exec rspec
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- wassup (0.4.0)
4
+ wassup (0.4.1)
5
5
  curses
6
6
  rest-client
7
7
 
@@ -9,7 +9,7 @@ GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
11
  colorize (0.8.1)
12
- curses (1.4.4)
12
+ curses (1.5.3)
13
13
  diff-lcs (1.4.4)
14
14
  domain_name (0.5.20190701)
15
15
  unf (>= 0.0.5, < 1.0.0)
@@ -45,6 +45,8 @@ GEM
45
45
 
46
46
  PLATFORMS
47
47
  arm64-darwin-21
48
+ arm64-darwin-24
49
+ x86_64-linux
48
50
 
49
51
  DEPENDENCIES
50
52
  colorize
data/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
  <img height="200" alt="Wassup logo" src="https://user-images.githubusercontent.com/401294/145626927-7eb0fda5-c62a-47c8-9422-074b178fd8ef.png" />
3
3
  </h3>
4
4
 
5
+ [![CI](https://github.com/joshdholtz/wassup/actions/workflows/ci.yml/badge.svg)](https://github.com/joshdholtz/wassup/actions/workflows/ci.yml)
5
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg?style=flat)](https://github.com/fastlane/fastlane/blob/master/LICENSE)
6
7
  [![Gem](https://img.shields.io/gem/v/wassup.svg?style=flat)](https://rubygems.org/gems/wassup)
7
8
 
data/bin/wassup CHANGED
@@ -6,7 +6,7 @@ debug = ARGV.delete("--debug")
6
6
  path = ARGV[0] || 'Supfile'
7
7
  port = ARGV[1] || 0
8
8
 
9
- unless File.exists?(path)
9
+ unless File.exist?(path)
10
10
  raise "Missing file: #{path}"
11
11
  end
12
12
 
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/wassup/helpers/github'
4
+
5
+ # Example demonstrating how to use the new GitHub API method
6
+ # to replace manual RestClient requests with rate-limited API calls
7
+
8
+ # Instead of manual RestClient calls like:
9
+ # resp = RestClient::Request.execute(
10
+ # method: :get,
11
+ # url: "https://api.github.com/repos/owner/repo/pulls/123",
12
+ # user: ENV["WASSUP_GITHUB_USERNAME"],
13
+ # password: ENV["WASSUP_GITHUB_ACCESS_TOKEN"]
14
+ # )
15
+ # pr = JSON.parse(resp)
16
+
17
+ # Use the new GitHub API method:
18
+ org = "your-org"
19
+ repo = "your-repo"
20
+ pr_number = 123
21
+
22
+ # Get PR data
23
+ pr = Wassup::Helpers::GitHub.api(path: "/repos/#{org}/#{repo}/pulls/#{pr_number}")
24
+
25
+ puts "PR: #{pr['title']}"
26
+ puts "Draft: #{pr['draft']}"
27
+ puts "Requested reviewers: #{pr['requested_reviewers'].size}"
28
+ puts "Requested teams: #{pr['requested_teams'].size}"
29
+
30
+ # Get PR reviews
31
+ reviews = Wassup::Helpers::GitHub.api(path: "/repos/#{org}/#{repo}/pulls/#{pr_number}/reviews")
32
+
33
+ # Filter out your own reviews
34
+ reviews = reviews.select { |review| review["user"]["login"] != "your-username" }
35
+
36
+ approved = reviews.count { |review| review["state"] == "APPROVED" }
37
+ changes_requested = reviews.count { |review| review["state"] == "CHANGES_REQUESTED" }
38
+
39
+ puts "Approved: #{approved}"
40
+ puts "Changes requested: #{changes_requested}"
41
+
42
+ # Get check runs for the PR's head commit
43
+ head_sha = pr["head"]["sha"]
44
+ check_runs = Wassup::Helpers::GitHub.api(path: "/repos/#{org}/#{repo}/commits/#{head_sha}/check-runs")
45
+
46
+ puts "Check runs: #{check_runs['check_runs'].size}"
47
+
48
+ # Get commit statuses
49
+ statuses = Wassup::Helpers::GitHub.api(path: "/repos/#{org}/#{repo}/commits/#{head_sha}/statuses")
50
+
51
+ puts "Statuses: #{statuses.size}"
52
+
53
+ # Count different status types
54
+ success_count = statuses.count { |status| status["state"] == "success" }
55
+ failure_count = statuses.count { |status| status["state"] == "failure" }
56
+
57
+ puts "Success: #{success_count}, Failures: #{failure_count}"
58
+
59
+ # Example with query parameters
60
+ # Get PRs with specific state
61
+ open_prs = Wassup::Helpers::GitHub.api(
62
+ path: "/repos/#{org}/#{repo}/pulls",
63
+ params: { state: "open", per_page: 10 }
64
+ )
65
+
66
+ puts "Open PRs: #{open_prs.size}"
67
+
68
+ # Example with POST request (creating an issue comment)
69
+ # comment_body = { body: "This is a test comment" }
70
+ # new_comment = Wassup::Helpers::GitHub.api(
71
+ # path: "/repos/#{org}/#{repo}/issues/#{pr_number}/comments",
72
+ # method: :post,
73
+ # body: comment_body
74
+ # )
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env ruby
2
+ # Demo script to show how the rate limiter handles burst requests
3
+
4
+ require_relative '../lib/wassup/helpers/github_rate_limiter'
5
+
6
+ # Mock the RestClient for demo purposes
7
+ class MockRestClient
8
+ def self.execute(**args)
9
+ # Simulate API response time
10
+ sleep(0.05)
11
+
12
+ # Create a mock response with rate limit headers
13
+ response = OpenStruct.new(
14
+ body: '{"items": []}',
15
+ headers: {
16
+ x_ratelimit_remaining: rand(100..4999),
17
+ x_ratelimit_reset: (Time.now + 3600).to_i,
18
+ x_ratelimit_limit: 5000
19
+ }
20
+ )
21
+
22
+ puts "Request processed: #{args[:url]} (remaining: #{response.headers[:x_ratelimit_remaining]})"
23
+ response.body
24
+ end
25
+ end
26
+
27
+ # Replace RestClient with our mock
28
+ module RestClient
29
+ Request = MockRestClient
30
+ end
31
+
32
+ # Test burst handling
33
+ puts "Testing Rate Limiter with Burst Requests"
34
+ puts "=" * 50
35
+
36
+ rate_limiter = Wassup::Helpers::GitHub::RateLimiter.instance
37
+
38
+ # Send 20 requests simultaneously
39
+ puts "\nSending 20 requests simultaneously..."
40
+ start_time = Time.now
41
+
42
+ threads = []
43
+ 20.times do |i|
44
+ threads << Thread.new do
45
+ begin
46
+ rate_limiter.execute_request(
47
+ method: :get,
48
+ url: "https://api.github.com/test/#{i}"
49
+ )
50
+ puts "Request #{i+1} completed"
51
+ rescue => e
52
+ puts "Request #{i+1} failed: #{e.message}"
53
+ end
54
+ end
55
+ end
56
+
57
+ # Wait for all requests to complete
58
+ threads.each(&:join)
59
+
60
+ end_time = Time.now
61
+ puts "\nAll requests completed in #{(end_time - start_time).round(2)} seconds"
62
+
63
+ # Show final status
64
+ puts "\nFinal Rate Limiter Status:"
65
+ puts rate_limiter.status
66
+
67
+ # Cleanup
68
+ rate_limiter.stop_worker
data/lib/wassup/app.rb CHANGED
@@ -172,9 +172,15 @@ module Wassup
172
172
  pane.refresh()
173
173
  end
174
174
  @redraw_panes = false
175
+ # Use doupdate for more efficient screen updates when multiple panes are updated
176
+ Curses.doupdate if @panes.size > 1
175
177
  else
176
178
  @help_pane.refresh()
177
179
  end
180
+
181
+ # Add throttling to prevent busy-waiting and reduce battery drain
182
+ # 10ms delay limits to ~100 FPS while maintaining responsiveness
183
+ sleep(0.01)
178
184
  end
179
185
  ensure
180
186
  Curses.close_screen
@@ -247,8 +253,16 @@ module Wassup
247
253
  end
248
254
  end
249
255
 
256
+ # Ensure main panes are properly drawn before opening help
257
+ @panes.each do |id, pane|
258
+ pane.redraw()
259
+ pane.refresh()
260
+ end
261
+
250
262
  # Maybe find a way to add some a second border or an clear border to add more space to show its floating
251
- @help_pane = Pane.new(0.5, 0.5, 0.25, 0.25, title: "Help", highlight: false, focus_number: nil, interval: 100, show_refresh: false, content_block: content_block, selection_blocks: nil, selection_blocks_description: nil)
263
+ @help_pane = Pane.new(0.5, 0.5, 0.25, 0.25, title: "Help", highlight: false, focus_number: nil, interval: 1000, show_refresh: false, content_block: content_block, selection_blocks: nil, selection_blocks_description: nil)
264
+ # Force initial refresh to show content immediately
265
+ @help_pane.refresh(force: true)
252
266
  else
253
267
  @help_pane.close
254
268
  @help_pane = nil
@@ -3,6 +3,7 @@ module Wassup
3
3
  module GitHub
4
4
  require 'json'
5
5
  require 'rest-client'
6
+ require_relative 'github_rate_limiter'
6
7
 
7
8
  # https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests
8
9
  def self.issues(org:, repo:nil, q: nil)
@@ -22,12 +23,9 @@ module Wassup
22
23
 
23
24
  items = []
24
25
 
25
- resp = RestClient::Request.execute(
26
+ resp = RateLimiter.execute_request(
26
27
  method: :get,
27
- url: "https://api.github.com/search/issues?q=#{q}",
28
- user: ENV["WASSUP_GITHUB_USERNAME"],
29
- password: ENV["WASSUP_GITHUB_ACCESS_TOKEN"],
30
- headers: { "Accept": "application/vnd.github.v3+json", "Content-Type": "application/json" },
28
+ url: "https://api.github.com/search/issues?q=#{q}"
31
29
  )
32
30
  partial_items = JSON.parse(resp)["items"]
33
31
  items += partial_items
@@ -36,11 +34,9 @@ module Wassup
36
34
  end
37
35
 
38
36
  def self.repos(org:)
39
- resp = RestClient::Request.execute(
37
+ resp = RateLimiter.execute_request(
40
38
  method: :get,
41
- url: "https://api.github.com/orgs/#{org}/repos",
42
- user: ENV["WASSUP_GITHUB_USERNAME"],
43
- password: ENV["WASSUP_GITHUB_ACCESS_TOKEN"]
39
+ url: "https://api.github.com/orgs/#{org}/repos"
44
40
  )
45
41
  return JSON.parse(resp)
46
42
  end
@@ -57,11 +53,9 @@ module Wassup
57
53
  end
58
54
 
59
55
  return repos.map do |repo|
60
- resp = RestClient::Request.execute(
56
+ resp = RateLimiter.execute_request(
61
57
  method: :get,
62
- url: "https://api.github.com/repos/#{repo.org}/#{repo.repo}/pulls?per_page=100",
63
- user: ENV["WASSUP_GITHUB_USERNAME"],
64
- password: ENV["WASSUP_GITHUB_ACCESS_TOKEN"]
58
+ url: "https://api.github.com/repos/#{repo.org}/#{repo.repo}/pulls?per_page=100"
65
59
  )
66
60
 
67
61
  JSON.parse(resp)
@@ -69,15 +63,46 @@ module Wassup
69
63
  end
70
64
 
71
65
  def self.releases(org:, repo:)
72
- resp = RestClient::Request.execute(
66
+ resp = RateLimiter.execute_request(
73
67
  method: :get,
74
- url: "https://api.github.com/repos/#{org}/#{repo}/releases",
75
- user: ENV["WASSUP_GITHUB_USERNAME"],
76
- password: ENV["WASSUP_GITHUB_ACCESS_TOKEN"]
68
+ url: "https://api.github.com/repos/#{org}/#{repo}/releases"
77
69
  )
78
70
 
79
71
  return JSON.parse(resp)
80
72
  end
73
+
74
+ # Generic GitHub API method for any endpoint
75
+ def self.api(path:, method: :get, params: {}, body: nil)
76
+ # Handle full URLs or relative paths
77
+ if path.start_with?('http')
78
+ url = path
79
+ else
80
+ # Ensure path starts with /
81
+ path = "/#{path}" unless path.start_with?('/')
82
+ url = "https://api.github.com#{path}"
83
+ end
84
+
85
+ # Add query parameters if provided
86
+ if params.any?
87
+ query_string = params.map { |k, v| "#{k}=#{v}" }.join('&')
88
+ url += "?#{query_string}"
89
+ end
90
+
91
+ # Prepare request options
92
+ options = {}
93
+ if body
94
+ options[:payload] = body.is_a?(String) ? body : body.to_json
95
+ end
96
+
97
+ # Make the request using the rate limiter
98
+ resp = RateLimiter.execute_request(
99
+ method: method,
100
+ url: url,
101
+ **options
102
+ )
103
+
104
+ return JSON.parse(resp)
105
+ end
81
106
  end
82
107
  end
83
108
  end
@@ -0,0 +1,380 @@
1
+ module Wassup
2
+ module Helpers
3
+ module GitHub
4
+ class RateLimiter
5
+ require 'json'
6
+ require 'rest-client'
7
+ require 'thread'
8
+ require 'timeout'
9
+
10
+ attr_reader :remaining, :reset_at, :limit, :queue_size
11
+
12
+ def initialize
13
+ @mutex = Mutex.new
14
+ @queue = []
15
+ @remaining = nil
16
+ @reset_at = nil
17
+ @limit = nil
18
+ @search_remaining = nil
19
+ @search_reset_at = nil
20
+ @search_limit = nil
21
+ @last_request_time = nil
22
+ @worker_threads = []
23
+ @running = false
24
+ @max_queue_size = 20
25
+ @max_concurrent_requests = 5
26
+ @min_delay_between_requests = 1 # seconds
27
+ @min_delay_between_search_requests = 5 # seconds (12 requests/minute to avoid abuse detection)
28
+ @last_search_request_time = nil
29
+ @current_requests = []
30
+ @last_completed_request = nil
31
+ @last_error = nil
32
+ end
33
+
34
+ def start_worker
35
+ return if @running
36
+
37
+ @running = true
38
+ @max_concurrent_requests.times do |i|
39
+ @worker_threads << Thread.new do
40
+ process_queue
41
+ end
42
+ end
43
+ end
44
+
45
+ def stop_worker
46
+ @running = false
47
+ @worker_threads.each(&:join)
48
+ @worker_threads.clear
49
+ end
50
+
51
+ def execute_request(method:, url:, **options)
52
+ future = RequestFuture.new
53
+
54
+ @mutex.synchronize do
55
+ # Reject requests if queue is too large
56
+ if @queue.size >= @max_queue_size
57
+ future.set_error(StandardError.new("Rate limiter queue is full (#{@max_queue_size} requests). Please try again later."))
58
+ return future.get
59
+ end
60
+
61
+ @queue << {
62
+ future: future,
63
+ method: method,
64
+ url: url,
65
+ options: options,
66
+ queued_at: Time.now
67
+ }
68
+ end
69
+
70
+ start_worker
71
+
72
+ # Add timeout to prevent hanging
73
+ begin
74
+ Timeout.timeout(120) do
75
+ future.get
76
+ end
77
+ rescue Timeout::Error
78
+ queue_size = @mutex.synchronize { @queue.size }
79
+ raise StandardError.new("GitHub API request timed out after 120 seconds: #{method} #{url} (queue size: #{queue_size})")
80
+ end
81
+ end
82
+
83
+ def queue_size
84
+ @mutex.synchronize { @queue.size }
85
+ end
86
+
87
+ def status
88
+ @mutex.synchronize do
89
+ next_search_available = nil
90
+ if @last_search_request_time
91
+ next_search_time = @last_search_request_time + @min_delay_between_search_requests
92
+ next_search_available = [(next_search_time - Time.now).to_i, 0].max
93
+ end
94
+
95
+ {
96
+ remaining: @remaining,
97
+ reset_at: @reset_at,
98
+ limit: @limit,
99
+ search_remaining: @search_remaining,
100
+ search_reset_at: @search_reset_at,
101
+ search_limit: @search_limit,
102
+ next_search_available: next_search_available,
103
+ queue_size: @queue.size,
104
+ running: @running,
105
+ worker_threads: @worker_threads.size,
106
+ current_requests: @current_requests.dup,
107
+ last_completed: @last_completed_request,
108
+ last_error: @last_error
109
+ }
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def process_queue
116
+ while @running
117
+ request = nil
118
+
119
+ @mutex.synchronize do
120
+ request = @queue.shift
121
+ end
122
+
123
+ if request
124
+ process_request(request)
125
+ else
126
+ sleep(0.1)
127
+ end
128
+ end
129
+ end
130
+
131
+ def process_request(request)
132
+ queue_time = Time.now - request[:queued_at]
133
+ request_start = Time.now
134
+
135
+ # Track current request
136
+ @mutex.synchronize do
137
+ @current_requests << "#{request[:method]} #{request[:url]}"
138
+ end
139
+
140
+ wait_if_needed
141
+
142
+ begin
143
+ response = make_request(
144
+ method: request[:method],
145
+ url: request[:url],
146
+ **request[:options]
147
+ )
148
+
149
+ request_time = Time.now - request_start
150
+
151
+ update_rate_limit_from_response(response)
152
+ request[:future].set_result(response)
153
+
154
+ # Track completed request
155
+ @mutex.synchronize do
156
+ @current_requests.delete("#{request[:method]} #{request[:url]}")
157
+ @last_completed_request = "#{request[:method]} #{request[:url]} (#{request_time.round(2)}s)"
158
+ end
159
+
160
+ # Add minimum delay between requests to prevent overwhelming the API
161
+ sleep(@min_delay_between_requests)
162
+
163
+ rescue RestClient::TooManyRequests => e
164
+ @mutex.synchronize do
165
+ @current_requests.delete("#{request[:method]} #{request[:url]}")
166
+ @last_error = "Rate limit exceeded: #{request[:method]} #{request[:url]}"
167
+ end
168
+ handle_rate_limit_exceeded(request, e)
169
+ rescue RestClient::Forbidden => e
170
+ @mutex.synchronize do
171
+ @current_requests.delete("#{request[:method]} #{request[:url]}")
172
+ @last_error = "Forbidden: #{request[:method]} #{request[:url]}"
173
+ end
174
+ handle_forbidden_error(request, e)
175
+ rescue => e
176
+ @mutex.synchronize do
177
+ @current_requests.delete("#{request[:method]} #{request[:url]}")
178
+ @last_error = "Error: #{e.message}"
179
+ end
180
+ request[:future].set_error(e)
181
+ end
182
+ end
183
+
184
+ def wait_if_needed
185
+ current_time = Time.now.to_i
186
+
187
+ # Check if this is a search request and enforce minimum delay
188
+ if @last_search_request_time
189
+ time_since_last_search = Time.now - @last_search_request_time
190
+ if time_since_last_search < @min_delay_between_search_requests
191
+ sleep_time = @min_delay_between_search_requests - time_since_last_search
192
+ sleep(sleep_time) if sleep_time > 0
193
+ end
194
+ end
195
+
196
+ # Check search API rate limit
197
+ if @search_remaining && @search_reset_at
198
+ if @search_remaining == 0 && current_time < @search_reset_at
199
+ sleep_time = @search_reset_at - current_time + 1
200
+ sleep(sleep_time) if sleep_time > 0
201
+ return
202
+ end
203
+
204
+ # If search API is low, add extra delay
205
+ if @search_remaining < 5
206
+ sleep(3)
207
+ return
208
+ end
209
+ end
210
+
211
+ # Check regular API rate limit
212
+ if @remaining && @reset_at
213
+ if @remaining == 0 && current_time < @reset_at
214
+ sleep_time = @reset_at - current_time + 1
215
+ sleep(sleep_time) if sleep_time > 0
216
+ return
217
+ end
218
+
219
+ # Calculate dynamic delay based on remaining requests and time
220
+ delay = calculate_dynamic_delay
221
+ sleep(delay) if delay > 0
222
+ end
223
+ end
224
+
225
+ def calculate_dynamic_delay
226
+ return 0 unless @remaining && @reset_at
227
+
228
+ current_time = Time.now.to_i
229
+ time_until_reset = [@reset_at - current_time, 0].max
230
+
231
+ # If we have plenty of requests remaining, no delay needed
232
+ return 0 if @remaining > 50
233
+
234
+ # If we're getting low on requests, add a small delay
235
+ if @remaining < 10
236
+ return 2.0
237
+ elsif @remaining < 25
238
+ return 1.0
239
+ else
240
+ return 0.5
241
+ end
242
+ end
243
+
244
+ def handle_rate_limit_exceeded(request, error)
245
+ # Check if the error response has retry-after header
246
+ retry_after = error.response&.headers&.[](:retry_after)
247
+
248
+ if retry_after
249
+ sleep_time = retry_after.to_i + 1
250
+ elsif @reset_at
251
+ # Fallback to reset time or exponential backoff
252
+ sleep_time = [@reset_at - Time.now.to_i + 1, 60].max
253
+ else
254
+ # Default fallback when no reset time is available
255
+ sleep_time = 60
256
+ end
257
+
258
+ sleep(sleep_time)
259
+
260
+ # Retry the request
261
+ @mutex.synchronize do
262
+ @queue.unshift(request)
263
+ end
264
+ end
265
+
266
+ def handle_forbidden_error(request, error)
267
+ # Check if this is rate limiting disguised as 403 (GitHub API inconsistency)
268
+ response_body = error.response&.body
269
+ if response_body&.include?("rate limit") || response_body&.include?("abuse")
270
+ # Treat as rate limit and retry
271
+ handle_rate_limit_exceeded(request, error)
272
+ else
273
+ # Genuine authentication/authorization error - don't retry
274
+ request[:future].set_error(error)
275
+ end
276
+ end
277
+
278
+ def make_request(method:, url:, **options)
279
+ @last_request_time = Time.now
280
+
281
+ # Track search requests
282
+ if url.include?('/search/')
283
+ @last_search_request_time = Time.now
284
+ end
285
+
286
+ # Set default headers for GitHub API
287
+ headers = {
288
+ "Accept" => "application/vnd.github.v3+json",
289
+ "Content-Type" => "application/json"
290
+ }.merge(options[:headers] || {})
291
+
292
+ # Add authentication
293
+ auth_options = {
294
+ user: ENV["WASSUP_GITHUB_USERNAME"],
295
+ password: ENV["WASSUP_GITHUB_ACCESS_TOKEN"]
296
+ }
297
+
298
+ RestClient::Request.execute(
299
+ method: method,
300
+ url: url,
301
+ headers: headers,
302
+ **auth_options,
303
+ **options.except(:headers)
304
+ )
305
+ end
306
+
307
+ def update_rate_limit_from_response(response)
308
+ # Handle case where response is a string (from tests)
309
+ return unless response.respond_to?(:headers)
310
+
311
+ headers = response.headers
312
+
313
+ # Check for search API rate limit headers first
314
+ if headers[:x_ratelimit_resource] == 'search'
315
+ @search_remaining = headers[:x_ratelimit_remaining]&.to_i
316
+ @search_reset_at = headers[:x_ratelimit_reset]&.to_i
317
+ @search_limit = headers[:x_ratelimit_limit]&.to_i
318
+ else
319
+ # Regular API rate limit headers
320
+ @remaining = headers[:x_ratelimit_remaining]&.to_i
321
+ @reset_at = headers[:x_ratelimit_reset]&.to_i
322
+ @limit = headers[:x_ratelimit_limit]&.to_i
323
+ end
324
+ end
325
+
326
+ # Singleton instance
327
+ @instance = nil
328
+ @instance_mutex = Mutex.new
329
+
330
+ def self.instance
331
+ @instance_mutex.synchronize do
332
+ @instance ||= new
333
+ end
334
+ end
335
+
336
+ def self.execute_request(**args)
337
+ instance.execute_request(**args)
338
+ end
339
+
340
+ def self.status
341
+ instance.status
342
+ end
343
+ end
344
+
345
+ class RequestFuture
346
+ def initialize
347
+ @mutex = Mutex.new
348
+ @condition = ConditionVariable.new
349
+ @result = nil
350
+ @error = nil
351
+ @completed = false
352
+ end
353
+
354
+ def set_result(result)
355
+ @mutex.synchronize do
356
+ @result = result
357
+ @completed = true
358
+ @condition.signal
359
+ end
360
+ end
361
+
362
+ def set_error(error)
363
+ @mutex.synchronize do
364
+ @error = error
365
+ @completed = true
366
+ @condition.signal
367
+ end
368
+ end
369
+
370
+ def get
371
+ @mutex.synchronize do
372
+ @condition.wait(@mutex) until @completed
373
+ raise @error if @error
374
+ @result
375
+ end
376
+ end
377
+ end
378
+ end
379
+ end
380
+ end
data/lib/wassup/pane.rb CHANGED
@@ -45,6 +45,9 @@ module Wassup
45
45
 
46
46
  attr_accessor :port
47
47
 
48
+ # Performance optimization attributes
49
+ attr_accessor :last_content_hash, :last_highlighted_line, :last_focused_state, :parsed_lines_cache
50
+
48
51
  class Content
49
52
  class Row
50
53
  attr_accessor :display
@@ -97,6 +100,12 @@ module Wassup
97
100
  self.show_refresh = show_refresh
98
101
 
99
102
  self.selected_view_index = 0
103
+
104
+ # Initialize performance optimization attributes
105
+ self.last_content_hash = nil
106
+ self.last_highlighted_line = nil
107
+ self.last_focused_state = nil
108
+ self.parsed_lines_cache = {}
100
109
 
101
110
  if !debug
102
111
  self.win.refresh
@@ -324,7 +333,7 @@ module Wassup
324
333
 
325
334
  self.last_refresh_char_at ||= Time.now
326
335
 
327
- if Time.now - self.last_refresh_char_at >= 0.15
336
+ if Time.now - self.last_refresh_char_at >= 0.25
328
337
  self.win.setpos(0, 1)
329
338
  self.win.addstr(self.refresh_char)
330
339
  self.win.refresh
@@ -439,8 +448,59 @@ module Wassup
439
448
  self.subwin.refresh
440
449
  end
441
450
 
451
+ # Cache color parsing results to avoid redundant regex operations
452
+ def parse_line_colors(line)
453
+ return self.parsed_lines_cache[line] if self.parsed_lines_cache.key?(line)
454
+
455
+ splits = line.split(/\[.*?\]/) # returns ["hey something", "other thing", "okay"]
456
+ scans = line.scan(/\[.*?\]/) #returns ["red", "white"]
457
+ scans = scans.map do |str|
458
+ if str.start_with?('[fg=')
459
+ str = str.gsub('[fg=', '').gsub(']','')
460
+ Wassup::Color.new(str)
461
+ else
462
+ str
463
+ end
464
+ end
465
+
466
+ all_parts = splits.zip(scans).flatten.compact
467
+
468
+ # Cache the result
469
+ self.parsed_lines_cache[line] = all_parts
470
+
471
+ # Limit cache size to prevent memory bloat
472
+ if self.parsed_lines_cache.size > 1000
473
+ # Remove oldest entries (keep most recent 500)
474
+ self.parsed_lines_cache = self.parsed_lines_cache.to_a.last(500).to_h
475
+ end
476
+
477
+ all_parts
478
+ end
479
+
442
480
  def virtual_reload
443
481
  return if self.data_lines.nil? || self.data_lines.empty?
482
+
483
+ # Skip optimization for first-time loads or if optimization attributes aren't initialized
484
+ if self.last_content_hash.nil? || self.last_highlighted_line.nil? || self.last_focused_state.nil?
485
+ # Initialize on first run and force redraw
486
+ self.last_content_hash = self.data_lines.hash
487
+ self.last_highlighted_line = self.highlighted_line
488
+ self.last_focused_state = self.focused
489
+ else
490
+ # Check if content has changed to avoid unnecessary redraws
491
+ current_content_hash = self.data_lines.hash
492
+ highlight_changed = self.last_highlighted_line != self.highlighted_line
493
+ focus_changed = self.last_focused_state != self.focused
494
+
495
+ if current_content_hash == self.last_content_hash && !highlight_changed && !focus_changed
496
+ return # No changes, skip redraw
497
+ end
498
+
499
+ # Track what changed for next time
500
+ self.last_content_hash = current_content_hash
501
+ self.last_highlighted_line = self.highlighted_line
502
+ self.last_focused_state = self.focused
503
+ end
444
504
 
445
505
  # TODO: This errored out but might be because thread stuff???
446
506
  self.data_lines[self.top..(self.top+self.subwin.maxy-1)].each_with_index do |line, idx|
@@ -452,18 +512,8 @@ module Wassup
452
512
 
453
513
  self.subwin.attrset(Curses.color_pair(Wassup::Color::Pair::NORMAL))
454
514
 
455
- splits = line.split(/\[.*?\]/) # returns ["hey something", "other thing", "okay"]
456
- scans = line.scan(/\[.*?\]/) #returns ["red", "white"]
457
- scans = scans.map do |str|
458
- if str.start_with?('[fg=')
459
- str = str.gsub('[fg=', '').gsub(']','')
460
- Wassup::Color.new(str)
461
- else
462
- str
463
- end
464
- end
465
-
466
- all_parts = splits.zip(scans).flatten.compact
515
+ # Use cached color parsing instead of expensive regex operations
516
+ all_parts = parse_line_colors(line)
467
517
 
468
518
  char_count = 0
469
519
 
@@ -1,3 +1,3 @@
1
1
  module Wassup
2
- VERSION = "0.4.1"
2
+ VERSION = "0.5.0"
3
3
  end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.2"
data/wassup.gemspec CHANGED
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.description = %q{A scriptable terminal dashboard}
11
11
  spec.homepage = "https://github.com/joshdholtz/wassup"
12
12
  spec.license = "MIT"
13
- spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
13
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
14
14
 
15
15
  spec.metadata["homepage_uri"] = spec.homepage
16
16
  spec.metadata["source_code_uri"] = "https://github.com/joshdholtz/wassup"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wassup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Holtz
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-06-05 00:00:00.000000000 Z
11
+ date: 2025-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: curses
@@ -47,6 +47,7 @@ extensions: []
47
47
  extra_rdoc_files: []
48
48
  files:
49
49
  - ".github/FUNDING.yml"
50
+ - ".github/workflows/ci.yml"
50
51
  - ".gitignore"
51
52
  - ".rspec"
52
53
  - ".travis.yml"
@@ -95,9 +96,11 @@ files:
95
96
  - docs/tsconfig.json
96
97
  - examples/basic/Supfile
97
98
  - examples/debug/Supfile
99
+ - examples/github_api_demo.rb
98
100
  - examples/josh-fastlane/README.md
99
101
  - examples/josh-fastlane/Supfile
100
102
  - examples/josh-fastlane/demo.png
103
+ - examples/rate_limiter_demo.rb
101
104
  - examples/simple/Supfile
102
105
  - examples/starter/Supfile
103
106
  - lib/wassup.rb
@@ -105,6 +108,7 @@ files:
105
108
  - lib/wassup/color.rb
106
109
  - lib/wassup/helpers/circleci.rb
107
110
  - lib/wassup/helpers/github.rb
111
+ - lib/wassup/helpers/github_rate_limiter.rb
108
112
  - lib/wassup/helpers/netlify.rb
109
113
  - lib/wassup/helpers/shortcut.rb
110
114
  - lib/wassup/pane.rb
@@ -114,6 +118,7 @@ files:
114
118
  - lib/wassup/panes/netlify.rb
115
119
  - lib/wassup/panes/shortcut.rb
116
120
  - lib/wassup/version.rb
121
+ - mise.toml
117
122
  - wassup.gemspec
118
123
  homepage: https://github.com/joshdholtz/wassup
119
124
  licenses:
@@ -129,14 +134,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
129
134
  requirements:
130
135
  - - ">="
131
136
  - !ruby/object:Gem::Version
132
- version: 2.3.0
137
+ version: 3.0.0
133
138
  required_rubygems_version: !ruby/object:Gem::Requirement
134
139
  requirements:
135
140
  - - ">="
136
141
  - !ruby/object:Gem::Version
137
142
  version: '0'
138
143
  requirements: []
139
- rubygems_version: 3.2.33
144
+ rubygems_version: 3.4.19
140
145
  signing_key:
141
146
  specification_version: 4
142
147
  summary: A scriptable terminal dashboard