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 +4 -4
- data/.github/workflows/ci.yml +28 -0
- data/Gemfile.lock +4 -2
- data/README.md +1 -0
- data/bin/wassup +1 -1
- data/examples/github_api_demo.rb +74 -0
- data/examples/rate_limiter_demo.rb +68 -0
- data/lib/wassup/app.rb +15 -1
- data/lib/wassup/helpers/github.rb +42 -17
- data/lib/wassup/helpers/github_rate_limiter.rb +380 -0
- data/lib/wassup/pane.rb +63 -13
- data/lib/wassup/version.rb +1 -1
- data/mise.toml +2 -0
- data/wassup.gemspec +1 -1
- metadata +9 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 23906c1fdf983d063b8201f142a2cdbc5f44b87c9ec5a7f846287bda1d99223c
|
4
|
+
data.tar.gz: d3d9223c00be833ba713f3d89c7c1bab2a443b6e0aa838d10372e61f81ba80c7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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.
|
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
|
+
[](https://github.com/joshdholtz/wassup/actions/workflows/ci.yml)
|
5
6
|
[](https://github.com/fastlane/fastlane/blob/master/LICENSE)
|
6
7
|
[](https://rubygems.org/gems/wassup)
|
7
8
|
|
data/bin/wassup
CHANGED
@@ -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:
|
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 =
|
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 =
|
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 =
|
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 =
|
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.
|
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
|
-
|
456
|
-
|
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
|
|
data/lib/wassup/version.rb
CHANGED
data/mise.toml
ADDED
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(">=
|
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
|
+
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:
|
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:
|
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.
|
144
|
+
rubygems_version: 3.4.19
|
140
145
|
signing_key:
|
141
146
|
specification_version: 4
|
142
147
|
summary: A scriptable terminal dashboard
|