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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/.builds/ruby-4.0.yml +38 -0
  3. data/.pre-commit-config.yaml +16 -0
  4. data/.rubocop.yml +8 -0
  5. data/AGENTS.md +72 -0
  6. data/CHANGELOG.md +23 -0
  7. data/LICENSE +661 -0
  8. data/LICENSES/AGPL-3.0-or-later.txt +661 -0
  9. data/LICENSES/CC-BY-SA-4.0.txt +427 -0
  10. data/LICENSES/CC0-1.0.txt +121 -0
  11. data/LICENSES/MIT-0.txt +16 -0
  12. data/LICENSES/MIT.txt +18 -0
  13. data/README.md +199 -0
  14. data/REUSE.toml +18 -0
  15. data/Rakefile +13 -0
  16. data/bin/agent_rake +13 -0
  17. data/bin/announce +13 -0
  18. data/bin/console +14 -0
  19. data/bin/consolidate_md +13 -0
  20. data/bin/hbs +13 -0
  21. data/bin/setup +17 -0
  22. data/doc/contributors/documentation_style.md +121 -0
  23. data/doc/custom.css +22 -0
  24. data/exe/agent_rake +96 -0
  25. data/exe/announce +1120 -0
  26. data/exe/consolidate_md +246 -0
  27. data/exe/hbs +670 -0
  28. data/exe/scaffold +662 -0
  29. data/lib/ratatui_ruby/devtools/tasks/autodoc/examples.rb +133 -0
  30. data/lib/ratatui_ruby/devtools/tasks/autodoc/member.rb +116 -0
  31. data/lib/ratatui_ruby/devtools/tasks/autodoc/name.rb +33 -0
  32. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +21 -0
  33. data/lib/ratatui_ruby/devtools/tasks/bump/cargo_lockfile.rb +38 -0
  34. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +67 -0
  35. data/lib/ratatui_ruby/devtools/tasks/bump/header.rb +43 -0
  36. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +50 -0
  37. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +78 -0
  38. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +63 -0
  39. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +77 -0
  40. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +63 -0
  41. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +75 -0
  42. data/lib/ratatui_ruby/devtools/tasks/bump.rake +80 -0
  43. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +47 -0
  44. data/lib/ratatui_ruby/devtools/tasks/doc.rake +887 -0
  45. data/lib/ratatui_ruby/devtools/tasks/example_viewer.html.erb +172 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +276 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +236 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +143 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +353 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +186 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license.rake +91 -0
  52. data/lib/ratatui_ruby/devtools/tasks/lint.rake +84 -0
  53. data/lib/ratatui_ruby/devtools/tasks/rdoc_config.rb +45 -0
  54. data/lib/ratatui_ruby/devtools/tasks/resources/build.yml.erb +54 -0
  55. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +7 -0
  56. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +104 -0
  57. data/lib/ratatui_ruby/devtools/tasks/sourcehut.rake +94 -0
  58. data/lib/ratatui_ruby/devtools/tasks/test.rake +18 -0
  59. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +47 -0
  60. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +18 -0
  61. data/lib/ratatui_ruby/devtools/templates/.pre-commit-config.yaml.erb +16 -0
  62. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +8 -0
  63. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +65 -0
  64. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +18 -0
  65. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +32 -0
  66. data/lib/ratatui_ruby/devtools/templates/README.md.erb +127 -0
  67. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +33 -0
  68. data/lib/ratatui_ruby/devtools/templates/Rakefile.erb +29 -0
  69. data/lib/ratatui_ruby/devtools/templates/bin/console.erb +18 -0
  70. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +24 -0
  71. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +16 -0
  72. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +49 -0
  73. data/lib/ratatui_ruby/devtools/templates/doc/custom.css.erb +24 -0
  74. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +56 -0
  75. data/lib/ratatui_ruby/devtools/templates/doc/images/.gitkeep +0 -0
  76. data/lib/ratatui_ruby/devtools/templates/doc/index.md.erb +25 -0
  77. data/lib/ratatui_ruby/devtools/templates/exe/.gitkeep +0 -0
  78. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +58 -0
  79. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +12 -0
  80. data/lib/ratatui_ruby/devtools/templates/tasks/example_viewer.html.erb +174 -0
  81. data/lib/ratatui_ruby/devtools/templates/tasks/resources/build.yml.erb +62 -0
  82. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +46 -0
  83. data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +9 -0
  84. data/lib/ratatui_ruby/devtools/templates/vendor/goodcop/base.yml +1047 -0
  85. data/lib/ratatui_ruby/devtools/version.rb +13 -0
  86. data/lib/ratatui_ruby/devtools.rb +137 -0
  87. data/mise.toml +7 -0
  88. data/sig/ratatui_ruby/devtools.rbs +15 -0
  89. data/vendor/goodcop/base.yml +1047 -0
  90. 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