kettle-dev 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/.devcontainer/devcontainer.json +26 -0
- data/.envrc +42 -0
- data/.git-hooks/commit-msg +41 -0
- data/.git-hooks/commit-subjects-goalie.txt +8 -0
- data/.git-hooks/footer-template.erb.txt +16 -0
- data/.git-hooks/prepare-commit-msg +20 -0
- data/.github/FUNDING.yml +13 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ancient.yml +80 -0
- data/.github/workflows/auto-assign.yml +21 -0
- data/.github/workflows/codeql-analysis.yml +70 -0
- data/.github/workflows/coverage.yml +130 -0
- data/.github/workflows/current.yml +88 -0
- data/.github/workflows/dependency-review.yml +20 -0
- data/.github/workflows/discord-notifier.yml +38 -0
- data/.github/workflows/heads.yml +87 -0
- data/.github/workflows/jruby.yml +79 -0
- data/.github/workflows/legacy.yml +70 -0
- data/.github/workflows/locked_deps.yml +88 -0
- data/.github/workflows/opencollective.yml +40 -0
- data/.github/workflows/style.yml +67 -0
- data/.github/workflows/supported.yml +85 -0
- data/.github/workflows/truffle.yml +78 -0
- data/.github/workflows/unlocked_deps.yml +87 -0
- data/.github/workflows/unsupported.yml +78 -0
- data/.gitignore +48 -0
- data/.gitlab-ci.yml +45 -0
- data/.junie/guidelines-rbs.md +49 -0
- data/.junie/guidelines.md +132 -0
- data/.opencollective.yml +3 -0
- data/.qlty/qlty.toml +79 -0
- data/.rspec +8 -0
- data/.rubocop.yml +13 -0
- data/.simplecov +7 -0
- data/.tool-versions +1 -0
- data/.yard_gfm_support.rb +22 -0
- data/.yardopts +11 -0
- data/Appraisal.root.gemfile +12 -0
- data/Appraisals +120 -0
- data/CHANGELOG.md +26 -1
- data/Gemfile +32 -0
- data/Rakefile +99 -0
- data/checksums/kettle-dev-1.0.2.gem.sha256 +1 -0
- data/checksums/kettle-dev-1.0.2.gem.sha512 +1 -0
- data/checksums/kettle-dev-1.0.3.gem.sha256 +1 -0
- data/checksums/kettle-dev-1.0.3.gem.sha512 +1 -0
- data/exe/kettle-release +2 -3
- data/gemfiles/modular/coverage.gemfile +6 -0
- data/gemfiles/modular/documentation.gemfile +11 -0
- data/gemfiles/modular/style.gemfile +16 -0
- data/lib/kettle/dev/rakelib/appraisal.rake +40 -0
- data/lib/kettle/dev/rakelib/bench.rake +58 -0
- data/lib/kettle/dev/rakelib/bundle_audit.rake +18 -0
- data/lib/kettle/dev/rakelib/ci.rake +348 -0
- data/lib/kettle/dev/rakelib/install.rake +304 -0
- data/lib/kettle/dev/rakelib/reek.rake +34 -0
- data/lib/kettle/dev/rakelib/require_bench.rake +7 -0
- data/lib/kettle/dev/rakelib/rubocop_gradual.rake +9 -0
- data/lib/kettle/dev/rakelib/spec_test.rake +42 -0
- data/lib/kettle/dev/rakelib/template.rake +461 -0
- data/lib/kettle/dev/rakelib/yard.rake +33 -0
- data/lib/kettle/dev/version.rb +1 -1
- data.tar.gz.sig +0 -0
- metadata +67 -4
- metadata.gz.sig +0 -0
@@ -0,0 +1,348 @@
|
|
1
|
+
# --- CI helpers ---
|
2
|
+
namespace :ci do
|
3
|
+
# rubocop:disable ThreadSafety/NewThread
|
4
|
+
desc "Run 'act' with a selected workflow. Usage: rake ci:act[loc] (short code = first 3 letters of filename, e.g., 'loc' => locked_deps.yml), rake ci:act[locked_deps], rake ci:act[locked_deps.yml], or rake ci:act (then choose)"
|
5
|
+
task :act, [:opt] do |_t, args|
|
6
|
+
require "io/console"
|
7
|
+
require "open3"
|
8
|
+
require "net/http"
|
9
|
+
require "json"
|
10
|
+
require "uri"
|
11
|
+
require "kettle/dev/ci_helpers"
|
12
|
+
|
13
|
+
# Build mapping dynamically from workflow files; short code = first three letters of filename.
|
14
|
+
# Collisions are resolved by first-come wins via ||= as requested.
|
15
|
+
mapping = {}
|
16
|
+
|
17
|
+
# Normalize provided option. Accept either short code or the exact yml/yaml filename
|
18
|
+
choice = args[:opt]&.strip
|
19
|
+
root_dir = Kettle::Dev::CIHelpers.project_root
|
20
|
+
workflows_dir = File.join(root_dir, ".github", "workflows")
|
21
|
+
|
22
|
+
# Determine actual workflow files present, and prepare dynamic additions excluding specified files.
|
23
|
+
existing_files = if Dir.exist?(workflows_dir)
|
24
|
+
Dir[File.join(workflows_dir, "*.yml")] + Dir[File.join(workflows_dir, "*.yaml")]
|
25
|
+
else
|
26
|
+
[]
|
27
|
+
end
|
28
|
+
existing_basenames = existing_files.map { |p| File.basename(p) }
|
29
|
+
|
30
|
+
# Build short-code mapping (first 3 chars of filename stem), excluding some maintenance workflows.
|
31
|
+
exclusions = Kettle::Dev::CIHelpers.exclusions
|
32
|
+
candidate_files = existing_basenames.uniq - exclusions
|
33
|
+
candidate_files.sort.each do |fname|
|
34
|
+
stem = fname.sub(/\.(ya?ml)\z/, "")
|
35
|
+
code = stem[0, 3].to_s.downcase
|
36
|
+
next if code.empty?
|
37
|
+
mapping[code] ||= fname # first-come wins on collisions
|
38
|
+
end
|
39
|
+
|
40
|
+
# Any remaining candidates that didn't get a unique shortcode are treated as dynamic (number-only) options
|
41
|
+
dynamic_files = candidate_files - mapping.values
|
42
|
+
|
43
|
+
# For internal status tracking and rendering, we use a display_code_for hash.
|
44
|
+
# For mapped (short-code) entries, display_code is the short code.
|
45
|
+
# For dynamic entries, display_code is empty string, but we key statuses by a unique code = the filename.
|
46
|
+
display_code_for = {}
|
47
|
+
mapping.keys.each { |k| display_code_for[k] = k }
|
48
|
+
dynamic_files.each { |f| display_code_for[f] = "" }
|
49
|
+
|
50
|
+
# Helpers
|
51
|
+
get_branch = proc do
|
52
|
+
out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
|
53
|
+
status.success? ? out.strip : nil
|
54
|
+
end
|
55
|
+
|
56
|
+
get_origin = proc do
|
57
|
+
out, status = Open3.capture2("git", "config", "--get", "remote.origin.url")
|
58
|
+
next nil unless status.success?
|
59
|
+
url = out.strip
|
60
|
+
# Support ssh and https URLs
|
61
|
+
if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
|
62
|
+
[$1, $2.sub(/\.git\z/, "")]
|
63
|
+
elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
|
64
|
+
[$1, $2.sub(/\.git\z/, "")]
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
status_emoji = proc do |status, conclusion|
|
69
|
+
case status
|
70
|
+
when "queued"
|
71
|
+
"⏳️"
|
72
|
+
when "in_progress"
|
73
|
+
"👟"
|
74
|
+
when "completed"
|
75
|
+
(conclusion == "success") ? "✅" : "🍅"
|
76
|
+
else
|
77
|
+
"⏳️"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
fetch_and_print_status = proc do |workflow_file|
|
82
|
+
branch = get_branch.call
|
83
|
+
org_repo = get_origin.call
|
84
|
+
unless branch && org_repo
|
85
|
+
puts "GHA status: (skipped; missing git branch or remote)"
|
86
|
+
next
|
87
|
+
end
|
88
|
+
owner, repo = org_repo
|
89
|
+
uri = URI("https://api.github.com/repos/#{owner}/#{repo}/actions/workflows/#{workflow_file}/runs?branch=#{URI.encode_www_form_component(branch)}&per_page=1")
|
90
|
+
req = Net::HTTP::Get.new(uri)
|
91
|
+
req["User-Agent"] = "ci:act rake task"
|
92
|
+
token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
|
93
|
+
req["Authorization"] = "token #{token}" if token && !token.empty?
|
94
|
+
|
95
|
+
begin
|
96
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
|
97
|
+
if res.is_a?(Net::HTTPSuccess)
|
98
|
+
data = JSON.parse(res.body)
|
99
|
+
run = data["workflow_runs"]&.first
|
100
|
+
if run
|
101
|
+
status = run["status"]
|
102
|
+
conclusion = run["conclusion"]
|
103
|
+
emoji = status_emoji.call(status, conclusion)
|
104
|
+
details = [status, conclusion].compact.join("/")
|
105
|
+
puts "Latest GHA (#{branch}) for #{workflow_file}: #{emoji} (#{details})"
|
106
|
+
else
|
107
|
+
puts "Latest GHA (#{branch}) for #{workflow_file}: none"
|
108
|
+
end
|
109
|
+
else
|
110
|
+
puts "GHA status: request failed (#{res.code})"
|
111
|
+
end
|
112
|
+
rescue StandardError => e
|
113
|
+
puts "GHA status: error #{e.class}: #{e.message}"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def run_act_for(file_path)
|
118
|
+
# Prefer array form to avoid shell escaping issues
|
119
|
+
ok = system("act", "-W", file_path)
|
120
|
+
abort("ci:act failed: 'act' command not found or exited with failure") unless ok
|
121
|
+
end
|
122
|
+
|
123
|
+
def process_success_response(res, c, f, old = nil, current = nil)
|
124
|
+
data = JSON.parse(res.body)
|
125
|
+
run = data["workflow_runs"]&.first
|
126
|
+
append = (old && current) ? " (update git remote: #{old} → #{current})" : ""
|
127
|
+
if run
|
128
|
+
st = run["status"]
|
129
|
+
con = run["conclusion"]
|
130
|
+
emoji = case st
|
131
|
+
when "queued" then "⏳️"
|
132
|
+
when "in_progress" then "👟"
|
133
|
+
when "completed" then ((con == "success") ? "✅" : "🍅")
|
134
|
+
else "⏳️"
|
135
|
+
end
|
136
|
+
details = [st, con].compact.join("/")
|
137
|
+
[c, f, "#{emoji} (#{details})#{append}"]
|
138
|
+
else
|
139
|
+
[c, f, "none#{append}"]
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
if choice && !choice.empty?
|
144
|
+
# If user passed a filename directly (with or without extension), resolve it
|
145
|
+
file = if mapping.key?(choice)
|
146
|
+
mapping.fetch(choice)
|
147
|
+
elsif !!(/\.(yml|yaml)\z/ =~ choice)
|
148
|
+
# Accept either full basename (without ext) or basename with .yml/.yaml
|
149
|
+
choice
|
150
|
+
else
|
151
|
+
cand_yml = File.join(workflows_dir, "#{choice}.yml")
|
152
|
+
cand_yaml = File.join(workflows_dir, "#{choice}.yaml")
|
153
|
+
if File.file?(cand_yml)
|
154
|
+
"#{choice}.yml"
|
155
|
+
elsif File.file?(cand_yaml)
|
156
|
+
"#{choice}.yaml"
|
157
|
+
else
|
158
|
+
# Fall back to .yml for error messaging; will fail below
|
159
|
+
"#{choice}.yml"
|
160
|
+
end
|
161
|
+
end
|
162
|
+
file_path = File.join(workflows_dir, file)
|
163
|
+
unless File.file?(file_path)
|
164
|
+
puts "Unknown option or missing workflow file: #{choice} -> #{file}"
|
165
|
+
puts "Available options:"
|
166
|
+
mapping.each { |k, v| puts " #{k.ljust(3)} => #{v}" }
|
167
|
+
# Also display dynamically discovered files
|
168
|
+
unless dynamic_files.empty?
|
169
|
+
puts " (others) =>"
|
170
|
+
dynamic_files.each { |v| puts " #{v}" }
|
171
|
+
end
|
172
|
+
abort("ci:act aborted")
|
173
|
+
end
|
174
|
+
fetch_and_print_status.call(file)
|
175
|
+
run_act_for(file_path)
|
176
|
+
next
|
177
|
+
end
|
178
|
+
|
179
|
+
# No option provided: interactive menu with live GHA statuses via Threads (no Ractors)
|
180
|
+
require "thread"
|
181
|
+
|
182
|
+
tty = $stdout.tty?
|
183
|
+
# Build options: first the filtered short-code mapping, then dynamic files (no short codes)
|
184
|
+
options = mapping.to_a + dynamic_files.map { |f| [f, f] }
|
185
|
+
|
186
|
+
# Add a Quit choice
|
187
|
+
quit_code = "q"
|
188
|
+
options_with_quit = options + [[quit_code, "(quit)"]]
|
189
|
+
|
190
|
+
idx_by_code = {}
|
191
|
+
options_with_quit.each_with_index { |(k, _v), i| idx_by_code[k] = i }
|
192
|
+
|
193
|
+
# Determine repo context once
|
194
|
+
branch = get_branch.call
|
195
|
+
org = get_origin.call
|
196
|
+
owner, repo = org if org
|
197
|
+
token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
|
198
|
+
|
199
|
+
puts "Select a workflow to run with 'act':"
|
200
|
+
|
201
|
+
# Render initial menu with placeholder statuses
|
202
|
+
placeholder = "[…]"
|
203
|
+
options_with_quit.each_with_index do |(k, v), idx|
|
204
|
+
status_col = (k == quit_code) ? "" : placeholder
|
205
|
+
disp = (k == quit_code) ? k : display_code_for[k]
|
206
|
+
line = format("%2d) %-3s => %-20s %s", idx + 1, disp, v, status_col)
|
207
|
+
puts line
|
208
|
+
end
|
209
|
+
|
210
|
+
puts "(Fetching latest GHA status for branch #{branch || "n/a"} — you can type your choice and press Enter)"
|
211
|
+
prompt = "Enter number or code (or 'q' to quit): "
|
212
|
+
print prompt
|
213
|
+
$stdout.flush
|
214
|
+
|
215
|
+
# Thread + Queue to read user input
|
216
|
+
input_q = Queue.new
|
217
|
+
input_thread = Thread.new do
|
218
|
+
line = $stdin.gets&.strip
|
219
|
+
input_q << line
|
220
|
+
end
|
221
|
+
|
222
|
+
# Worker threads to fetch statuses and stream updates as they complete
|
223
|
+
status_q = Queue.new
|
224
|
+
workers = []
|
225
|
+
|
226
|
+
# Capture a monotonic start time to guard against early race with terminal rendering
|
227
|
+
start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
228
|
+
|
229
|
+
options.each do |code, file|
|
230
|
+
workers << Thread.new(code, file, owner, repo, branch, token, start_at) do |c, f, ow, rp, br, tk, st_at|
|
231
|
+
begin
|
232
|
+
# small initial delay if threads finish too quickly, to let the menu/prompt finish rendering
|
233
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
234
|
+
delay = 0.12 - (now - st_at)
|
235
|
+
sleep(delay) if delay && delay > 0
|
236
|
+
|
237
|
+
if ow.nil? || rp.nil? || br.nil?
|
238
|
+
status_q << [c, f, "n/a"]
|
239
|
+
Thread.exit
|
240
|
+
end
|
241
|
+
uri = URI("https://api.github.com/repos/#{ow}/#{rp}/actions/workflows/#{f}/runs?branch=#{URI.encode_www_form_component(br)}&per_page=1")
|
242
|
+
req = Net::HTTP::Get.new(uri)
|
243
|
+
req["User-Agent"] = "ci:act rake task"
|
244
|
+
req["Authorization"] = "token #{tk}" if tk && !tk.empty?
|
245
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
|
246
|
+
status_q <<
|
247
|
+
if res.is_a?(Net::HTTPSuccess)
|
248
|
+
process_success_response(res, c, f)
|
249
|
+
else
|
250
|
+
[c, f, "fail #{res.code}"]
|
251
|
+
end
|
252
|
+
rescue StandardError
|
253
|
+
status_q << [c, f, "err"]
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
# Live update loop: either statuses arrive or the user submits input
|
259
|
+
statuses = Hash.new(placeholder)
|
260
|
+
selected = nil
|
261
|
+
|
262
|
+
loop do
|
263
|
+
# Check for user input first (non-blocking)
|
264
|
+
unless input_q.empty?
|
265
|
+
selected = begin
|
266
|
+
input_q.pop(true)
|
267
|
+
rescue
|
268
|
+
nil
|
269
|
+
end
|
270
|
+
break if selected
|
271
|
+
end
|
272
|
+
|
273
|
+
# Drain any available status updates without blocking
|
274
|
+
begin
|
275
|
+
code, file_name, display = status_q.pop(true)
|
276
|
+
statuses[code] = display
|
277
|
+
|
278
|
+
if tty
|
279
|
+
idx = idx_by_code[code]
|
280
|
+
if idx.nil?
|
281
|
+
puts "status #{code}: #{display}"
|
282
|
+
print(prompt)
|
283
|
+
else
|
284
|
+
move_up = options_with_quit.size - idx + 1 # 1 for instruction line + remaining options above last
|
285
|
+
$stdout.print("\e[#{move_up}A\r\e[2K")
|
286
|
+
disp = (code == quit_code) ? code : display_code_for[code]
|
287
|
+
$stdout.print(format("%2d) %-3s => %-20s %s\n", idx + 1, disp, file_name, display))
|
288
|
+
$stdout.print("\e[#{move_up - 1}B\r")
|
289
|
+
$stdout.print(prompt)
|
290
|
+
end
|
291
|
+
$stdout.flush
|
292
|
+
else
|
293
|
+
puts "status #{code}: #{display}"
|
294
|
+
end
|
295
|
+
rescue ThreadError
|
296
|
+
# Queue empty: brief sleep to avoid busy wait
|
297
|
+
sleep(0.05)
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
# Cleanup: kill any still-running threads
|
302
|
+
begin
|
303
|
+
workers.each { |t| t.kill if t&.alive? }
|
304
|
+
rescue StandardError
|
305
|
+
# ignore
|
306
|
+
end
|
307
|
+
begin
|
308
|
+
input_thread.kill if input_thread&.alive?
|
309
|
+
rescue StandardError
|
310
|
+
# ignore
|
311
|
+
end
|
312
|
+
|
313
|
+
input = selected
|
314
|
+
abort("ci:act aborted: no selection") if input.nil? || input.empty?
|
315
|
+
|
316
|
+
# Normalize selection
|
317
|
+
chosen_file = nil
|
318
|
+
if !!(/^\d+$/ =~ input)
|
319
|
+
idx = input.to_i - 1
|
320
|
+
if idx < 0 || idx >= options_with_quit.length
|
321
|
+
abort("ci:act aborted: invalid selection #{input}")
|
322
|
+
end
|
323
|
+
code, val = options_with_quit[idx]
|
324
|
+
if code == quit_code
|
325
|
+
puts "ci:act: quit"
|
326
|
+
next
|
327
|
+
else
|
328
|
+
chosen_file = val
|
329
|
+
end
|
330
|
+
else
|
331
|
+
code = input
|
332
|
+
if ["q", "quit", "exit"].include?(code.downcase)
|
333
|
+
puts "ci:act: quit"
|
334
|
+
next
|
335
|
+
end
|
336
|
+
chosen_file = mapping[code]
|
337
|
+
abort("ci:act aborted: unknown code '#{code}'") unless chosen_file
|
338
|
+
end
|
339
|
+
|
340
|
+
file_path = File.join(workflows_dir, chosen_file)
|
341
|
+
abort("ci:act aborted: workflow not found: #{file_path}") unless File.file?(file_path)
|
342
|
+
|
343
|
+
# Print status for the chosen workflow (for consistency)
|
344
|
+
fetch_and_print_status.call(chosen_file)
|
345
|
+
run_act_for(file_path)
|
346
|
+
end
|
347
|
+
# rubocop:enable ThreadSafety/NewThread
|
348
|
+
end
|
@@ -0,0 +1,304 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Install helpers for kettle-dev
|
4
|
+
# Creates a .github directory in the invoking project, copying the templates
|
5
|
+
# from this library's repository checkout (when available), and sets executable
|
6
|
+
# bits on script files. Also prints post-install instructions.
|
7
|
+
namespace :kettle do
|
8
|
+
namespace :dev do
|
9
|
+
desc "Install kettle-dev GitHub automation and setup hints into the current project"
|
10
|
+
task :install do
|
11
|
+
require "fileutils"
|
12
|
+
require "kettle/dev/template_helpers"
|
13
|
+
|
14
|
+
helpers = Kettle::Dev::TemplateHelpers
|
15
|
+
|
16
|
+
# Determine invoking project root (where rake was started)
|
17
|
+
project_root = helpers.project_root
|
18
|
+
|
19
|
+
# Run file templating via dedicated task
|
20
|
+
Rake::Task["kettle:dev:template"].invoke
|
21
|
+
# template task does the clean git check first thing.
|
22
|
+
# If template is moved do it does not run here, then the clean git check must be run here instead.
|
23
|
+
# Ensure git working tree is clean before making changes
|
24
|
+
# helpers.ensure_clean_git!(root: project_root, task_label: "kettle:dev:install")
|
25
|
+
|
26
|
+
# .tool-versions cleanup offers
|
27
|
+
tool_versions_path = File.join(project_root, ".tool-versions")
|
28
|
+
if File.file?(tool_versions_path)
|
29
|
+
rv = File.join(project_root, ".ruby-version")
|
30
|
+
rg = File.join(project_root, ".ruby-gemset")
|
31
|
+
to_remove = [rv, rg].select { |p| File.exist?(p) }
|
32
|
+
unless to_remove.empty?
|
33
|
+
if helpers.ask("Remove #{to_remove.map { |p| File.basename(p) }.join(" and ")} (managed by .tool-versions)?", true)
|
34
|
+
to_remove.each { |p| FileUtils.rm_f(p) }
|
35
|
+
puts "Removed #{to_remove.map { |p| File.basename(p) }.join(" and ")}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
puts
|
41
|
+
puts "Next steps:"
|
42
|
+
puts "1) Configure a shared git hooks path (optional, recommended):"
|
43
|
+
puts " git config --global core.hooksPath .git-hooks"
|
44
|
+
puts
|
45
|
+
puts "2) Install binstubs for this gem so the commit-msg tool is available in ./bin:"
|
46
|
+
puts " bundle binstubs kettle-dev --path bin"
|
47
|
+
puts " # After running, you should have bin/kettle-commit-msg (wrapper)."
|
48
|
+
puts
|
49
|
+
# Step 3: direnv and .envrc
|
50
|
+
envrc_path = File.join(project_root, ".envrc")
|
51
|
+
puts "3) Install direnv (if not already):"
|
52
|
+
puts " brew install direnv"
|
53
|
+
if helpers.modified_by_template?(envrc_path)
|
54
|
+
puts " Your .envrc was created/updated by kettle:dev:template."
|
55
|
+
puts " It includes PATH_add bin so that executables in ./bin are on PATH when direnv is active."
|
56
|
+
puts " This allows running tools without the bin/ prefix inside the project directory."
|
57
|
+
else
|
58
|
+
# Ensure PATH_add bin exists in .envrc if the template task did not modify it this run
|
59
|
+
begin
|
60
|
+
current = File.file?(envrc_path) ? File.read(envrc_path) : ""
|
61
|
+
rescue StandardError
|
62
|
+
current = ""
|
63
|
+
end
|
64
|
+
has_path_add = current.lines.any? { |l| l.strip =~ /^PATH_add\s+bin\b/ }
|
65
|
+
if has_path_add
|
66
|
+
puts " Your .envrc already contains PATH_add bin."
|
67
|
+
else
|
68
|
+
puts " Adding PATH_add bin to your project's .envrc is recommended to expose ./bin on PATH."
|
69
|
+
if helpers.ask("Add PATH_add bin to #{envrc_path}?", true)
|
70
|
+
content = current.dup
|
71
|
+
insertion = "# Run any command in this project's bin/ without the bin/ prefix\nPATH_add bin\n"
|
72
|
+
if content.empty?
|
73
|
+
content = insertion
|
74
|
+
else
|
75
|
+
content = insertion + "\n" + content unless content.start_with?(insertion)
|
76
|
+
end
|
77
|
+
File.open(envrc_path, "w") { |f| f.write(content) }
|
78
|
+
puts " Updated #{envrc_path} with PATH_add bin"
|
79
|
+
updated_envrc_by_install = true
|
80
|
+
else
|
81
|
+
puts " Skipping modification of .envrc. You may add 'PATH_add bin' manually at the top."
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Warn about .env.local and offer to add it to .gitignore
|
87
|
+
puts
|
88
|
+
puts "WARNING: Do not commit .env.local; it often contains machine-local secrets."
|
89
|
+
puts "Ensure your .gitignore includes:"
|
90
|
+
puts " # direnv - brew install direnv"
|
91
|
+
puts " .env.local"
|
92
|
+
|
93
|
+
gitignore_path = File.join(project_root, ".gitignore")
|
94
|
+
if helpers.modified_by_template?(gitignore_path)
|
95
|
+
# .gitignore was created or replaced by template; do not modify it here
|
96
|
+
# Assume template provided sensible defaults.
|
97
|
+
else
|
98
|
+
begin
|
99
|
+
gitignore_current = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
|
100
|
+
rescue StandardError
|
101
|
+
gitignore_current = ""
|
102
|
+
end
|
103
|
+
has_env_local = gitignore_current.lines.any? { |l| l.strip == ".env.local" }
|
104
|
+
unless has_env_local
|
105
|
+
puts
|
106
|
+
puts "Would you like to add '.env.local' to #{gitignore_path}?"
|
107
|
+
print "Add to .gitignore now [Y/n]: "
|
108
|
+
answer = $stdin.gets&.strip
|
109
|
+
add_it = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
|
110
|
+
true
|
111
|
+
else
|
112
|
+
answer.nil? || answer.empty? || answer =~ /\Ay(es)?\z/i
|
113
|
+
end
|
114
|
+
if add_it
|
115
|
+
FileUtils.mkdir_p(File.dirname(gitignore_path))
|
116
|
+
mode = File.exist?(gitignore_path) ? "a" : "w"
|
117
|
+
File.open(gitignore_path, mode) do |f|
|
118
|
+
f.write("\n") unless gitignore_current.empty? || gitignore_current.end_with?("\n")
|
119
|
+
unless gitignore_current.lines.any? { |l| l.strip == "# direnv - brew install direnv" }
|
120
|
+
f.write("# direnv - brew install direnv\n")
|
121
|
+
end
|
122
|
+
f.write(".env.local\n")
|
123
|
+
end
|
124
|
+
puts "Added .env.local to #{gitignore_path}"
|
125
|
+
else
|
126
|
+
puts "Skipping modification of .gitignore. Remember to add .env.local to avoid committing it."
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Validate gemspec homepage points to GitHub and is a non-interpolated string
|
132
|
+
begin
|
133
|
+
gemspecs = Dir.glob(File.join(project_root, "*.gemspec"))
|
134
|
+
if gemspecs.empty?
|
135
|
+
puts
|
136
|
+
puts "No .gemspec found in #{project_root}; skipping homepage check."
|
137
|
+
else
|
138
|
+
gemspec_path = gemspecs.first
|
139
|
+
if gemspecs.size > 1
|
140
|
+
puts
|
141
|
+
puts "Multiple gemspecs found; defaulting to #{File.basename(gemspec_path)} for homepage check."
|
142
|
+
end
|
143
|
+
|
144
|
+
content = File.read(gemspec_path)
|
145
|
+
homepage_line = content.lines.find { |l| l =~ /\bspec\.homepage\s*=\s*/ }
|
146
|
+
if homepage_line.nil?
|
147
|
+
puts
|
148
|
+
puts "WARNING: spec.homepage not found in #{File.basename(gemspec_path)}."
|
149
|
+
puts "This gem should declare a GitHub homepage: https://github.com/<org>/<repo>"
|
150
|
+
else
|
151
|
+
# Extract the assigned value as text
|
152
|
+
assigned = homepage_line.split("=", 2).last.to_s.strip
|
153
|
+
# Detect interpolation
|
154
|
+
interpolated = assigned.include?('#{')
|
155
|
+
|
156
|
+
# If quoted literal, strip quotes
|
157
|
+
if assigned.start_with?("\"", "'")
|
158
|
+
begin
|
159
|
+
assigned[1..-2]
|
160
|
+
rescue
|
161
|
+
assigned
|
162
|
+
end
|
163
|
+
else
|
164
|
+
assigned
|
165
|
+
end
|
166
|
+
|
167
|
+
github_repo_from_url = lambda do |url|
|
168
|
+
return unless url
|
169
|
+
url = url.strip
|
170
|
+
# Supported formats:
|
171
|
+
# - https://github.com/org/repo(.git)?
|
172
|
+
# - http(s)://github.com/org/repo
|
173
|
+
# - git@github.com:org/repo(.git)?
|
174
|
+
# - ssh://git@github.com/org/repo(.git)?
|
175
|
+
m = url.match(%r{github\.com[/:]([^/\s:]+)/([^/\s]+?)(?:\.git)?/?\z}i)
|
176
|
+
return unless m
|
177
|
+
org = m[1]
|
178
|
+
repo = m[2]
|
179
|
+
[org, repo]
|
180
|
+
end
|
181
|
+
|
182
|
+
github_homepage_literal = lambda do |val|
|
183
|
+
return false unless val
|
184
|
+
return false if val.include?('#{')
|
185
|
+
v = val.to_s.strip
|
186
|
+
if (v.start_with?("\"") && v.end_with?("\"")) || (v.start_with?("'") && v.end_with?("'"))
|
187
|
+
v = begin
|
188
|
+
v[1..-2]
|
189
|
+
rescue
|
190
|
+
v
|
191
|
+
end
|
192
|
+
end
|
193
|
+
return false unless v =~ %r{\Ahttps?://github\.com/}i
|
194
|
+
!!github_repo_from_url.call(v)
|
195
|
+
end
|
196
|
+
|
197
|
+
valid_literal = github_homepage_literal.call(assigned)
|
198
|
+
|
199
|
+
if interpolated || !valid_literal
|
200
|
+
puts
|
201
|
+
puts "Checking git remote 'origin' to derive GitHub homepage..."
|
202
|
+
origin_url = nil
|
203
|
+
begin
|
204
|
+
origin_cmd = ["git", "-C", project_root.to_s, "remote", "get-url", "origin"]
|
205
|
+
origin_url = IO.popen(origin_cmd, &:read).to_s.strip
|
206
|
+
rescue StandardError
|
207
|
+
origin_url = ""
|
208
|
+
end
|
209
|
+
|
210
|
+
org_repo = github_repo_from_url.call(origin_url)
|
211
|
+
unless org_repo
|
212
|
+
puts "ERROR: git remote 'origin' is not a GitHub URL (or not found): #{origin_url.empty? ? "(none)" : origin_url}"
|
213
|
+
puts "To complete installation: set your GitHub repository as the 'origin' remote, and move any other forge to an alternate name."
|
214
|
+
puts "Example:"
|
215
|
+
puts " git remote rename origin something_else"
|
216
|
+
puts " git remote add origin https://github.com/<org>/<repo>.git"
|
217
|
+
puts "After fixing, re-run: rake kettle:dev:install"
|
218
|
+
abort("Aborting: homepage cannot be corrected without a GitHub origin remote.")
|
219
|
+
end
|
220
|
+
|
221
|
+
org, repo = org_repo
|
222
|
+
suggested = "https://github.com/#{org}/#{repo}"
|
223
|
+
|
224
|
+
puts "Current spec.homepage appears #{interpolated ? "interpolated" : "invalid"}: #{assigned}"
|
225
|
+
puts "Suggested literal homepage: \"#{suggested}\""
|
226
|
+
print("Update #{File.basename(gemspec_path)} to use this homepage? [Y/n]: ")
|
227
|
+
ans = $stdin.gets&.strip
|
228
|
+
do_update = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
|
229
|
+
true
|
230
|
+
else
|
231
|
+
ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
|
232
|
+
end
|
233
|
+
|
234
|
+
if do_update
|
235
|
+
new_line = homepage_line.sub(/=.*/, "= \"#{suggested}\"\n")
|
236
|
+
new_content = content.sub(homepage_line, new_line)
|
237
|
+
File.open(gemspec_path, "w") { |f| f.write(new_content) }
|
238
|
+
puts "Updated spec.homepage in #{File.basename(gemspec_path)} to #{suggested}"
|
239
|
+
else
|
240
|
+
puts "Skipping update of spec.homepage. You should set it to: #{suggested}"
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
rescue StandardError => e
|
246
|
+
puts "WARNING: An error occurred while checking gemspec homepage: #{e.class}: #{e.message}"
|
247
|
+
end
|
248
|
+
|
249
|
+
# If .envrc was modified during install step, require `direnv allow` and exit unless allowed
|
250
|
+
if defined?(updated_envrc_by_install) && updated_envrc_by_install
|
251
|
+
if ENV.fetch("allowed", "").to_s =~ /\A(1|true|y|yes)\z/i
|
252
|
+
puts "Proceeding after .envrc update because allowed=true."
|
253
|
+
else
|
254
|
+
puts
|
255
|
+
puts "IMPORTANT: .envrc was updated during kettle:dev:install."
|
256
|
+
puts "Please review it and then run:"
|
257
|
+
puts " direnv allow"
|
258
|
+
puts
|
259
|
+
puts "After that, re-run to resume:"
|
260
|
+
puts " bundle exec rake kettle:dev:install allowed=true"
|
261
|
+
abort("Aborting: direnv allow required after .envrc changes.")
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
# Summary of templating changes
|
266
|
+
begin
|
267
|
+
results = helpers.template_results
|
268
|
+
meaningful = results.select { |_, rec| [:create, :replace, :dir_create, :dir_replace].include?(rec[:action]) }
|
269
|
+
puts
|
270
|
+
puts "Summary of templating changes:"
|
271
|
+
if meaningful.empty?
|
272
|
+
puts " (no files were created or replaced by kettle:dev:template)"
|
273
|
+
else
|
274
|
+
# Order: create, replace, dir_create, dir_replace
|
275
|
+
action_labels = {
|
276
|
+
create: "Created",
|
277
|
+
replace: "Replaced",
|
278
|
+
dir_create: "Directory created",
|
279
|
+
dir_replace: "Directory replaced",
|
280
|
+
}
|
281
|
+
[:create, :replace, :dir_create, :dir_replace].each do |sym|
|
282
|
+
items = meaningful.select { |_, rec| rec[:action] == sym }.map { |path, _| path }
|
283
|
+
next if items.empty?
|
284
|
+
puts " #{action_labels[sym]}:"
|
285
|
+
items.sort.each do |abs|
|
286
|
+
rel = begin
|
287
|
+
abs.start_with?(project_root.to_s) ? abs.sub(/^#{Regexp.escape(project_root.to_s)}\/?/, "") : abs
|
288
|
+
rescue
|
289
|
+
abs
|
290
|
+
end
|
291
|
+
puts " - #{rel}"
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
rescue StandardError => e
|
296
|
+
puts
|
297
|
+
puts "Summary of templating changes: (unavailable: #{e.class}: #{e.message})"
|
298
|
+
end
|
299
|
+
|
300
|
+
puts
|
301
|
+
puts "kettle:dev:install complete."
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# Setup Reek
|
2
|
+
begin
|
3
|
+
require "reek/rake/task"
|
4
|
+
|
5
|
+
Reek::Rake::Task.new do |t|
|
6
|
+
t.fail_on_error = true
|
7
|
+
t.verbose = false
|
8
|
+
t.source_files = "{lib,spec,tests}/**/*.rb"
|
9
|
+
end
|
10
|
+
|
11
|
+
# Store current Reek output into REEK file
|
12
|
+
require "open3"
|
13
|
+
desc("Run reek and store the output into the REEK file")
|
14
|
+
task("reek:update") do
|
15
|
+
# Run via Bundler if available to ensure the right gem version is used
|
16
|
+
cmd = [Gem.bindir ? File.join(Gem.bindir, "bundle") : "bundle", "exec", "reek"]
|
17
|
+
|
18
|
+
output, status = Open3.capture2e(*cmd)
|
19
|
+
|
20
|
+
File.write("REEK", output)
|
21
|
+
|
22
|
+
# Mirror the failure semantics of the standard reek task
|
23
|
+
unless status.success?
|
24
|
+
abort("reek:update failed (reek reported smells). Output written to REEK")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
Kettle::Dev.register_default("reek:update") unless Kettle::Dev::IS_CI
|
28
|
+
rescue LoadError
|
29
|
+
warn("[kettle-dev][reek.rake] failed to load reek/rake/task") if Kettle::Dev::DEBUGGING
|
30
|
+
desc("(stub) reek is unavailable")
|
31
|
+
task(:reek) do
|
32
|
+
warn("NOTE: reek isn't installed, or is disabled for #{RUBY_VERSION} in the current environment")
|
33
|
+
end
|
34
|
+
end
|