ratatui_ruby-devtools 0.1.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 +7 -0
- data/.builds/ruby-4.0.yml +38 -0
- data/.pre-commit-config.yaml +16 -0
- data/.rubocop.yml +8 -0
- data/AGENTS.md +72 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +661 -0
- data/LICENSES/AGPL-3.0-or-later.txt +661 -0
- data/LICENSES/CC-BY-SA-4.0.txt +427 -0
- data/LICENSES/CC0-1.0.txt +121 -0
- data/LICENSES/MIT-0.txt +16 -0
- data/LICENSES/MIT.txt +18 -0
- data/README.md +199 -0
- data/REUSE.toml +18 -0
- data/Rakefile +13 -0
- data/bin/agent_rake +13 -0
- data/bin/announce +13 -0
- data/bin/console +14 -0
- data/bin/consolidate_md +13 -0
- data/bin/hbs +13 -0
- data/bin/setup +17 -0
- data/doc/contributors/documentation_style.md +121 -0
- data/doc/custom.css +22 -0
- data/exe/agent_rake +96 -0
- data/exe/announce +1120 -0
- data/exe/consolidate_md +246 -0
- data/exe/hbs +670 -0
- data/exe/scaffold +662 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc/examples.rb +133 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc/member.rb +116 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc/name.rb +33 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +21 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/cargo_lockfile.rb +38 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +67 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/header.rb +43 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +50 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +78 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +63 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +77 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +63 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +75 -0
- data/lib/ratatui_ruby/devtools/tasks/bump.rake +80 -0
- data/lib/ratatui_ruby/devtools/tasks/cargo.rake +47 -0
- data/lib/ratatui_ruby/devtools/tasks/doc.rake +887 -0
- data/lib/ratatui_ruby/devtools/tasks/example_viewer.html.erb +172 -0
- data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +276 -0
- data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +236 -0
- data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +143 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +353 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +186 -0
- data/lib/ratatui_ruby/devtools/tasks/license.rake +91 -0
- data/lib/ratatui_ruby/devtools/tasks/lint.rake +84 -0
- data/lib/ratatui_ruby/devtools/tasks/rdoc_config.rb +45 -0
- data/lib/ratatui_ruby/devtools/tasks/resources/build.yml.erb +54 -0
- data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +7 -0
- data/lib/ratatui_ruby/devtools/tasks/reuse.rake +104 -0
- data/lib/ratatui_ruby/devtools/tasks/sourcehut.rake +94 -0
- data/lib/ratatui_ruby/devtools/tasks/test.rake +18 -0
- data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +47 -0
- data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +18 -0
- data/lib/ratatui_ruby/devtools/templates/.pre-commit-config.yaml.erb +16 -0
- data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +8 -0
- data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +65 -0
- data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +18 -0
- data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +32 -0
- data/lib/ratatui_ruby/devtools/templates/README.md.erb +127 -0
- data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +33 -0
- data/lib/ratatui_ruby/devtools/templates/Rakefile.erb +29 -0
- data/lib/ratatui_ruby/devtools/templates/bin/console.erb +18 -0
- data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +24 -0
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +16 -0
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +49 -0
- data/lib/ratatui_ruby/devtools/templates/doc/custom.css.erb +24 -0
- data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +56 -0
- data/lib/ratatui_ruby/devtools/templates/doc/images/.gitkeep +0 -0
- data/lib/ratatui_ruby/devtools/templates/doc/index.md.erb +25 -0
- data/lib/ratatui_ruby/devtools/templates/exe/.gitkeep +0 -0
- data/lib/ratatui_ruby/devtools/templates/gemspec.erb +58 -0
- data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +12 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/example_viewer.html.erb +174 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/build.yml.erb +62 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +46 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +9 -0
- data/lib/ratatui_ruby/devtools/templates/vendor/goodcop/base.yml +1047 -0
- data/lib/ratatui_ruby/devtools/version.rb +13 -0
- data/lib/ratatui_ruby/devtools.rb +137 -0
- data/mise.toml +7 -0
- data/sig/ratatui_ruby/devtools.rbs +15 -0
- data/vendor/goodcop/base.yml +1047 -0
- metadata +252 -0
data/exe/hbs
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
#--
|
|
5
|
+
# SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
6
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
7
|
+
#++
|
|
8
|
+
|
|
9
|
+
####
|
|
10
|
+
# ======================================================================
|
|
11
|
+
# hbs - watch SourceHut builds for current commit
|
|
12
|
+
# ======================================================================
|
|
13
|
+
#
|
|
14
|
+
# SYNOPSIS
|
|
15
|
+
# bin/hbs [-N|--no-wait] [-b|--builds N] [-y|--yes] [-S|--no-say]
|
|
16
|
+
#
|
|
17
|
+
# DESCRIPTION
|
|
18
|
+
# Check SourceHut builds for the current HEAD commit and wait for
|
|
19
|
+
# them to complete. Announces result via macOS text-to-speech.
|
|
20
|
+
#
|
|
21
|
+
# Fetches from origin first. If HEAD has not been pushed, prompts
|
|
22
|
+
# to watch latest builds anyway (or auto-confirms with -y). Watches
|
|
23
|
+
# builds until complete, then reports pass/fail with clear visual
|
|
24
|
+
# output. Exit 0 only if all builds for HEAD passed.
|
|
25
|
+
#
|
|
26
|
+
# This makes it reliable for chaining: bin/hbs && rake release
|
|
27
|
+
#
|
|
28
|
+
# OPTIONS
|
|
29
|
+
# -N, --no-wait Exit 1 immediately if no builds exist for HEAD.
|
|
30
|
+
# -b, --builds N Number of builds to watch (default: count of
|
|
31
|
+
# .yml files in .builds/ directory).
|
|
32
|
+
# -y, --yes Auto-confirm all prompts (non-interactive).
|
|
33
|
+
# -S, --no-say Disable text-to-speech announcements.
|
|
34
|
+
#
|
|
35
|
+
# EXIT STATUS
|
|
36
|
+
# 0 All builds for HEAD passed
|
|
37
|
+
# 1 Build failed, no builds found, or HEAD not pushed
|
|
38
|
+
#
|
|
39
|
+
# EXAMPLES
|
|
40
|
+
# Wait for builds and release if they pass:
|
|
41
|
+
# bin/hbs && bundle exec rake release
|
|
42
|
+
#
|
|
43
|
+
# Non-interactive (auto-confirm prompts):
|
|
44
|
+
# bin/hbs -y
|
|
45
|
+
#
|
|
46
|
+
# Quick check without waiting:
|
|
47
|
+
# bin/hbs -N
|
|
48
|
+
#
|
|
49
|
+
# DEPENDENCIES
|
|
50
|
+
# hut SourceHut CLI (https://sr.ht/~xenrox/hut/)
|
|
51
|
+
# git To get current HEAD commit
|
|
52
|
+
# say macOS text-to-speech
|
|
53
|
+
#
|
|
54
|
+
# ======================================================================
|
|
55
|
+
|
|
56
|
+
require "optparse"
|
|
57
|
+
require "open3"
|
|
58
|
+
require "rdoc"
|
|
59
|
+
|
|
60
|
+
# Represents a git commit for build lookups.
|
|
61
|
+
#
|
|
62
|
+
# SourceHut builds are identified by commit SHA. Determining whether HEAD is
|
|
63
|
+
# pushed and how far ahead we are requires git commands. This class wraps
|
|
64
|
+
# those queries.
|
|
65
|
+
#
|
|
66
|
+
# [short_sha] The 7-character short SHA.
|
|
67
|
+
Commit = Data.define(:short_sha) do
|
|
68
|
+
# Returns the current HEAD commit.
|
|
69
|
+
def self.head
|
|
70
|
+
sha = `git rev-parse --short HEAD 2>/dev/null`.strip
|
|
71
|
+
raise "Not in a git repository" if sha.empty?
|
|
72
|
+
|
|
73
|
+
new(short_sha: sha)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the origin/trunk HEAD commit, or nil if not available.
|
|
77
|
+
def self.origin_head
|
|
78
|
+
sha = `git rev-parse --short origin/trunk 2>/dev/null`.strip
|
|
79
|
+
return nil if sha.empty?
|
|
80
|
+
|
|
81
|
+
new(short_sha: sha)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Checks whether this commit exists on the remote.
|
|
85
|
+
#
|
|
86
|
+
# Watching builds for unpushed commits wastes time. The remote hasn't run
|
|
87
|
+
# CI for code it hasn't received. Check this before querying SourceHut.
|
|
88
|
+
def pushed?
|
|
89
|
+
system("git", "merge-base", "--is-ancestor", short_sha, "origin/trunk",
|
|
90
|
+
out: File::NULL, err: File::NULL)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Counts commits between local HEAD and origin.
|
|
94
|
+
#
|
|
95
|
+
# Users need to know how far ahead they are. A large number suggests pushing
|
|
96
|
+
# before watching builds. Use this to inform the user about sync status.
|
|
97
|
+
def commits_ahead
|
|
98
|
+
`git rev-list --count origin/trunk..HEAD 2>/dev/null`.strip.to_i
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Represents a single SourceHut build job.
|
|
103
|
+
#
|
|
104
|
+
# Build jobs have IDs, statuses, and output logs. Querying status, fetching
|
|
105
|
+
# output, and refreshing require hut CLI calls. This class wraps those.
|
|
106
|
+
#
|
|
107
|
+
# [id] The numeric build ID.
|
|
108
|
+
# [status] The current status string (RUNNING, SUCCESS, FAILED, etc.)
|
|
109
|
+
Build = Data.define(:id, :status) do
|
|
110
|
+
# Indicates whether the build is still in progress.
|
|
111
|
+
#
|
|
112
|
+
# Completed builds have final results. Running builds need polling. Check
|
|
113
|
+
# this to determine whether to wait or report.
|
|
114
|
+
def running?
|
|
115
|
+
%w[RUNNING PENDING QUEUED].include?(status)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Indicates whether the build failed.
|
|
119
|
+
#
|
|
120
|
+
# A failed build blocks releases. Success requires all builds to pass. Check
|
|
121
|
+
# this to gate release actions.
|
|
122
|
+
def failed?
|
|
123
|
+
status == "FAILED"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Generates the SourceHut web URL for this build.
|
|
127
|
+
#
|
|
128
|
+
# Users need direct links to investigate failures or review logs. Use this
|
|
129
|
+
# to create clickable references in output.
|
|
130
|
+
def url
|
|
131
|
+
"https://builds.sr.ht/~kerrick/job/#{id}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Fetches the full build output from SourceHut.
|
|
135
|
+
#
|
|
136
|
+
# Debugging failures requires log access. The hut CLI retrieves logs. Use
|
|
137
|
+
# this to display build details or extract specific information.
|
|
138
|
+
def output
|
|
139
|
+
out, = Open3.capture2("hut", "builds", "show", id.to_s)
|
|
140
|
+
out
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Extracts displayable log lines from build output.
|
|
144
|
+
#
|
|
145
|
+
# Build output includes headers. Display needs just the log content. Use
|
|
146
|
+
# this when showing logs in a formatted UI.
|
|
147
|
+
def content_lines
|
|
148
|
+
# Skip the URL line (first line) since it becomes the title
|
|
149
|
+
output.lines.drop(1).map(&:rstrip)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Extracts the most recent timestamp from the build log.
|
|
153
|
+
#
|
|
154
|
+
# Long-running builds need progress indication. The latest timestamp shows
|
|
155
|
+
# recency. Use this to label build sections or detect stalled jobs.
|
|
156
|
+
def last_timestamp
|
|
157
|
+
# Extract the latest timestamp from the runtime log (format: YYYY/MM/DD HH:MM:SS)
|
|
158
|
+
timestamps = output.scan(%r{\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}})
|
|
159
|
+
timestamps.last
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Queries SourceHut for the current build status.
|
|
163
|
+
#
|
|
164
|
+
# Build status changes over time. Stale data misleads users. Call this
|
|
165
|
+
# periodically to get fresh status and detect completion.
|
|
166
|
+
def refresh
|
|
167
|
+
out, = Open3.capture2("hut", "builds", "show", id.to_s)
|
|
168
|
+
new_status = out.lines.first(3).join.match(/RUNNING|SUCCESS|FAILED|PENDING|QUEUED/)&.[](0) || status
|
|
169
|
+
Build.new(id:, status: new_status)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A collection of SourceHut builds.
|
|
174
|
+
#
|
|
175
|
+
# Watching builds requires finding them by commit, checking statuses, and
|
|
176
|
+
# refreshing as a group. Individual Build objects lack this context.
|
|
177
|
+
#
|
|
178
|
+
# This class manages collections. It queries builds by commit or status.
|
|
179
|
+
# It refreshes all builds together. It reports aggregate status.
|
|
180
|
+
#
|
|
181
|
+
# [builds] Array of Build objects.
|
|
182
|
+
BuildSet = Data.define(:builds) do
|
|
183
|
+
# Finds builds for a specific commit.
|
|
184
|
+
#
|
|
185
|
+
# [commit] A Commit object to search for.
|
|
186
|
+
def self.for_commit(commit)
|
|
187
|
+
output, = Open3.capture2("hut", "builds", "list")
|
|
188
|
+
ids = output.lines
|
|
189
|
+
.each_cons(6)
|
|
190
|
+
.select { |chunk| chunk.any? { |l| l.include?(commit.short_sha) } }
|
|
191
|
+
.filter_map { |chunk| chunk.first[/#(\d+)/, 1]&.to_i }
|
|
192
|
+
.uniq
|
|
193
|
+
|
|
194
|
+
builds = ids.map { |id| Build.new(id:, status: fetch_status(id)) }
|
|
195
|
+
new(builds:)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Finds currently running builds.
|
|
199
|
+
def self.running
|
|
200
|
+
output, = Open3.capture2("hut", "builds", "list")
|
|
201
|
+
ids = output.lines
|
|
202
|
+
.select { |l| l.match?(/RUNNING|PENDING|QUEUED/) }
|
|
203
|
+
.filter_map { |l| l[/#(\d+)/, 1]&.to_i }
|
|
204
|
+
.uniq
|
|
205
|
+
|
|
206
|
+
builds = ids.map { |id| Build.new(id:, status: fetch_status(id)) }
|
|
207
|
+
new(builds:)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Finds the most recent builds.
|
|
211
|
+
#
|
|
212
|
+
# [count] Number of builds to return.
|
|
213
|
+
def self.latest(count = 4)
|
|
214
|
+
output, = Open3.capture2("hut", "builds", "list")
|
|
215
|
+
ids = output.lines
|
|
216
|
+
.filter_map { |l| l[/#(\d+)/, 1]&.to_i }
|
|
217
|
+
.uniq
|
|
218
|
+
.first(count)
|
|
219
|
+
|
|
220
|
+
builds = ids.map { |id| Build.new(id:, status: fetch_status(id)) }
|
|
221
|
+
new(builds:)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Finds builds with a specific status.
|
|
225
|
+
#
|
|
226
|
+
# [target_status] The status to search for (FAILED, SUCCESS).
|
|
227
|
+
# [count] Number of builds per set.
|
|
228
|
+
def self.with_status(target_status, count = 4)
|
|
229
|
+
output, = Open3.capture2("hut", "builds", "list")
|
|
230
|
+
all_ids = output.lines
|
|
231
|
+
.filter_map { |l| l[/#(\d+)/, 1]&.to_i }
|
|
232
|
+
.uniq
|
|
233
|
+
|
|
234
|
+
# Group builds into sets of `count` and find the first set with any matching status
|
|
235
|
+
all_ids.each_slice(count) do |set_ids|
|
|
236
|
+
builds = set_ids.map { |id| Build.new(id:, status: fetch_status(id)) }
|
|
237
|
+
has_match = case target_status
|
|
238
|
+
when "FAILED" then builds.any?(&:failed?)
|
|
239
|
+
when "SUCCESS" then builds.none?(&:failed?)
|
|
240
|
+
end
|
|
241
|
+
return new(builds:) if has_match
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
new(builds: [])
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Queries the status of a specific build by ID.
|
|
248
|
+
#
|
|
249
|
+
# Build creation returns only the ID. Status requires a separate query. Use
|
|
250
|
+
# this when constructing Build objects from ID lists.
|
|
251
|
+
#
|
|
252
|
+
# [id] The build ID.
|
|
253
|
+
def self.fetch_status(id)
|
|
254
|
+
output, = Open3.capture2("hut", "builds", "show", id.to_s)
|
|
255
|
+
output.lines.first(3).join.match(/RUNNING|SUCCESS|FAILED|PENDING|QUEUED/)&.[](0) || "UNKNOWN"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Indicates whether the collection has no builds.
|
|
259
|
+
#
|
|
260
|
+
# A missing commit has no builds. Watching nothing wastes time. Check this
|
|
261
|
+
# to abort early or prompt the user about missing CI.
|
|
262
|
+
def empty?
|
|
263
|
+
builds.empty?
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Indicates whether any build is still in progress.
|
|
267
|
+
#
|
|
268
|
+
# Completed builds have final results. Running builds need polling. Check
|
|
269
|
+
# this to determine whether to wait or report.
|
|
270
|
+
def any_running?
|
|
271
|
+
builds.any?(&:running?)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Indicates whether any build failed.
|
|
275
|
+
#
|
|
276
|
+
# A failed build blocks releases. Success requires all builds to pass. Check
|
|
277
|
+
# this to gate release actions.
|
|
278
|
+
def any_failed?
|
|
279
|
+
builds.any?(&:failed?)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Extracts the commit SHA from build output.
|
|
283
|
+
#
|
|
284
|
+
# Builds track which commit they ran. Displaying the SHA confirms the correct
|
|
285
|
+
# code was tested. Use this to show the user what was verified.
|
|
286
|
+
def commit_sha
|
|
287
|
+
# Extract commit SHA from the first build's output - this is the canonical source
|
|
288
|
+
return nil if builds.empty?
|
|
289
|
+
|
|
290
|
+
sha_match = builds.first.output.match(/\[([a-f0-9]{7})\]\[0\]/)
|
|
291
|
+
sha_match&.[](1)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Queries SourceHut for fresh status of all builds.
|
|
295
|
+
#
|
|
296
|
+
# Build status changes over time. Watching requires periodic updates. Call
|
|
297
|
+
# this in a loop to poll until all builds complete.
|
|
298
|
+
def refresh
|
|
299
|
+
BuildSet.new(builds: builds.map(&:refresh))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Collects the build IDs for display.
|
|
303
|
+
#
|
|
304
|
+
# Users need to identify which builds are being watched. IDs enable lookup
|
|
305
|
+
# and linking. Use this when showing build references.
|
|
306
|
+
def ids
|
|
307
|
+
builds.map(&:id)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Watches SourceHut builds and reports results.
|
|
312
|
+
#
|
|
313
|
+
# CI builds run asynchronously. Developers need to know when they finish and
|
|
314
|
+
# whether they passed. Polling manually is tedious. Parsing output is error-prone.
|
|
315
|
+
#
|
|
316
|
+
# This class watches builds. It polls until complete. It announces results via
|
|
317
|
+
# macOS text-to-speech. It displays formatted output with clear pass/fail status.
|
|
318
|
+
#
|
|
319
|
+
# Use it to wait for CI before releasing.
|
|
320
|
+
class BuildWatch
|
|
321
|
+
# Seconds between status polls.
|
|
322
|
+
POLL_INTERVAL = 30
|
|
323
|
+
|
|
324
|
+
# Seconds to wait for builds to appear.
|
|
325
|
+
WAIT_INTERVAL = 5
|
|
326
|
+
|
|
327
|
+
# Maximum attempts to find builds before giving up.
|
|
328
|
+
MAX_WAIT_ATTEMPTS = 6
|
|
329
|
+
|
|
330
|
+
# Creates a new BuildWatch.
|
|
331
|
+
#
|
|
332
|
+
# [wait_for_builds] Whether to wait for builds to appear.
|
|
333
|
+
# [build_count] Number of builds to expect.
|
|
334
|
+
# [auto_yes] Whether to auto-confirm prompts.
|
|
335
|
+
# [no_say] Whether to disable text-to-speech.
|
|
336
|
+
def initialize(wait_for_builds: true, build_count: 4, auto_yes: false, no_say: false)
|
|
337
|
+
@wait_for_builds = wait_for_builds
|
|
338
|
+
@build_count = build_count
|
|
339
|
+
@auto_yes = auto_yes
|
|
340
|
+
@no_say = no_say
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
private def box(*lines, style: :info, title: nil)
|
|
344
|
+
width = 78
|
|
345
|
+
inner_width = width - 4 # "║ " + content + " ║"
|
|
346
|
+
|
|
347
|
+
color = case style
|
|
348
|
+
when :success then "\e[32m"
|
|
349
|
+
when :error then "\e[31m"
|
|
350
|
+
else "\e[36m"
|
|
351
|
+
end
|
|
352
|
+
reset = "\e[0m"
|
|
353
|
+
|
|
354
|
+
top_border = if title
|
|
355
|
+
title_text = " #{title} "
|
|
356
|
+
left_pad = (width - 2 - title_text.length) / 2
|
|
357
|
+
right_pad = width - 2 - title_text.length - left_pad
|
|
358
|
+
"#{color}╔#{'═' * left_pad}#{title_text}#{'═' * right_pad}╗#{reset}"
|
|
359
|
+
else
|
|
360
|
+
"#{color}╔#{'═' * (width - 2)}╗#{reset}"
|
|
361
|
+
end
|
|
362
|
+
bottom_border = "#{color}╚#{'═' * (width - 2)}╝#{reset}"
|
|
363
|
+
|
|
364
|
+
puts top_border
|
|
365
|
+
lines.each do |line|
|
|
366
|
+
wrap_text(line, inner_width).each do |wrapped|
|
|
367
|
+
puts "#{color}║ #{wrapped.ljust(inner_width)} ║#{reset}"
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
puts bottom_border
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
private def single_box(*lines, title: nil)
|
|
374
|
+
width = 78
|
|
375
|
+
inner_width = width - 4 # "│ " + content + " │"
|
|
376
|
+
|
|
377
|
+
gray = "\e[90m"
|
|
378
|
+
reset = "\e[0m"
|
|
379
|
+
|
|
380
|
+
top_border = if title
|
|
381
|
+
title_text = " #{title} "
|
|
382
|
+
left_pad = (width - 2 - title_text.length) / 2
|
|
383
|
+
right_pad = width - 2 - title_text.length - left_pad
|
|
384
|
+
"#{gray}┌#{'─' * left_pad}#{title_text}#{'─' * right_pad}┐#{reset}"
|
|
385
|
+
else
|
|
386
|
+
"#{gray}┌#{'─' * (width - 2)}┐#{reset}"
|
|
387
|
+
end
|
|
388
|
+
bottom_border = "#{gray}└#{'─' * (width - 2)}┘#{reset}"
|
|
389
|
+
|
|
390
|
+
puts top_border
|
|
391
|
+
lines.each do |line|
|
|
392
|
+
wrap_text(line, inner_width).each do |wrapped|
|
|
393
|
+
if wrapped.length > inner_width
|
|
394
|
+
# Line is too long even after wrapping (e.g., a URL that can't be broken).
|
|
395
|
+
# Let it "blow out" the right wall rather than truncating or wrapping mid-word.
|
|
396
|
+
puts "#{gray}│#{reset} #{wrapped}"
|
|
397
|
+
else
|
|
398
|
+
puts "#{gray}│#{reset} #{wrapped.ljust(inner_width)} #{gray}│#{reset}"
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
puts bottom_border
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
private def result_box(message, style:, sha: nil)
|
|
406
|
+
width = 78
|
|
407
|
+
inner_width = width - 4
|
|
408
|
+
symbol = (style == :success) ? "✓" : "✗"
|
|
409
|
+
flanks = "#{symbol} #{symbol} #{symbol}"
|
|
410
|
+
|
|
411
|
+
# Include commit SHA if provided
|
|
412
|
+
full_message = sha ? "#{message} (#{sha})" : message
|
|
413
|
+
|
|
414
|
+
# Calculate padding: inner_width - 2*flanks - message - spaces around message
|
|
415
|
+
available = inner_width - (flanks.length * 2) - full_message.length
|
|
416
|
+
left_pad = available / 2
|
|
417
|
+
right_pad = available - left_pad
|
|
418
|
+
|
|
419
|
+
centered = "#{flanks}#{' ' * left_pad}#{full_message}#{' ' * right_pad}#{flanks}"
|
|
420
|
+
box(centered, style:)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
private def wrap_text(text, width)
|
|
424
|
+
return [text] if text.length <= width
|
|
425
|
+
|
|
426
|
+
# Use RDoc::Text#wrap which handles URLs better
|
|
427
|
+
wrapper = Object.new.extend(RDoc::Text)
|
|
428
|
+
wrapper.wrap(text, width).lines.map(&:chomp)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
private def show_builds(build_set)
|
|
432
|
+
if build_set.any_failed?
|
|
433
|
+
# Announce failure immediately - don't wait for all build details to be fetched
|
|
434
|
+
say("Builds have Failed")
|
|
435
|
+
|
|
436
|
+
# Failed builds: raw output with red full-block separators
|
|
437
|
+
red = "\e[31m"
|
|
438
|
+
reset = "\e[0m"
|
|
439
|
+
block_line = "#{red}#{'█' * 80}#{reset}"
|
|
440
|
+
|
|
441
|
+
build_set.builds.each_with_index do |build, idx|
|
|
442
|
+
if idx.positive?
|
|
443
|
+
# Show separator with build info for easy scanning
|
|
444
|
+
next_build = build_set.builds[idx]
|
|
445
|
+
ts = next_build.last_timestamp
|
|
446
|
+
label = ts ? "##{next_build.id} — #{ts} — #{next_build.status}" : "##{next_build.id} — #{next_build.status}"
|
|
447
|
+
# Down-arrows indicate this labels the NEXT build below; inset by 1 to avoid edge rendering issues
|
|
448
|
+
centered = " ↓#{label.center(76)}↓ "
|
|
449
|
+
|
|
450
|
+
puts
|
|
451
|
+
puts block_line
|
|
452
|
+
puts "#{red}#{centered}#{reset}"
|
|
453
|
+
puts block_line
|
|
454
|
+
puts
|
|
455
|
+
end
|
|
456
|
+
system("hut", "builds", "show", build.id.to_s)
|
|
457
|
+
end
|
|
458
|
+
else
|
|
459
|
+
# Normal builds: gray single-boxes with URL titles
|
|
460
|
+
build_set.builds.each do |build|
|
|
461
|
+
single_box(*build.content_lines, title: build.url)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Starts the build watching TUI.
|
|
467
|
+
#
|
|
468
|
+
# Users invoke hbs to monitor CI. This method checks prerequisites, resolves
|
|
469
|
+
# target commit, queries builds, and polls until completion. It reports
|
|
470
|
+
# results with terminal notifications.
|
|
471
|
+
def run
|
|
472
|
+
check_dependencies!
|
|
473
|
+
|
|
474
|
+
# Debug mode: find builds by status instead of commit
|
|
475
|
+
if (force_status = ENV["HBS_FORCE"])
|
|
476
|
+
target = case force_status.upcase
|
|
477
|
+
when "PASSED" then "SUCCESS"
|
|
478
|
+
when "FAILED" then "FAILED"
|
|
479
|
+
else
|
|
480
|
+
warn "HBS_FORCE must be 'PASSED' or 'FAILED'"
|
|
481
|
+
exit 1
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
box("HBS_FORCE=#{force_status}: Finding #{@build_count} #{target} builds...")
|
|
485
|
+
build_set = BuildSet.with_status(target, @build_count)
|
|
486
|
+
if build_set.empty?
|
|
487
|
+
box("No #{target} builds found", style: :error)
|
|
488
|
+
exit 1
|
|
489
|
+
end
|
|
490
|
+
box("Found builds: #{build_set.ids.join(', ')}")
|
|
491
|
+
report(build_set)
|
|
492
|
+
return
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
commit = Commit.head
|
|
496
|
+
system("git", "fetch", "--quiet", out: File::NULL, err: File::NULL)
|
|
497
|
+
origin = Commit.origin_head
|
|
498
|
+
|
|
499
|
+
unless commit.pushed?
|
|
500
|
+
return handle_unpushed(commit, origin)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
box("Looking for builds for commit #{commit.short_sha}...")
|
|
504
|
+
|
|
505
|
+
build_set = find_builds(commit)
|
|
506
|
+
if build_set.empty?
|
|
507
|
+
puts
|
|
508
|
+
say("No builds found")
|
|
509
|
+
box("✗✗✗ NO BUILDS FOUND ✗✗✗", "Commit: #{commit.short_sha}", style: :error)
|
|
510
|
+
exit 1
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
box("Found builds: #{build_set.ids.join(', ')}")
|
|
514
|
+
watch(build_set) if build_set.any_running?
|
|
515
|
+
report(build_set.refresh)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
private def check_dependencies!
|
|
519
|
+
%w[hut say].each do |cmd|
|
|
520
|
+
unless system("command", "-v", cmd, out: File::NULL, err: File::NULL)
|
|
521
|
+
warn "Error: '#{cmd}' not found."
|
|
522
|
+
exit 1
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
private def find_builds(commit)
|
|
528
|
+
attempts = 0
|
|
529
|
+
loop do
|
|
530
|
+
build_set = BuildSet.for_commit(commit)
|
|
531
|
+
return build_set unless build_set.empty?
|
|
532
|
+
return build_set unless @wait_for_builds
|
|
533
|
+
|
|
534
|
+
attempts += 1
|
|
535
|
+
if attempts >= MAX_WAIT_ATTEMPTS
|
|
536
|
+
return build_set
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
puts "No builds found, waiting... (#{attempts}/#{MAX_WAIT_ATTEMPTS})"
|
|
540
|
+
sleep WAIT_INTERVAL
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
private def handle_unpushed(commit, origin)
|
|
545
|
+
ahead = commit.commits_ahead
|
|
546
|
+
origin_sha = origin&.short_sha || "unknown"
|
|
547
|
+
|
|
548
|
+
box(
|
|
549
|
+
"Your branch is ahead of 'origin' by #{ahead} commit#{'s' if ahead != 1}.",
|
|
550
|
+
" Local HEAD: #{commit.short_sha}",
|
|
551
|
+
" Origin HEAD: #{origin_sha}"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
unless @auto_yes
|
|
555
|
+
print "Watch latest SourceHut builds anyway? [Y/n] "
|
|
556
|
+
reply = $stdin.gets&.strip || ""
|
|
557
|
+
if reply.downcase.start_with?("n")
|
|
558
|
+
puts
|
|
559
|
+
say("Exiting")
|
|
560
|
+
box("✗✗✗ EXITING (BUILDS NOT VERIFIED) ✗✗✗", style: :error)
|
|
561
|
+
exit 1
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
build_set = BuildSet.latest(@build_count)
|
|
566
|
+
if build_set.empty?
|
|
567
|
+
puts
|
|
568
|
+
say("No builds found")
|
|
569
|
+
box("✗✗✗ NO BUILDS ON SOURCEHUT ✗✗✗", style: :error)
|
|
570
|
+
exit 1
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
box("Latest builds: #{build_set.ids.join(', ')}")
|
|
574
|
+
watch(build_set) if build_set.any_running?
|
|
575
|
+
|
|
576
|
+
final_set = build_set.refresh
|
|
577
|
+
show_builds(final_set)
|
|
578
|
+
puts
|
|
579
|
+
|
|
580
|
+
sha = final_set.commit_sha
|
|
581
|
+
if final_set.any_failed?
|
|
582
|
+
# say() already called in show_builds when failure first detected
|
|
583
|
+
result_box("BUILDS FAILED", style: :error, sha:)
|
|
584
|
+
else
|
|
585
|
+
say("Builds have Passed")
|
|
586
|
+
result_box("BUILDS PASSED", style: :success, sha:)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Recheck if HEAD is now pushed (user may have pushed while watching)
|
|
590
|
+
current_head = Commit.head
|
|
591
|
+
if current_head.pushed?
|
|
592
|
+
box("HEAD #{current_head.short_sha} is now pushed. Re-run to verify.")
|
|
593
|
+
else
|
|
594
|
+
box("HEAD #{current_head.short_sha} still not pushed.")
|
|
595
|
+
end
|
|
596
|
+
exit 1
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
private def watch(build_set)
|
|
600
|
+
say("Watching Builds")
|
|
601
|
+
show_builds(build_set)
|
|
602
|
+
|
|
603
|
+
current = build_set
|
|
604
|
+
while current.any_running?
|
|
605
|
+
sleep POLL_INTERVAL
|
|
606
|
+
current = current.refresh
|
|
607
|
+
show_builds(current)
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
private def report(build_set)
|
|
612
|
+
puts
|
|
613
|
+
show_builds(build_set)
|
|
614
|
+
puts
|
|
615
|
+
|
|
616
|
+
# Extract commit SHA from build output - this is the canonical source
|
|
617
|
+
sha = build_set.commit_sha
|
|
618
|
+
|
|
619
|
+
if build_set.any_failed?
|
|
620
|
+
# say() already called in show_builds when failure first detected
|
|
621
|
+
result_box("BUILDS FAILED", style: :error, sha:)
|
|
622
|
+
exit 1
|
|
623
|
+
else
|
|
624
|
+
say("Builds have Passed")
|
|
625
|
+
result_box("BUILDS PASSED", style: :success, sha:)
|
|
626
|
+
exit 0
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
private def say(message)
|
|
631
|
+
return if @no_say
|
|
632
|
+
|
|
633
|
+
# Fire async so script doesn't block waiting for speech
|
|
634
|
+
pid = spawn("say", message, out: File::NULL, err: File::NULL)
|
|
635
|
+
Process.detach(pid)
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Parse options
|
|
640
|
+
default_build_count = Dir.glob(".builds/*.yml").size
|
|
641
|
+
default_build_count = 4 if default_build_count.zero?
|
|
642
|
+
|
|
643
|
+
options = { wait_for_builds: true, build_count: default_build_count, auto_yes: false, no_say: false }
|
|
644
|
+
|
|
645
|
+
OptionParser.new do |opts|
|
|
646
|
+
opts.banner = "Usage: bin/hbs [OPTIONS]"
|
|
647
|
+
|
|
648
|
+
opts.on("-N", "--no-wait", "Don't wait for builds to appear") do
|
|
649
|
+
options[:wait_for_builds] = false
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
opts.on("-b", "--builds N", Integer, "Number of builds to watch (default: #{default_build_count})") do |n|
|
|
653
|
+
options[:build_count] = n
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
opts.on("-y", "--yes", "Auto-confirm all prompts") do
|
|
657
|
+
options[:auto_yes] = true
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
opts.on("-S", "--no-say", "Disable text-to-speech") do
|
|
661
|
+
options[:no_say] = true
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
opts.on("-h", "--help", "Show this help") do
|
|
665
|
+
puts opts
|
|
666
|
+
exit
|
|
667
|
+
end
|
|
668
|
+
end.parse!
|
|
669
|
+
|
|
670
|
+
BuildWatch.new(**options).run
|