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/announce
ADDED
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
|
|
7
|
+
####
|
|
8
|
+
# ======================================================================
|
|
9
|
+
# announce_tui - TUI for release announcements (experimental)
|
|
10
|
+
# ======================================================================
|
|
11
|
+
#
|
|
12
|
+
# SYNOPSIS
|
|
13
|
+
# bin/announce_tui [OPTIONS] [VERSION]
|
|
14
|
+
#
|
|
15
|
+
# DESCRIPTION
|
|
16
|
+
# A TUI for managing release announcements. Displays email preview,
|
|
17
|
+
# commit preview, and a release checklist with action controls.
|
|
18
|
+
#
|
|
19
|
+
# OPTIONS
|
|
20
|
+
# -w, --wiki-dir PATH Path to wiki repo (default: ../ratatui_ruby-wiki)
|
|
21
|
+
# -n, --builds N Number of builds to show (default: 4)
|
|
22
|
+
# -h, --help Show this help message
|
|
23
|
+
#
|
|
24
|
+
# ======================================================================
|
|
25
|
+
|
|
26
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
27
|
+
require "ratatui_ruby"
|
|
28
|
+
require "uri"
|
|
29
|
+
require "optparse"
|
|
30
|
+
require "fileutils"
|
|
31
|
+
require "tmpdir"
|
|
32
|
+
|
|
33
|
+
# A path flag value with display methods.
|
|
34
|
+
#
|
|
35
|
+
# CLI flags need both full and abbreviated display. Paths are long. Home
|
|
36
|
+
# directory abbreviation improves readability.
|
|
37
|
+
#
|
|
38
|
+
# [value] The path string.
|
|
39
|
+
PathFlag = Data.define(:value) do
|
|
40
|
+
# Returns the full path value.
|
|
41
|
+
def to_s = value
|
|
42
|
+
|
|
43
|
+
# Returns an abbreviated path using ~ for home directory.
|
|
44
|
+
def to_short
|
|
45
|
+
home = Dir.home
|
|
46
|
+
value.start_with?(home) ? value.sub(home, "~") : value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A boolean flag value with display methods.
|
|
51
|
+
#
|
|
52
|
+
# CLI flags need string representation for display. This wraps a boolean
|
|
53
|
+
# with <tt>to_s</tt> and <tt>to_short</tt> methods.
|
|
54
|
+
#
|
|
55
|
+
# [value] The boolean value.
|
|
56
|
+
BoolFlag = Data.define(:value) do
|
|
57
|
+
# Returns "true" or "false".
|
|
58
|
+
def to_s = value.to_s
|
|
59
|
+
|
|
60
|
+
# Returns "yes" or "no".
|
|
61
|
+
def to_short = value ? "yes" : "no"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# An integer flag value with display methods.
|
|
65
|
+
#
|
|
66
|
+
# CLI flags need string representation for display. This wraps an integer
|
|
67
|
+
# with <tt>to_s</tt> and <tt>to_short</tt> methods.
|
|
68
|
+
#
|
|
69
|
+
# [value] The integer value.
|
|
70
|
+
IntFlag = Data.define(:value) do
|
|
71
|
+
# Returns the integer as a string.
|
|
72
|
+
def to_s = value.to_s
|
|
73
|
+
|
|
74
|
+
# Returns the integer as a string.
|
|
75
|
+
def to_short = value.to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Global CLI flags singleton.
|
|
79
|
+
#
|
|
80
|
+
# CLI options need to be accessible throughout the application. Passing them
|
|
81
|
+
# through every method call is verbose. A module-level singleton provides
|
|
82
|
+
# clean access.
|
|
83
|
+
module CLIFlags
|
|
84
|
+
class << self
|
|
85
|
+
# Whether to perform a dry run (no side effects).
|
|
86
|
+
attr_accessor :dry_run
|
|
87
|
+
|
|
88
|
+
# Path to the wiki repository.
|
|
89
|
+
attr_accessor :wiki_dir
|
|
90
|
+
|
|
91
|
+
# Number of builds to display.
|
|
92
|
+
attr_accessor :builds
|
|
93
|
+
|
|
94
|
+
# Version to announce.
|
|
95
|
+
attr_accessor :version
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
self.dry_run = BoolFlag.new(false)
|
|
99
|
+
self.builds = IntFlag.new(4)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Reads and parses release announcement files.
|
|
103
|
+
#
|
|
104
|
+
# Release announcements live in the wiki repo as markdown files. They contain
|
|
105
|
+
# a Subject line and body for mailing list posts. Parsing them manually each
|
|
106
|
+
# time is tedious.
|
|
107
|
+
#
|
|
108
|
+
# This class reads announcement files, extracts subject and body, and generates
|
|
109
|
+
# commit messages. It checks both stable and devel announcement directories.
|
|
110
|
+
#
|
|
111
|
+
# Use it to preview and send release emails.
|
|
112
|
+
class Announcement
|
|
113
|
+
# The email subject line.
|
|
114
|
+
attr_reader :subject
|
|
115
|
+
|
|
116
|
+
# The email body content.
|
|
117
|
+
attr_reader :body
|
|
118
|
+
|
|
119
|
+
# The file path to the announcement.
|
|
120
|
+
attr_reader :path
|
|
121
|
+
|
|
122
|
+
# Creates an Announcement for a version.
|
|
123
|
+
#
|
|
124
|
+
# [wiki_dir] Path to the wiki repository.
|
|
125
|
+
# [version] The version string (e.g., "v1.2.3").
|
|
126
|
+
def initialize(wiki_dir, version)
|
|
127
|
+
# Prefer announcements/ (major/minor) over devel-announcements/ (patches)
|
|
128
|
+
stable_path = File.join(wiki_dir, "announcements", "#{version}.md")
|
|
129
|
+
devel_path = File.join(wiki_dir, "devel-announcements", "#{version}.md")
|
|
130
|
+
@path = File.exist?(stable_path) ? stable_path : devel_path
|
|
131
|
+
@subject = nil
|
|
132
|
+
@body = nil
|
|
133
|
+
parse
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Checks whether the announcement file exists on disk.
|
|
137
|
+
#
|
|
138
|
+
# The UI previews announcement content. Missing files produce errors or empty
|
|
139
|
+
# previews. Check this before accessing subject or body to avoid confusion.
|
|
140
|
+
def exists?
|
|
141
|
+
File.exist?(@path)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Generates a conventional commit message for this announcement.
|
|
145
|
+
#
|
|
146
|
+
# Announcements get committed to the wiki. Consistent commit messages aid
|
|
147
|
+
# changelog generation and git log readability. This generates the message
|
|
148
|
+
# following project conventions.
|
|
149
|
+
def commit_message
|
|
150
|
+
<<~MSG
|
|
151
|
+
docs: add #{File.basename(@path, '.md')} release announcement
|
|
152
|
+
|
|
153
|
+
Generated with [Antigravity](https://antigravity.google)
|
|
154
|
+
|
|
155
|
+
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
|
|
156
|
+
MSG
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private def parse
|
|
160
|
+
unless exists?
|
|
161
|
+
@subject = "(Announcement file not found)"
|
|
162
|
+
@body = "Expected file: #{@path}"
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
content = File.read(@path)
|
|
167
|
+
lines = content.lines
|
|
168
|
+
|
|
169
|
+
subject_line = lines.find { |l| l.start_with?("Subject: ") }
|
|
170
|
+
if subject_line
|
|
171
|
+
@subject = subject_line.sub(/^Subject: /, "").strip
|
|
172
|
+
subject_index = lines.index(subject_line)
|
|
173
|
+
body_lines = lines[(subject_index + 1)..]
|
|
174
|
+
.reject { |l| l.match?(/^<!--/) || l.match?(/-->/) }
|
|
175
|
+
@body = body_lines.join.strip
|
|
176
|
+
else
|
|
177
|
+
@subject = "(No subject found)"
|
|
178
|
+
@body = content
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Queries git repository state for release verification.
|
|
184
|
+
#
|
|
185
|
+
# Releases require the correct git tag to exist and be pushed. Checking these
|
|
186
|
+
# conditions manually is error-prone. Network latency makes synchronous checks
|
|
187
|
+
# slow.
|
|
188
|
+
#
|
|
189
|
+
# This class queries local and remote tags. It spawns background processes for
|
|
190
|
+
# network operations. It caches results for responsive UI.
|
|
191
|
+
#
|
|
192
|
+
# Use it to verify release preconditions.
|
|
193
|
+
class GitRepo
|
|
194
|
+
# Temp file for background check results.
|
|
195
|
+
CACHE_FILE = File.join(Dir.tmpdir, "ratatui_git_tag_pushed.txt")
|
|
196
|
+
|
|
197
|
+
# The latest git tag in the repository.
|
|
198
|
+
attr_reader :latest_tag
|
|
199
|
+
|
|
200
|
+
# The expected version tag (e.g., "v1.2.3").
|
|
201
|
+
attr_reader :expected_version
|
|
202
|
+
|
|
203
|
+
# Whether the tag has been pushed to origin.
|
|
204
|
+
attr_reader :tag_pushed
|
|
205
|
+
|
|
206
|
+
# Creates a new GitRepo and starts background checks.
|
|
207
|
+
def initialize
|
|
208
|
+
@expected_version = "v#{RatatuiRuby::VERSION}"
|
|
209
|
+
@latest_tag = `git describe --tags --abbrev=0 2>/dev/null`.strip
|
|
210
|
+
@latest_tag = nil if @latest_tag.empty?
|
|
211
|
+
@tag_pushed = nil
|
|
212
|
+
@loading = true
|
|
213
|
+
@check_pid = nil
|
|
214
|
+
|
|
215
|
+
# Spawn background process BEFORE TUI enters raw mode
|
|
216
|
+
start_background_check if tag_matches?
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Compares the latest git tag against the expected version.
|
|
220
|
+
#
|
|
221
|
+
# Releases require the correct tag. A mismatch indicates the tag wasn't
|
|
222
|
+
# created or the wrong branch is checked out. Check this before proceeding
|
|
223
|
+
# with release steps.
|
|
224
|
+
def tag_matches?
|
|
225
|
+
@latest_tag == @expected_version
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Indicates whether background network checks are in progress.
|
|
229
|
+
#
|
|
230
|
+
# The TUI shows loading indicators while fetching remote data. Displaying
|
|
231
|
+
# stale data confuses users. Poll this to update the loading UI and detect
|
|
232
|
+
# when fresh data arrives.
|
|
233
|
+
def loading?
|
|
234
|
+
return false unless @loading
|
|
235
|
+
|
|
236
|
+
# Poll for background process completion
|
|
237
|
+
if @check_pid
|
|
238
|
+
_pid, status = Process.waitpid2(@check_pid, Process::WNOHANG)
|
|
239
|
+
if status
|
|
240
|
+
# Process completed, read result
|
|
241
|
+
@tag_pushed = File.exist?(CACHE_FILE) && File.read(CACHE_FILE).strip == "true"
|
|
242
|
+
@loading = false
|
|
243
|
+
@check_pid = nil
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
@loading
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Fetches tag information from git synchronously.
|
|
250
|
+
#
|
|
251
|
+
# The user triggers manual refresh. Synchronous fetch blocks but guarantees
|
|
252
|
+
# fresh data. Use this for explicit refresh actions, not TUI polling loops.
|
|
253
|
+
def refresh
|
|
254
|
+
@loading = true
|
|
255
|
+
@latest_tag = `git describe --tags --abbrev=0 2>/dev/null`.strip
|
|
256
|
+
@latest_tag = nil if @latest_tag.empty?
|
|
257
|
+
@tag_pushed = check_tag_pushed_sync
|
|
258
|
+
@loading = false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Refreshes tag information in the background.
|
|
262
|
+
#
|
|
263
|
+
# The TUI polls for fresh data while rendering. Synchronous network calls
|
|
264
|
+
# freeze the interface. This method spawns a background process so the UI
|
|
265
|
+
# stays responsive.
|
|
266
|
+
def refresh_async
|
|
267
|
+
@loading = true
|
|
268
|
+
start_background_check if tag_matches?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private def start_background_check
|
|
272
|
+
# Spawn process that runs git and writes result to temp file
|
|
273
|
+
@check_pid = Process.spawn(
|
|
274
|
+
"git ls-remote --tags origin 2>/dev/null | grep -q '#{@expected_version}' && echo true > #{CACHE_FILE} || echo false > #{CACHE_FILE}"
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
private def check_tag_pushed_sync
|
|
279
|
+
return false unless tag_matches?
|
|
280
|
+
remote_tags = `git ls-remote --tags origin 2>/dev/null`.strip
|
|
281
|
+
remote_tags.include?(@expected_version)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Queries SourceHut build status.
|
|
286
|
+
#
|
|
287
|
+
# Releases should wait for CI to pass. Checking SourceHut manually is tedious.
|
|
288
|
+
# Network latency makes synchronous checks slow.
|
|
289
|
+
#
|
|
290
|
+
# This class queries build status via the hut CLI. It refreshes in background
|
|
291
|
+
# threads. It reports aggregate pass/fail/running status.
|
|
292
|
+
#
|
|
293
|
+
# Use it to verify CI status before releasing.
|
|
294
|
+
class SourceHut
|
|
295
|
+
# Array of build hashes with :id and :status keys.
|
|
296
|
+
attr_reader :builds
|
|
297
|
+
|
|
298
|
+
# Creates a new SourceHut query.
|
|
299
|
+
#
|
|
300
|
+
# [max_builds] Maximum number of builds to track.
|
|
301
|
+
def initialize(max_builds: 4)
|
|
302
|
+
@max_builds = max_builds
|
|
303
|
+
@builds = []
|
|
304
|
+
@loading = true
|
|
305
|
+
refresh_async
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Indicates whether build data is being fetched.
|
|
309
|
+
#
|
|
310
|
+
# The TUI shows loading indicators while fetching. Displaying stale build
|
|
311
|
+
# status misleads users. Poll this to show spinners and detect when fresh
|
|
312
|
+
# data arrives.
|
|
313
|
+
def loading?
|
|
314
|
+
@loading
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Fetches build information from SourceHut synchronously.
|
|
318
|
+
#
|
|
319
|
+
# The user triggers manual refresh. Synchronous fetch blocks but guarantees
|
|
320
|
+
# fresh data. Use this for explicit refresh actions, not TUI polling loops.
|
|
321
|
+
def refresh
|
|
322
|
+
@loading = true
|
|
323
|
+
output = `hut builds list 2>/dev/null | head -#{@max_builds * 10}`.strip
|
|
324
|
+
@builds = parse_builds(output)
|
|
325
|
+
@loading = false
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Queries build status in a background thread.
|
|
329
|
+
#
|
|
330
|
+
# The TUI renders continuously. Blocking on hut CLI calls freezes the
|
|
331
|
+
# interface. This method offloads the query to a thread so the UI stays
|
|
332
|
+
# responsive.
|
|
333
|
+
def refresh_async
|
|
334
|
+
Thread.new { refresh }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Indicates whether all tracked builds succeeded.
|
|
338
|
+
#
|
|
339
|
+
# Releases should wait for CI. A single failure means the release isn't
|
|
340
|
+
# ready. Check this to gate release actions.
|
|
341
|
+
def all_passed?
|
|
342
|
+
@builds.all? { |b| b[:status] == :success }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Indicates whether any build is still in progress.
|
|
346
|
+
#
|
|
347
|
+
# Releases should wait for CI to complete. Running builds mean the final
|
|
348
|
+
# result isn't known yet. Check this to show waiting states.
|
|
349
|
+
def any_running?
|
|
350
|
+
@builds.any? { |b| b[:status] == :running }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Indicates whether any build failed.
|
|
354
|
+
#
|
|
355
|
+
# A failed build blocks the release. Check this to prevent premature
|
|
356
|
+
# announcements and surface CI failures.
|
|
357
|
+
def any_failed?
|
|
358
|
+
@builds.any? { |b| b[:status] == :failed }
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
private def parse_builds(output)
|
|
362
|
+
# Parse hut builds list output
|
|
363
|
+
builds = []
|
|
364
|
+
output.scan(/#(\d+).*?(SUCCESS|FAILED|RUNNING)/i) do |id, status|
|
|
365
|
+
builds << {
|
|
366
|
+
id:,
|
|
367
|
+
status: status.downcase.to_sym,
|
|
368
|
+
}
|
|
369
|
+
end
|
|
370
|
+
builds.first(@max_builds)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Queries RubyGems.org for gem publication status.
|
|
375
|
+
#
|
|
376
|
+
# Releases should verify the gem is published. Checking manually is tedious.
|
|
377
|
+
# Network latency makes synchronous checks slow.
|
|
378
|
+
#
|
|
379
|
+
# This class queries gem info from the remote registry. It refreshes in
|
|
380
|
+
# background threads. It reports whether a specific version is published.
|
|
381
|
+
#
|
|
382
|
+
# Use it to verify gem publication after releasing.
|
|
383
|
+
class RubyGemsInfo
|
|
384
|
+
# The latest published version string.
|
|
385
|
+
attr_reader :published_version
|
|
386
|
+
|
|
387
|
+
# Creates a new RubyGemsInfo query.
|
|
388
|
+
#
|
|
389
|
+
# [gem_name] The gem name to query.
|
|
390
|
+
def initialize(gem_name)
|
|
391
|
+
@gem_name = gem_name
|
|
392
|
+
@published_version = nil
|
|
393
|
+
@loading = true
|
|
394
|
+
refresh_async
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Indicates whether gem data is being fetched.
|
|
398
|
+
#
|
|
399
|
+
# The TUI shows loading indicators while fetching. Displaying stale
|
|
400
|
+
# publication status misleads users. Poll this to show spinners and detect
|
|
401
|
+
# when fresh data arrives.
|
|
402
|
+
def loading?
|
|
403
|
+
@loading
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Fetches gem publication information synchronously.
|
|
407
|
+
#
|
|
408
|
+
# The user triggers manual refresh. Synchronous fetch blocks but guarantees
|
|
409
|
+
# fresh data. Use this for explicit refresh actions, not TUI polling loops.
|
|
410
|
+
def refresh
|
|
411
|
+
@loading = true
|
|
412
|
+
output = `gem info #{@gem_name} -r 2>/dev/null`.strip
|
|
413
|
+
match = output.match(/#{@gem_name} \(([^)]+)\)/)
|
|
414
|
+
@published_version = match[1] if match
|
|
415
|
+
@loading = false
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Queries gem publication in a background thread.
|
|
419
|
+
#
|
|
420
|
+
# The TUI renders continuously. Blocking on gem info calls freezes the
|
|
421
|
+
# interface. This method offloads the query to a thread so the UI stays
|
|
422
|
+
# responsive.
|
|
423
|
+
def refresh_async
|
|
424
|
+
Thread.new { refresh }
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Compares the given version against the published version.
|
|
428
|
+
#
|
|
429
|
+
# Releases verify the gem actually published. A mismatch means the release
|
|
430
|
+
# failed or is still propagating. Check this before announcing.
|
|
431
|
+
#
|
|
432
|
+
# [version] The version string (with or without "v" prefix).
|
|
433
|
+
def published?(version)
|
|
434
|
+
@published_version == version.delete_prefix("v")
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Manages wiki repository commit and push status.
|
|
439
|
+
#
|
|
440
|
+
# Release announcements live in the wiki repo. They need to be committed and
|
|
441
|
+
# pushed as part of the release process. Tracking this state manually is
|
|
442
|
+
# error-prone.
|
|
443
|
+
#
|
|
444
|
+
# This class queries git status for announcement files. It performs commits
|
|
445
|
+
# and pushes. It reports committed/pushed state for the UI.
|
|
446
|
+
#
|
|
447
|
+
# Use it to manage wiki announcement lifecycle.
|
|
448
|
+
class WikiRepo
|
|
449
|
+
# Whether the announcement is committed.
|
|
450
|
+
attr_reader :committed
|
|
451
|
+
|
|
452
|
+
# Whether the announcement is pushed.
|
|
453
|
+
attr_reader :pushed
|
|
454
|
+
|
|
455
|
+
# Creates a new WikiRepo for an announcement.
|
|
456
|
+
#
|
|
457
|
+
# [wiki_dir] Path to the wiki repository.
|
|
458
|
+
# [announcement_path] Full path to the announcement file.
|
|
459
|
+
def initialize(wiki_dir, announcement_path)
|
|
460
|
+
@wiki_dir = wiki_dir
|
|
461
|
+
@relative_path = announcement_path.sub("#{wiki_dir}/", "")
|
|
462
|
+
@committed = false
|
|
463
|
+
@pushed = false
|
|
464
|
+
@loading = true
|
|
465
|
+
refresh_async
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Indicates whether git status is being queried.
|
|
469
|
+
#
|
|
470
|
+
# The TUI shows loading indicators while fetching. Displaying stale
|
|
471
|
+
# commit/push status misleads users. Poll this to show spinners and detect
|
|
472
|
+
# when fresh data arrives.
|
|
473
|
+
def loading?
|
|
474
|
+
@loading
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Queries git status synchronously.
|
|
478
|
+
#
|
|
479
|
+
# The user triggers manual refresh. Synchronous queries block but guarantee
|
|
480
|
+
# fresh data. Use this for explicit refresh actions, not TUI polling loops.
|
|
481
|
+
def refresh
|
|
482
|
+
@loading = true
|
|
483
|
+
Dir.chdir(@wiki_dir) do
|
|
484
|
+
# Check if committed (not in diff or untracked)
|
|
485
|
+
diff_clean = system("git diff --quiet #{@relative_path} 2>/dev/null")
|
|
486
|
+
cached_clean = system("git diff --cached --quiet #{@relative_path} 2>/dev/null")
|
|
487
|
+
tracked = system("git ls-files --error-unmatch #{@relative_path} >/dev/null 2>&1")
|
|
488
|
+
@committed = diff_clean && cached_clean && tracked
|
|
489
|
+
|
|
490
|
+
# Check if pushed
|
|
491
|
+
if @committed
|
|
492
|
+
unpushed = `git log origin/HEAD..HEAD --oneline 2>/dev/null`.strip
|
|
493
|
+
@pushed = unpushed.empty?
|
|
494
|
+
else
|
|
495
|
+
@pushed = false
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
@loading = false
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Checks wiki commit/push status in a background thread.
|
|
502
|
+
#
|
|
503
|
+
# The TUI renders continuously. Blocking on git status calls freezes the
|
|
504
|
+
# interface. This method offloads the query to a thread so the UI stays
|
|
505
|
+
# responsive.
|
|
506
|
+
def refresh_async
|
|
507
|
+
Thread.new { refresh }
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Commits the announcement file.
|
|
511
|
+
#
|
|
512
|
+
# Stages and commits the file with the given message. Does nothing in dry-run
|
|
513
|
+
# mode.
|
|
514
|
+
#
|
|
515
|
+
# [message] The commit message.
|
|
516
|
+
def commit!(message)
|
|
517
|
+
return if CLIFlags.dry_run.value
|
|
518
|
+
|
|
519
|
+
Dir.chdir(@wiki_dir) do
|
|
520
|
+
system("git", "add", @relative_path)
|
|
521
|
+
system("git", "commit", "-m", message)
|
|
522
|
+
end
|
|
523
|
+
refresh
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Pushes the wiki repo to origin.
|
|
527
|
+
#
|
|
528
|
+
# Does nothing in dry-run mode.
|
|
529
|
+
def push!
|
|
530
|
+
return if CLIFlags.dry_run.value
|
|
531
|
+
|
|
532
|
+
Dir.chdir(@wiki_dir) do
|
|
533
|
+
system("git", "push")
|
|
534
|
+
end
|
|
535
|
+
refresh
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Sends release announcement emails.
|
|
540
|
+
#
|
|
541
|
+
# Announcements go to mailing lists. macOS provides multiple ways to send:
|
|
542
|
+
# MailMate's emate CLI for instant delivery, or the mailto: URL scheme for
|
|
543
|
+
# manual review. Handling both paths manually is tedious.
|
|
544
|
+
#
|
|
545
|
+
# This class wraps email sending. It prefers emate when available. It falls
|
|
546
|
+
# back to mailto: URLs. It respects dry-run mode.
|
|
547
|
+
#
|
|
548
|
+
# Use it to send release announcements.
|
|
549
|
+
class Emailer
|
|
550
|
+
# Path to MailMate's emate CLI tool.
|
|
551
|
+
EMATE_PATH = "/Applications/MailMate.app/Contents/Resources/emate"
|
|
552
|
+
|
|
553
|
+
# Creates an Emailer.
|
|
554
|
+
#
|
|
555
|
+
# [to_address] The recipient email address.
|
|
556
|
+
# [subject] The email subject line.
|
|
557
|
+
# [body] The email body content.
|
|
558
|
+
def initialize(to_address, subject, body)
|
|
559
|
+
@to_address = to_address
|
|
560
|
+
@subject = subject
|
|
561
|
+
@body = body
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Detects whether MailMate's emate CLI is installed.
|
|
565
|
+
#
|
|
566
|
+
# The UI shows which email method will be used. Users need to know if
|
|
567
|
+
# send will be instant or require manual intervention. Check this to select
|
|
568
|
+
# the appropriate UI indicator and send method.
|
|
569
|
+
def emate_available?
|
|
570
|
+
File.executable?(EMATE_PATH)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Sends the email via emate (instant delivery).
|
|
574
|
+
#
|
|
575
|
+
# Returns true on success. Returns true immediately in dry-run mode.
|
|
576
|
+
# Returns false if emate is not available.
|
|
577
|
+
def send_via_emate!
|
|
578
|
+
return true if CLIFlags.dry_run.value
|
|
579
|
+
|
|
580
|
+
return false unless emate_available?
|
|
581
|
+
|
|
582
|
+
IO.popen([
|
|
583
|
+
EMATE_PATH,
|
|
584
|
+
"mailto",
|
|
585
|
+
"--to",
|
|
586
|
+
@to_address,
|
|
587
|
+
"--subject",
|
|
588
|
+
@subject,
|
|
589
|
+
"--send-now",
|
|
590
|
+
], "w") do |io|
|
|
591
|
+
io.write(@body)
|
|
592
|
+
end
|
|
593
|
+
true
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Sends the email via mailto: URL (opens mail client for review).
|
|
597
|
+
def send_via_mailto!
|
|
598
|
+
encoded_subject = URI.encode_www_form_component(@subject).gsub("+", "%20")
|
|
599
|
+
encoded_body = URI.encode_www_form_component(@body).gsub("+", "%20")
|
|
600
|
+
mailto = "mailto:#{@to_address}?subject=#{encoded_subject}&body=#{encoded_body}"
|
|
601
|
+
system("open", mailto)
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# TUI application for managing release announcements.
|
|
606
|
+
#
|
|
607
|
+
# Releases involve multiple steps: previewing emails, checking CI, committing
|
|
608
|
+
# wiki changes, sending announcements. Coordinating manually is error-prone.
|
|
609
|
+
# Status changes asynchronously.
|
|
610
|
+
#
|
|
611
|
+
# This class provides a tabbed TUI. It previews emails and commits. It displays
|
|
612
|
+
# a release checklist with live status. It provides actions to ship wiki
|
|
613
|
+
# changes and send announcements.
|
|
614
|
+
#
|
|
615
|
+
# Use it to orchestrate release announcements.
|
|
616
|
+
class AnnounceTUI
|
|
617
|
+
# Tab names for the UI.
|
|
618
|
+
TABS = ["Preview Email", "Preview Commit", "Announce"].freeze
|
|
619
|
+
|
|
620
|
+
# The mailing list address.
|
|
621
|
+
TO_ADDRESS = "~kerrick/ratatui_ruby-announce@lists.sr.ht"
|
|
622
|
+
|
|
623
|
+
# Creates a new AnnounceTUI.
|
|
624
|
+
def initialize
|
|
625
|
+
@current_tab = 0
|
|
626
|
+
@scroll_positions = Hash.new { |h, k| h[k] = [0, 0] } # [scroll_y, scroll_x] per tab
|
|
627
|
+
@show_help = false
|
|
628
|
+
@use_emate = nil # nil = auto-detect, true = force emate, false = force mailto
|
|
629
|
+
@content_area = nil # Stored for page scroll calculations
|
|
630
|
+
@content_block = nil # Stored for page scroll calculations
|
|
631
|
+
@current_paragraph = nil # Stored for content length calculations
|
|
632
|
+
|
|
633
|
+
# Initialize domain objects (all caching happens here)
|
|
634
|
+
@git_repo = GitRepo.new
|
|
635
|
+
@version = CLIFlags.version || @git_repo.expected_version
|
|
636
|
+
|
|
637
|
+
@announcement = Announcement.new(CLIFlags.wiki_dir.value, @version)
|
|
638
|
+
@sourcehut = SourceHut.new(max_builds: CLIFlags.builds.value)
|
|
639
|
+
@rubygems = RubyGemsInfo.new("ratatui_ruby")
|
|
640
|
+
@wiki_repo = WikiRepo.new(CLIFlags.wiki_dir.value, @announcement.path)
|
|
641
|
+
@emailer = Emailer.new(TO_ADDRESS, @announcement.subject, @announcement.body)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Starts the TUI event loop.
|
|
645
|
+
#
|
|
646
|
+
# The announcement workflow requires user interaction. A TUI provides a
|
|
647
|
+
# responsive interface for previewing, checking status, and sending. Call
|
|
648
|
+
# this after parsing CLI arguments.
|
|
649
|
+
def run
|
|
650
|
+
RatatuiRuby.run do |tui|
|
|
651
|
+
@tui = tui
|
|
652
|
+
init_styles
|
|
653
|
+
|
|
654
|
+
loop do
|
|
655
|
+
render
|
|
656
|
+
break if handle_input == :quit
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
private def init_styles
|
|
662
|
+
@hotkey_style = @tui.style(modifiers: [:bold, :underlined])
|
|
663
|
+
@ok_style = @tui.style(fg: :green)
|
|
664
|
+
@pending_style = @tui.style(fg: :yellow)
|
|
665
|
+
@error_style = @tui.style(fg: :red)
|
|
666
|
+
@border_style = @tui.style(fg: :dark_gray)
|
|
667
|
+
@title_style = @tui.style(fg: :cyan)
|
|
668
|
+
@email_client_style = @tui.style(fg: :yellow)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
private def render
|
|
672
|
+
@tui.draw do |frame|
|
|
673
|
+
@tabs_area, content_area, wiki_dir_area = @tui.layout_split(
|
|
674
|
+
frame.area,
|
|
675
|
+
direction: :vertical,
|
|
676
|
+
constraints: [
|
|
677
|
+
@tui.constraint_length(3), # Tabs
|
|
678
|
+
@tui.constraint_fill(1), # Content
|
|
679
|
+
@tui.constraint_length(1), # Wiki Dir
|
|
680
|
+
],
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# Store content area for page scroll calculations
|
|
684
|
+
@content_area = content_area
|
|
685
|
+
|
|
686
|
+
render_tabs(frame, @tabs_area)
|
|
687
|
+
render_content(frame, content_area)
|
|
688
|
+
render_wiki_dir(frame, wiki_dir_area)
|
|
689
|
+
|
|
690
|
+
# Overlay on top
|
|
691
|
+
render_help_overlay(frame) if @show_help
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
private def render_tabs(frame, area)
|
|
696
|
+
email_client = use_emate? ? "emate" : "mailto"
|
|
697
|
+
|
|
698
|
+
# # Dry-run indicator: gray when "no", bright bold white when "yes"
|
|
699
|
+
# dry_run_style = CLIFlags.dry_run.value ? @tui.style(fg: :white, modifiers: [:bold]) : @border_style
|
|
700
|
+
# dry_run_title = @tui.text_line(
|
|
701
|
+
# @tui.paragraph(content: "dry-run: #{CLIFlags.dry_run.to_short}",
|
|
702
|
+
# style: dry_run_style)
|
|
703
|
+
# )
|
|
704
|
+
dry_run_title = nil
|
|
705
|
+
|
|
706
|
+
tabs = @tui.tabs(
|
|
707
|
+
titles: TABS,
|
|
708
|
+
selected_index: @current_tab,
|
|
709
|
+
divider: @tui.text_span(content: " ▶ ", style: @border_style),
|
|
710
|
+
block: @tui.block(
|
|
711
|
+
titles: [
|
|
712
|
+
{ content: "Announce #{@version}" },
|
|
713
|
+
{ content: dry_run_title, position: :top, alignment: :center },
|
|
714
|
+
{ content: @tui.text_line(spans: [@tui.text_span(content: email_client, style: @email_client_style)]), position: :top, alignment: :right },
|
|
715
|
+
],
|
|
716
|
+
borders: [:all],
|
|
717
|
+
border_style: @border_style,
|
|
718
|
+
),
|
|
719
|
+
highlight_style: @tui.style(fg: :magenta, modifiers: [:bold]),
|
|
720
|
+
)
|
|
721
|
+
frame.render_widget(tabs, area)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
private def use_emate?
|
|
725
|
+
@use_emate.nil? ? @emailer.emate_available? : @use_emate
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
private def render_content(frame, area)
|
|
729
|
+
case @current_tab
|
|
730
|
+
when 0 then render_email_preview(frame, area)
|
|
731
|
+
when 1 then render_commit_preview(frame, area)
|
|
732
|
+
when 2 then render_checklist(frame, area)
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
private def render_email_preview(frame, area)
|
|
737
|
+
email_text = <<~EMAIL
|
|
738
|
+
To: #{TO_ADDRESS}
|
|
739
|
+
Subject: #{@announcement.subject}
|
|
740
|
+
|
|
741
|
+
#{@announcement.body}
|
|
742
|
+
EMAIL
|
|
743
|
+
|
|
744
|
+
lines = email_text.lines
|
|
745
|
+
content_length = lines.size
|
|
746
|
+
|
|
747
|
+
@current_paragraph = @tui.paragraph(
|
|
748
|
+
text: email_text,
|
|
749
|
+
wrap: true,
|
|
750
|
+
scroll: [scroll_y, scroll_x],
|
|
751
|
+
block: content_block("Email Preview"),
|
|
752
|
+
)
|
|
753
|
+
frame.render_widget(@current_paragraph, area)
|
|
754
|
+
|
|
755
|
+
scrollbar = @tui.scrollbar(
|
|
756
|
+
content_length:,
|
|
757
|
+
position: scroll_y,
|
|
758
|
+
orientation: :vertical,
|
|
759
|
+
)
|
|
760
|
+
frame.render_widget(scrollbar, area)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
private def render_commit_preview(frame, area)
|
|
764
|
+
content = @announcement.commit_message
|
|
765
|
+
content_length = content.lines.size
|
|
766
|
+
|
|
767
|
+
@current_paragraph = @tui.paragraph(
|
|
768
|
+
text: content,
|
|
769
|
+
scroll: [scroll_y, scroll_x],
|
|
770
|
+
block: content_block("Commit Preview"),
|
|
771
|
+
)
|
|
772
|
+
frame.render_widget(@current_paragraph, area)
|
|
773
|
+
|
|
774
|
+
scrollbar = @tui.scrollbar(
|
|
775
|
+
content_length:,
|
|
776
|
+
position: scroll_y,
|
|
777
|
+
orientation: :vertical,
|
|
778
|
+
)
|
|
779
|
+
frame.render_widget(scrollbar, area)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
private def render_checklist(frame, area)
|
|
783
|
+
lines = checklist_items.map { |item| checklist_line(item) }
|
|
784
|
+
|
|
785
|
+
paragraph = @tui.paragraph(
|
|
786
|
+
text: lines,
|
|
787
|
+
block: content_block("Release Checklist"),
|
|
788
|
+
)
|
|
789
|
+
frame.render_widget(paragraph, area)
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
private def checklist_items
|
|
793
|
+
# Build checklist from domain objects (reads cached data only)
|
|
794
|
+
# Each item shows loading if its domain object is still loading
|
|
795
|
+
|
|
796
|
+
# Build latest tag status first (needed for tag_pushed logic)
|
|
797
|
+
latest_tag_value = @git_repo.latest_tag || "None"
|
|
798
|
+
latest_tag_status = if @git_repo.tag_matches?
|
|
799
|
+
:ok
|
|
800
|
+
else
|
|
801
|
+
latest_tag_value = "#{latest_tag_value} (Should be #{@git_repo.expected_version})"
|
|
802
|
+
:error
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Git repo tag pushed check (only meaningful if tag matches)
|
|
806
|
+
tag_pushed_item = if !@git_repo.tag_matches?
|
|
807
|
+
{ value: "N/A", status: :pending }
|
|
808
|
+
elsif @git_repo.loading? || @git_repo.tag_pushed.nil?
|
|
809
|
+
{ value: "Loading...", status: :loading }
|
|
810
|
+
elsif @git_repo.tag_pushed
|
|
811
|
+
{ value: "Yes", status: :ok }
|
|
812
|
+
else
|
|
813
|
+
{ value: "No", status: :error }
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# SourceHut builds
|
|
817
|
+
builds_item = if @sourcehut.loading?
|
|
818
|
+
{ value: "Loading...", status: :loading }
|
|
819
|
+
else
|
|
820
|
+
builds_status = if @sourcehut.any_running?
|
|
821
|
+
:pending
|
|
822
|
+
elsif @sourcehut.all_passed?
|
|
823
|
+
:ok
|
|
824
|
+
else
|
|
825
|
+
:error
|
|
826
|
+
end
|
|
827
|
+
builds_value = @sourcehut.builds.map { |b| "##{b[:id]} #{b[:status]}" }.join(", ")
|
|
828
|
+
{ value: builds_value.empty? ? "None" : builds_value, status: builds_status }
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# RubyGems
|
|
832
|
+
gem_item = if @rubygems.loading?
|
|
833
|
+
{ value: "Loading...", status: :loading }
|
|
834
|
+
else
|
|
835
|
+
{ value: @rubygems.published_version || "Unknown", status: @rubygems.published?(@version) ? :ok : :pending }
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# Wiki repo
|
|
839
|
+
wiki_committed = if @wiki_repo.loading?
|
|
840
|
+
{ value: "Loading...", status: :loading }
|
|
841
|
+
else
|
|
842
|
+
{ value: @wiki_repo.committed ? "Yes" : "No", status: @wiki_repo.committed ? :ok : :pending }
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
wiki_pushed = if @wiki_repo.loading?
|
|
846
|
+
{ value: "Loading...", status: :loading }
|
|
847
|
+
else
|
|
848
|
+
{ value: @wiki_repo.pushed ? "Yes" : "No", status: @wiki_repo.pushed ? :ok : :pending }
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
[
|
|
852
|
+
{ label: "Latest tag", value: latest_tag_value, status: latest_tag_status },
|
|
853
|
+
{ label: "Tag pushed", **tag_pushed_item },
|
|
854
|
+
{ label: "Builds (#{@sourcehut.builds.size})", **builds_item },
|
|
855
|
+
{ label: "Gem published", **gem_item },
|
|
856
|
+
{ label: "Wiki committed", **wiki_committed },
|
|
857
|
+
{ label: "Wiki pushed", **wiki_pushed },
|
|
858
|
+
]
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
private def checklist_line(item)
|
|
862
|
+
icon = case item[:status]
|
|
863
|
+
when :ok then "✓"
|
|
864
|
+
when :error then "✗"
|
|
865
|
+
when :pending then "●"
|
|
866
|
+
when :loading then "…"
|
|
867
|
+
else "?"
|
|
868
|
+
end
|
|
869
|
+
style = case item[:status]
|
|
870
|
+
when :ok then @ok_style
|
|
871
|
+
when :error then @error_style
|
|
872
|
+
when :pending, :loading then @pending_style
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
@tui.text_line(spans: [
|
|
876
|
+
@tui.text_span(content: "#{icon} ", style:),
|
|
877
|
+
@tui.text_span(content: "#{item[:label]}: "),
|
|
878
|
+
@tui.text_span(content: item[:value].to_s),
|
|
879
|
+
])
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
private def content_block(title)
|
|
883
|
+
# Bottom-left titles for universal controls
|
|
884
|
+
bottom_left = @tui.text_line(spans: [
|
|
885
|
+
@tui.text_span(content: "?", style: @hotkey_style),
|
|
886
|
+
@tui.text_span(content: ":Help"),
|
|
887
|
+
@tui.text_span(content: "─", style: @border_style),
|
|
888
|
+
@tui.text_span(content: "q", style: @hotkey_style),
|
|
889
|
+
@tui.text_span(content: ": Quit"),
|
|
890
|
+
])
|
|
891
|
+
|
|
892
|
+
titles = [
|
|
893
|
+
{ content: title, position: :top, alignment: :left },
|
|
894
|
+
{ content: bottom_left, position: :bottom, alignment: :left },
|
|
895
|
+
]
|
|
896
|
+
|
|
897
|
+
# Add action controls on Announce tab (bottom-right, styled red)
|
|
898
|
+
if @current_tab == 2
|
|
899
|
+
action_style = @tui.style(fg: :red, modifiers: [:bold, :underlined])
|
|
900
|
+
bottom_right = @tui.text_line(spans: [
|
|
901
|
+
@tui.text_span(content: "w", style: action_style),
|
|
902
|
+
@tui.text_span(content: ":Ship Wiki", style: @tui.style(fg: :red)),
|
|
903
|
+
@tui.text_span(content: "─", style: @border_style),
|
|
904
|
+
@tui.text_span(content: "s", style: action_style),
|
|
905
|
+
@tui.text_span(content: ":Send Email", style: @tui.style(fg: :red)),
|
|
906
|
+
@tui.text_span(content: "─", style: @border_style),
|
|
907
|
+
@tui.text_span(content: "r", style: action_style),
|
|
908
|
+
@tui.text_span(content: ":Refresh", style: @tui.style(fg: :red)),
|
|
909
|
+
])
|
|
910
|
+
titles << { content: bottom_right, position: :bottom, alignment: :right }
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
# Store block for page scroll inner height calculation
|
|
914
|
+
@content_block = @tui.block(
|
|
915
|
+
titles:,
|
|
916
|
+
borders: [:all],
|
|
917
|
+
border_style: @border_style,
|
|
918
|
+
title_style: @title_style,
|
|
919
|
+
)
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
private def control_line(pairs)
|
|
923
|
+
spans = pairs.flat_map do |key, desc|
|
|
924
|
+
[
|
|
925
|
+
@tui.text_span(content: key, style: @hotkey_style),
|
|
926
|
+
@tui.text_span(content: ": #{desc} "),
|
|
927
|
+
]
|
|
928
|
+
end
|
|
929
|
+
@tui.text_line(spans:)
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
private def render_help_overlay(frame)
|
|
933
|
+
# Clear the area first
|
|
934
|
+
frame.render_widget(@tui.clear, frame.area)
|
|
935
|
+
|
|
936
|
+
help_text = <<~HELP
|
|
937
|
+
Navigation
|
|
938
|
+
←/→ or h/l Switch tabs
|
|
939
|
+
[/] or Tab Switch tabs (alternate)
|
|
940
|
+
↑/↓ or j/k Scroll one line
|
|
941
|
+
PgUp/PgDn Scroll 10 lines
|
|
942
|
+
Home/End Scroll to top/bottom
|
|
943
|
+
Mouse wheel Scroll 3 lines
|
|
944
|
+
|
|
945
|
+
Actions (Announce tab only)
|
|
946
|
+
w Ship Wiki
|
|
947
|
+
s Send Email
|
|
948
|
+
r Refresh all data
|
|
949
|
+
|
|
950
|
+
General
|
|
951
|
+
? Toggle this help
|
|
952
|
+
q or Ctrl+C Quit
|
|
953
|
+
HELP
|
|
954
|
+
|
|
955
|
+
content = @tui.paragraph(
|
|
956
|
+
text: help_text,
|
|
957
|
+
block: @tui.block(title: "Help (press Esc to close)", borders: [:all]),
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
center_widget = @tui.center(
|
|
961
|
+
child: content,
|
|
962
|
+
width_percent: 80,
|
|
963
|
+
height_percent: 80,
|
|
964
|
+
)
|
|
965
|
+
frame.render_widget(center_widget, frame.area)
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
private def render_wiki_dir(frame, area)
|
|
969
|
+
frame.render_widget(@tui.text_line(
|
|
970
|
+
alignment: :center,
|
|
971
|
+
spans: [
|
|
972
|
+
@tui.text_span(
|
|
973
|
+
content: "Wiki Dir: ",
|
|
974
|
+
style: @tui.style(
|
|
975
|
+
modifiers: [:dim]
|
|
976
|
+
)
|
|
977
|
+
),
|
|
978
|
+
@tui.text_span(
|
|
979
|
+
content: CLIFlags.wiki_dir.to_short
|
|
980
|
+
),
|
|
981
|
+
]
|
|
982
|
+
), area)
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
private
|
|
986
|
+
|
|
987
|
+
private def handle_input
|
|
988
|
+
event = @tui.poll_event
|
|
989
|
+
|
|
990
|
+
# When help overlay is open, only ? closes it (but q/Ctrl+C still quit)
|
|
991
|
+
if @show_help
|
|
992
|
+
case event
|
|
993
|
+
in { type: :key, code: "q" } | { type: :key, code: "c", modifiers: ["ctrl"] }
|
|
994
|
+
return :quit
|
|
995
|
+
in { type: :key, code: "?" } | { type: :key, code: "esc" }
|
|
996
|
+
@show_help = false
|
|
997
|
+
else
|
|
998
|
+
# Ignore all other input
|
|
999
|
+
end
|
|
1000
|
+
return
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
case event
|
|
1004
|
+
in { type: :key, code: "q" } | { type: :key, code: "c", modifiers: ["ctrl"] }
|
|
1005
|
+
:quit
|
|
1006
|
+
in { type: :key, code: "right" } | { type: :key, code: "l" } | { type: :key, code: "]" } | { type: :key, code: "tab", modifiers: ["ctrl"] }
|
|
1007
|
+
@current_tab = [@current_tab + 1, TABS.size - 1].min
|
|
1008
|
+
in { type: :key, code: "left" } | { type: :key, code: "h" } | { type: :key, code: "[" } | { type: :key, code: "backtab" }
|
|
1009
|
+
@current_tab = [@current_tab - 1, 0].max
|
|
1010
|
+
in { type: :key, code: "up" } | { type: :key, code: "k" }
|
|
1011
|
+
self.scroll_y = [scroll_y - 1, 0].max
|
|
1012
|
+
in { type: :key, code: "down" } | { type: :key, code: "j" }
|
|
1013
|
+
self.scroll_y += 1
|
|
1014
|
+
in { type: :key, code: "home" }
|
|
1015
|
+
self.scroll_y = 0
|
|
1016
|
+
in { type: :key, code: "end" }
|
|
1017
|
+
self.scroll_y = max_scroll_y
|
|
1018
|
+
in { type: :key, code: "page_up" }
|
|
1019
|
+
self.scroll_y = [scroll_y - page_height, 0].max
|
|
1020
|
+
in { type: :key, code: "page_down" }
|
|
1021
|
+
self.scroll_y += page_height
|
|
1022
|
+
in { type: :key, code: "?" }
|
|
1023
|
+
@show_help = !@show_help
|
|
1024
|
+
in { type: :key, code: "w" } if @current_tab == 2
|
|
1025
|
+
commit_and_push_wiki
|
|
1026
|
+
in { type: :key, code: "s" } if @current_tab == 2
|
|
1027
|
+
send_email
|
|
1028
|
+
in { type: :key, code: "r" } if @current_tab == 2
|
|
1029
|
+
refresh_all
|
|
1030
|
+
in { type: :mouse, kind: "scroll_down" }
|
|
1031
|
+
self.scroll_y += 1
|
|
1032
|
+
in { type: :mouse, kind: "scroll_up" }
|
|
1033
|
+
self.scroll_y = [scroll_y - 1, 0].max
|
|
1034
|
+
|
|
1035
|
+
else
|
|
1036
|
+
# Ignore other events
|
|
1037
|
+
end
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
private def scroll_y = @scroll_positions[@current_tab][0]
|
|
1041
|
+
private def scroll_x = @scroll_positions[@current_tab][1]
|
|
1042
|
+
|
|
1043
|
+
private def scroll_y=(val)
|
|
1044
|
+
@scroll_positions[@current_tab][0] = val
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
private def scroll_x=(val)
|
|
1048
|
+
@scroll_positions[@current_tab][1] = val
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
private def page_height
|
|
1052
|
+
return 10 unless @content_area && @content_block
|
|
1053
|
+
|
|
1054
|
+
@content_block.inner(@content_area).height
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
private def content_length
|
|
1058
|
+
return 0 unless @current_paragraph && @content_area && @content_block
|
|
1059
|
+
|
|
1060
|
+
inner_width = @content_block.inner(@content_area).width
|
|
1061
|
+
@current_paragraph.line_count(inner_width)
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
private def max_scroll_y
|
|
1065
|
+
[content_length - page_height, 0].max
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
# Actions
|
|
1069
|
+
private def commit_and_push_wiki
|
|
1070
|
+
@wiki_repo.commit!(@announcement.commit_message)
|
|
1071
|
+
@wiki_repo.push!
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
private def send_email
|
|
1075
|
+
if use_emate?
|
|
1076
|
+
@emailer.send_via_emate!
|
|
1077
|
+
else
|
|
1078
|
+
@emailer.send_via_mailto!
|
|
1079
|
+
end
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
private def refresh_all
|
|
1083
|
+
@git_repo.refresh
|
|
1084
|
+
@sourcehut.refresh
|
|
1085
|
+
@rubygems.refresh
|
|
1086
|
+
@wiki_repo.refresh
|
|
1087
|
+
end
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
if __FILE__ == $PROGRAM_NAME
|
|
1091
|
+
# Parse CLI options
|
|
1092
|
+
script_dir = File.dirname(File.expand_path(__FILE__))
|
|
1093
|
+
|
|
1094
|
+
OptionParser.new do |opts|
|
|
1095
|
+
opts.banner = "Usage: bin/announce_tui [OPTIONS] [VERSION]"
|
|
1096
|
+
|
|
1097
|
+
opts.on("-w", "--wiki-dir PATH", "Path to wiki repo") do |path|
|
|
1098
|
+
CLIFlags.wiki_dir = PathFlag.new(File.expand_path(path))
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
opts.on("-n", "--builds N", Integer, "Number of builds to show") do |n|
|
|
1102
|
+
CLIFlags.builds = IntFlag.new(n)
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
opts.on("-d", "--dry-run", "Stub dangerous actions (emate, git commit/push)") do
|
|
1106
|
+
CLIFlags.dry_run = BoolFlag.new(true)
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
opts.on("-h", "--help", "Show this help") do
|
|
1110
|
+
puts opts
|
|
1111
|
+
exit
|
|
1112
|
+
end
|
|
1113
|
+
end.parse!
|
|
1114
|
+
|
|
1115
|
+
# Set defaults and determine version
|
|
1116
|
+
CLIFlags.wiki_dir ||= PathFlag.new(File.expand_path(File.join(script_dir, "..", "..", "ratatui_ruby-wiki")))
|
|
1117
|
+
CLIFlags.version = ARGV[0] # nil if not provided, will use RatatuiRuby::VERSION via GitRepo
|
|
1118
|
+
|
|
1119
|
+
AnnounceTUI.new.run
|
|
1120
|
+
end
|