wassup 0.4.0 → 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 +3 -2
- data/examples/github_api_demo.rb +74 -0
- data/examples/rate_limiter_demo.rb +68 -0
- data/lib/wassup/app.rb +21 -4
- data/lib/wassup/helpers/github.rb +42 -17
- data/lib/wassup/helpers/github_rate_limiter.rb +380 -0
- data/lib/wassup/pane.rb +97 -18
- 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
|
+
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
@@ -4,13 +4,14 @@ require 'wassup'
|
|
4
4
|
|
5
5
|
debug = ARGV.delete("--debug")
|
6
6
|
path = ARGV[0] || 'Supfile'
|
7
|
+
port = ARGV[1] || 0
|
7
8
|
|
8
|
-
unless File.
|
9
|
+
unless File.exist?(path)
|
9
10
|
raise "Missing file: #{path}"
|
10
11
|
end
|
11
12
|
|
12
13
|
if debug
|
13
14
|
Wassup::App.debug(path: path)
|
14
15
|
else
|
15
|
-
Wassup::App.start(path: path)
|
16
|
+
Wassup::App.start(path: path, port: port.to_i)
|
16
17
|
end
|
@@ -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
@@ -2,7 +2,7 @@ require 'curses'
|
|
2
2
|
|
3
3
|
module Wassup
|
4
4
|
class App
|
5
|
-
def self.start(path:)
|
5
|
+
def self.start(path:, port:)
|
6
6
|
Curses.init_screen
|
7
7
|
Curses.start_color
|
8
8
|
Curses.curs_set(0) # Invisible cursor
|
@@ -17,7 +17,7 @@ module Wassup
|
|
17
17
|
#Curses.init_pair(Curses::COLOR_BLUE,Curses::COLOR_BLUE,Curses::COLOR_BLACK)
|
18
18
|
#Curses.init_pair(Curses::COLOR_RED,Curses::COLOR_RED,Curses::COLOR_BLACK)
|
19
19
|
|
20
|
-
app = App.new(path: path)
|
20
|
+
app = App.new(path: path, port: port)
|
21
21
|
end
|
22
22
|
|
23
23
|
def self.debug(path:)
|
@@ -78,6 +78,7 @@ module Wassup
|
|
78
78
|
content_block: pane_builder.content_block,
|
79
79
|
selection_blocks: pane_builder.selection_blocks,
|
80
80
|
selection_blocks_description: pane_builder.selection_blocks_description,
|
81
|
+
port: self.port,
|
81
82
|
debug: debug
|
82
83
|
)
|
83
84
|
pane.focus_handler = @focus_handler
|
@@ -85,9 +86,11 @@ module Wassup
|
|
85
86
|
end
|
86
87
|
|
87
88
|
attr_accessor :panes
|
89
|
+
attr_accessor :port
|
88
90
|
attr_accessor :debug
|
89
91
|
|
90
|
-
def initialize(path:, debug: false)
|
92
|
+
def initialize(path:, port: nil, debug: false)
|
93
|
+
@port = port
|
91
94
|
@hidden_pane = nil
|
92
95
|
@help_pane = nil
|
93
96
|
@focused_pane = nil
|
@@ -169,9 +172,15 @@ module Wassup
|
|
169
172
|
pane.refresh()
|
170
173
|
end
|
171
174
|
@redraw_panes = false
|
175
|
+
# Use doupdate for more efficient screen updates when multiple panes are updated
|
176
|
+
Curses.doupdate if @panes.size > 1
|
172
177
|
else
|
173
178
|
@help_pane.refresh()
|
174
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)
|
175
184
|
end
|
176
185
|
ensure
|
177
186
|
Curses.close_screen
|
@@ -244,8 +253,16 @@ module Wassup
|
|
244
253
|
end
|
245
254
|
end
|
246
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
|
+
|
247
262
|
# Maybe find a way to add some a second border or an clear border to add more space to show its floating
|
248
|
-
@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)
|
249
266
|
else
|
250
267
|
@help_pane.close
|
251
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
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'curses'
|
2
2
|
|
3
|
+
require 'socket'
|
3
4
|
require 'time'
|
4
5
|
|
5
6
|
module Wassup
|
@@ -42,6 +43,11 @@ module Wassup
|
|
42
43
|
|
43
44
|
attr_accessor :win_height, :win_width, :win_top, :win_left
|
44
45
|
|
46
|
+
attr_accessor :port
|
47
|
+
|
48
|
+
# Performance optimization attributes
|
49
|
+
attr_accessor :last_content_hash, :last_highlighted_line, :last_focused_state, :parsed_lines_cache
|
50
|
+
|
45
51
|
class Content
|
46
52
|
class Row
|
47
53
|
attr_accessor :display
|
@@ -68,7 +74,9 @@ module Wassup
|
|
68
74
|
end
|
69
75
|
end
|
70
76
|
|
71
|
-
def initialize(height, width, top, left, title: nil, description: nil, alert_level: nil, highlight: true, focus_number: nil, interval:, show_refresh:, content_block:, selection_blocks:, selection_blocks_description:, debug: false)
|
77
|
+
def initialize(height, width, top, left, title: nil, description: nil, alert_level: nil, highlight: true, focus_number: nil, interval:, show_refresh:, content_block:, selection_blocks:, selection_blocks_description:, port: nil, debug: false)
|
78
|
+
|
79
|
+
self.port = port
|
72
80
|
|
73
81
|
if !debug
|
74
82
|
self.win_height = Curses.lines * height
|
@@ -92,6 +100,12 @@ module Wassup
|
|
92
100
|
self.show_refresh = show_refresh
|
93
101
|
|
94
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 = {}
|
95
109
|
|
96
110
|
if !debug
|
97
111
|
self.win.refresh
|
@@ -236,6 +250,8 @@ module Wassup
|
|
236
250
|
|
237
251
|
self.update_box
|
238
252
|
self.update_title
|
253
|
+
|
254
|
+
self.send_to_socket
|
239
255
|
else
|
240
256
|
# This shouldn't happen
|
241
257
|
# TODO: also fix this
|
@@ -317,7 +333,7 @@ module Wassup
|
|
317
333
|
|
318
334
|
self.last_refresh_char_at ||= Time.now
|
319
335
|
|
320
|
-
if Time.now - self.last_refresh_char_at >= 0.
|
336
|
+
if Time.now - self.last_refresh_char_at >= 0.25
|
321
337
|
self.win.setpos(0, 1)
|
322
338
|
self.win.addstr(self.refresh_char)
|
323
339
|
self.win.refresh
|
@@ -325,6 +341,31 @@ module Wassup
|
|
325
341
|
self.last_refresh_char_at = Time.now
|
326
342
|
end
|
327
343
|
end
|
344
|
+
|
345
|
+
def send_to_socket
|
346
|
+
return if self.port.nil?
|
347
|
+
return if self.port.to_i == 0
|
348
|
+
|
349
|
+
data = {
|
350
|
+
title: self.title,
|
351
|
+
description: self.description,
|
352
|
+
alert_level: self.alert_level,
|
353
|
+
alert_count: self.alert_count
|
354
|
+
}
|
355
|
+
|
356
|
+
sock = TCPSocket.new('127.0.0.1', self.port)
|
357
|
+
sock.write(data.to_json)
|
358
|
+
sock.close
|
359
|
+
end
|
360
|
+
|
361
|
+
def alert_count
|
362
|
+
alert_count = 0
|
363
|
+
if self.contents
|
364
|
+
alert_count = self.contents.map { |c| c.data.size }.inject(0, :+)
|
365
|
+
end
|
366
|
+
|
367
|
+
return alert_count
|
368
|
+
end
|
328
369
|
|
329
370
|
def update_title
|
330
371
|
return unless self.should_box
|
@@ -343,10 +384,7 @@ module Wassup
|
|
343
384
|
|
344
385
|
self.win.setpos(0, 3 + full_title.size)
|
345
386
|
alert = ""
|
346
|
-
alert_count =
|
347
|
-
if self.contents
|
348
|
-
alert_count = self.contents.map { |c| c.data.size }.inject(0, :+)
|
349
|
-
end
|
387
|
+
alert_count = self.alert_count
|
350
388
|
case self.alert_level
|
351
389
|
when AlertLevel::HIGH
|
352
390
|
self.win.attrset(Curses.color_pair(Wassup::Color::Pair::RED))
|
@@ -410,8 +448,59 @@ module Wassup
|
|
410
448
|
self.subwin.refresh
|
411
449
|
end
|
412
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
|
+
|
413
480
|
def virtual_reload
|
414
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
|
415
504
|
|
416
505
|
# TODO: This errored out but might be because thread stuff???
|
417
506
|
self.data_lines[self.top..(self.top+self.subwin.maxy-1)].each_with_index do |line, idx|
|
@@ -423,18 +512,8 @@ module Wassup
|
|
423
512
|
|
424
513
|
self.subwin.attrset(Curses.color_pair(Wassup::Color::Pair::NORMAL))
|
425
514
|
|
426
|
-
|
427
|
-
|
428
|
-
scans = scans.map do |str|
|
429
|
-
if str.start_with?('[fg=')
|
430
|
-
str = str.gsub('[fg=', '').gsub(']','')
|
431
|
-
Wassup::Color.new(str)
|
432
|
-
else
|
433
|
-
str
|
434
|
-
end
|
435
|
-
end
|
436
|
-
|
437
|
-
all_parts = splits.zip(scans).flatten.compact
|
515
|
+
# Use cached color parsing instead of expensive regex operations
|
516
|
+
all_parts = parse_line_colors(line)
|
438
517
|
|
439
518
|
char_count = 0
|
440
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
|