kettle-dev 1.0.10 → 1.0.12

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 (44) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.envrc +1 -1
  4. data/.github/workflows/coverage.yml +2 -2
  5. data/.github/workflows/coverage.yml.example +127 -0
  6. data/.github/workflows/discord-notifier.yml +2 -1
  7. data/.github/workflows/truffle.yml +0 -8
  8. data/.junie/guidelines.md +1 -0
  9. data/Appraisals +4 -1
  10. data/Appraisals.example +104 -0
  11. data/CHANGELOG.md +88 -29
  12. data/CHANGELOG.md.example +4 -4
  13. data/CONTRIBUTING.md +37 -1
  14. data/Gemfile +3 -0
  15. data/Gemfile.example +35 -0
  16. data/README.md +48 -10
  17. data/README.md.example +515 -0
  18. data/{Rakefile → Rakefile.example} +13 -27
  19. data/exe/kettle-changelog +404 -0
  20. data/exe/kettle-commit-msg +2 -0
  21. data/exe/kettle-readme-backers +2 -0
  22. data/exe/kettle-release +10 -9
  23. data/gemfiles/modular/optional.gemfile +1 -0
  24. data/lib/kettle/dev/ci_helpers.rb +19 -0
  25. data/lib/kettle/dev/ci_monitor.rb +192 -0
  26. data/lib/kettle/dev/git_adapter.rb +98 -33
  27. data/lib/kettle/dev/git_commit_footer.rb +1 -1
  28. data/lib/kettle/dev/input_adapter.rb +44 -0
  29. data/lib/kettle/dev/release_cli.rb +154 -177
  30. data/lib/kettle/dev/tasks/ci_task.rb +22 -1
  31. data/lib/kettle/dev/tasks/install_task.rb +313 -95
  32. data/lib/kettle/dev/tasks/template_task.rb +176 -74
  33. data/lib/kettle/dev/template_helpers.rb +61 -8
  34. data/lib/kettle/dev/version.rb +1 -1
  35. data/lib/kettle/dev/versioning.rb +68 -0
  36. data/sig/kettle/dev/ci_helpers.rbs +1 -1
  37. data/sig/kettle/dev/ci_monitor.rbs +8 -0
  38. data/sig/kettle/dev/input_adapter.rbs +8 -0
  39. data/sig/kettle/dev/release_cli.rbs +1 -1
  40. data/sig/kettle/dev/template_helpers.rbs +3 -1
  41. data.tar.gz.sig +0 -0
  42. metadata +24 -22
  43. metadata.gz.sig +0 -0
  44. data/.gitlab-ci.yml +0 -45
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # vim: set syntax=ruby
5
+
6
+ # kettle-changelog: Generate a CHANGELOG.md entry for the current VERSION.
7
+ # - Reads VERSION from lib/**/version.rb (must be unique across files)
8
+ # - Moves entries from the "Unreleased" section into a new versioned section
9
+ # - Prepends 4 heading lines:
10
+ # - TAG
11
+ # - COVERAGE (line coverage)
12
+ # - BRANCH COVERAGE (branch coverage)
13
+ # - percent documented (parsed from `bin/yard` output)
14
+ # - Updates bottom link references to GitHub style, converts any existing
15
+ # GitLab links to GitHub links, and appends the new [X.Y.Z] and [X.Y.Zt] links.
16
+ #
17
+ # Notes:
18
+ # - Expects a JSON coverage report at coverage/coverage.json. If missing,
19
+ # it will instruct you to run: K_SOUP_COV_FORMATTERS="json" bin/rspec
20
+ # - Expects bin/yard to be available via Bundler.
21
+
22
+ $stdout.sync = true
23
+
24
+ # Depending library or project must be using bundler
25
+ require "bundler/setup"
26
+
27
+ require "json"
28
+ require "time"
29
+ require "open3"
30
+ require "shellwords"
31
+
32
+ begin
33
+ require "kettle/dev"
34
+ rescue LoadError => e
35
+ warn("kettle/dev: failed to load: #{e.message}")
36
+ warn("Hint: Ensure the host project includes kettle-dev and run bundle install.")
37
+ exit(1)
38
+ end
39
+
40
+ require "kettle/dev/versioning"
41
+
42
+ puts "== kettle-changelog v#{Kettle::Dev::Version::VERSION} =="
43
+
44
+ module Kettle
45
+ module Dev
46
+ class ChangelogCLI
47
+ def initialize
48
+ @root = Kettle::Dev::CIHelpers.project_root
49
+ @changelog_path = File.join(@root, "CHANGELOG.md")
50
+ @coverage_path = File.join(@root, "coverage", "coverage.json")
51
+ end
52
+
53
+ def run
54
+ version = Kettle::Dev::Versioning.detect_version(@root)
55
+ today = Time.now.strftime("%Y-%m-%d")
56
+ owner, repo = Kettle::Dev::CIHelpers.repo_info
57
+ unless owner && repo
58
+ warn("Could not determine GitHub owner/repo from origin remote.")
59
+ warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
60
+ end
61
+
62
+ line_cov_line, branch_cov_line = coverage_lines
63
+ yard_line = yard_percent_documented
64
+
65
+ changelog = File.read(@changelog_path)
66
+
67
+ # If the detected version already exists in the changelog, abort to avoid duplicates
68
+ if changelog =~ /^## \[#{Regexp.escape(version)}\]/
69
+ abort("CHANGELOG.md already has a section for version #{version}. Bump version.rb or remove the duplicate.")
70
+ end
71
+
72
+ unreleased_block, before, after = extract_unreleased(changelog)
73
+ if unreleased_block.nil?
74
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md")
75
+ end
76
+
77
+ if unreleased_block.strip.empty?
78
+ warn("No entries found under Unreleased. Creating an empty version section anyway.")
79
+ end
80
+
81
+ prev_version = detect_previous_version(after)
82
+
83
+ new_section = +""
84
+ new_section << "## [#{version}] - #{today}\n"
85
+ new_section << "- TAG: [v#{version}][#{version}t]\n"
86
+ new_section << "- #{line_cov_line}\n" if line_cov_line
87
+ new_section << "- #{branch_cov_line}\n" if branch_cov_line
88
+ new_section << "- #{yard_line}\n" if yard_line
89
+ new_section << filter_unreleased_sections(unreleased_block)
90
+ # Ensure exactly one blank line separates this new section from the next section
91
+ new_section.rstrip!
92
+ new_section << "\n\n"
93
+
94
+ # Reset the Unreleased section to empty category headings
95
+ unreleased_reset = <<~MD
96
+ ## [Unreleased]
97
+ ### Added
98
+ ### Changed
99
+ ### Deprecated
100
+ ### Removed
101
+ ### Fixed
102
+ ### Security
103
+ MD
104
+
105
+ updated = before + unreleased_reset + "\n" + new_section + after
106
+
107
+ updated = update_link_refs(updated, owner, repo, prev_version, version)
108
+
109
+ # Ensure exactly one trailing newline at EOF
110
+ updated = updated.rstrip + "\n"
111
+
112
+ File.write(@changelog_path, updated)
113
+ puts "CHANGELOG.md updated with v#{version} section."
114
+ end
115
+
116
+ private
117
+
118
+ def abort(msg)
119
+ Kettle::Dev::ExitAdapter.abort(msg)
120
+ rescue NameError
121
+ Kernel.abort(msg)
122
+ end
123
+
124
+ def detect_version
125
+ candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
126
+ abort("Could not find version.rb under lib/**.") if candidates.empty?
127
+ versions = candidates.map do |path|
128
+ content = File.read(path)
129
+ m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
130
+ next unless m
131
+ m[2]
132
+ end.compact
133
+ abort("VERSION constant not found in #{@root}/lib/**/version.rb") if versions.none?
134
+ abort("Multiple VERSION constants found to be out of sync (#{versions.inspect}) in #{@root}/lib/**/version.rb") unless versions.uniq.length == 1
135
+ versions.first
136
+ end
137
+
138
+ def extract_unreleased(content)
139
+ lines = content.lines
140
+ start_i = lines.index { |l| l.start_with?("## [Unreleased]") }
141
+ return [nil, nil, nil] unless start_i
142
+ # Find the next version heading after Unreleased
143
+ next_i = (start_i + 1)
144
+ while next_i < lines.length && !lines[next_i].start_with?("## [")
145
+ next_i += 1
146
+ end
147
+ # Now next_i points to the next section heading or EOF
148
+ before = lines[0..(start_i - 1)].join
149
+ unreleased_block = lines[(start_i + 1)..(next_i - 1)].join
150
+ after = lines[next_i..-1]&.join || ""
151
+ [unreleased_block, before, after]
152
+ end
153
+
154
+ def detect_previous_version(after_text)
155
+ # after_text begins with the first released section following Unreleased
156
+ m = after_text.match(/^## \[(\d+\.\d+\.\d+)\]/)
157
+ return m[1] if m
158
+ nil
159
+ end
160
+
161
+ # From the Unreleased block, keep only sections that have content.
162
+ # We detect sections as lines starting with '### '. A section has content if there is at least
163
+ # one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
164
+ # Returns a string that includes only the non-empty sections with their content.
165
+ def filter_unreleased_sections(unreleased_block)
166
+ lines = unreleased_block.lines
167
+ out = []
168
+ i = 0
169
+ while i < lines.length
170
+ line = lines[i]
171
+ if line.start_with?("### ")
172
+ header = line
173
+ i += 1
174
+ chunk = []
175
+ while i < lines.length && !lines[i].start_with?("### ") && !lines[i].start_with?("## ")
176
+ chunk << lines[i]
177
+ i += 1
178
+ end
179
+ # Determine if chunk has any content (non-blank)
180
+ content_present = chunk.any? { |l| l.strip != "" }
181
+ if content_present
182
+ # Trim trailing blank lines
183
+ while chunk.any? && chunk.last.strip == ""
184
+ chunk.pop
185
+ end
186
+ out << header
187
+ out.concat(chunk)
188
+ out << "\n" unless out.last&.end_with?("\n")
189
+ end
190
+ next
191
+ else
192
+ # Lines outside sections are ignored for released sections
193
+ i += 1
194
+ end
195
+ end
196
+ out.join
197
+ end
198
+
199
+ def coverage_lines
200
+ unless File.file?(@coverage_path)
201
+ warn("Coverage JSON not found at #{@coverage_path}.")
202
+ warn("Run: K_SOUP_COV_FORMATTERS=\"json\" bin/rspec")
203
+ return [nil, nil]
204
+ end
205
+ data = JSON.parse(File.read(@coverage_path))
206
+ files = data["coverage"] || {}
207
+ file_count = 0
208
+ total_lines = 0
209
+ covered_lines = 0
210
+ total_branches = 0
211
+ covered_branches = 0
212
+ files.each_value do |h|
213
+ lines = h["lines"] || []
214
+ line_relevant = lines.count { |x| x.is_a?(Integer) }
215
+ line_covered = lines.count { |x| x.is_a?(Integer) && x > 0 }
216
+ if line_relevant > 0
217
+ file_count += 1
218
+ total_lines += line_relevant
219
+ covered_lines += line_covered
220
+ end
221
+ branches = h["branches"] || []
222
+ branches.each do |b|
223
+ next unless b.is_a?(Hash)
224
+ cov = b["coverage"]
225
+ next unless cov.is_a?(Numeric)
226
+ total_branches += 1
227
+ covered_branches += 1 if cov > 0
228
+ end
229
+ end
230
+ line_pct = (total_lines > 0) ? ((covered_lines.to_f / total_lines) * 100.0) : 0.0
231
+ branch_pct = (total_branches > 0) ? ((covered_branches.to_f / total_branches) * 100.0) : 0.0
232
+ line_str = format("COVERAGE: %.2f%% -- %d/%d lines in %d files", line_pct, covered_lines, total_lines, file_count)
233
+ branch_str = format("BRANCH COVERAGE: %.2f%% -- %d/%d branches in %d files", branch_pct, covered_branches, total_branches, file_count)
234
+ [line_str, branch_str]
235
+ rescue StandardError => e
236
+ warn("Failed to parse coverage: #{e.class}: #{e.message}")
237
+ [nil, nil]
238
+ end
239
+
240
+ def yard_percent_documented
241
+ cmd = File.join(@root, "bin", "yard")
242
+ unless File.executable?(cmd)
243
+ warn("bin/yard not found or not executable; ensure yard is installed via bundler")
244
+ return
245
+ end
246
+ out, _ = Open3.capture2(cmd)
247
+ # Look for a line containing e.g., "95.35% documented"
248
+ line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
249
+ if line
250
+ line = line.strip
251
+ # Return exactly as requested: e.g. "95.35% documented"
252
+ line
253
+ else
254
+ warn("Could not find documented percentage in bin/yard output.")
255
+ nil
256
+ end
257
+ rescue StandardError => e
258
+ warn("Failed to run bin/yard: #{e.class}: #{e.message}")
259
+ nil
260
+ end
261
+
262
+ def update_link_refs(content, owner, repo, prev_version, new_version)
263
+ # Convert any GitLab links to GitHub
264
+ content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/compare/([^\.]+)\.\.\.([^\s]+)}) do
265
+ o = owner || Regexp.last_match(1)
266
+ r = repo || Regexp.last_match(2)
267
+ from = Regexp.last_match(3)
268
+ to = Regexp.last_match(4)
269
+ "https://github.com/#{o}/#{r}/compare/#{from}...#{to}"
270
+ end
271
+ content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/tags/(v[^\s\]]+)}) do
272
+ o = owner || Regexp.last_match(1)
273
+ r = repo || Regexp.last_match(2)
274
+ tag = Regexp.last_match(3)
275
+ "https://github.com/#{o}/#{r}/releases/tag/#{tag}"
276
+ end
277
+
278
+ # Append or update the bottom reference links
279
+ lines = content.lines
280
+
281
+ # Find the index of the Unreleased heading; only manipulate refs after this point
282
+ unreleased_idx = lines.index { |l| l.start_with?("## [Unreleased]") } || -1
283
+
284
+ # Find the first link-ref line (e.g., "[Unreleased]: http...") AFTER Unreleased
285
+ first_ref = nil
286
+ lines.each_with_index do |l, i|
287
+ if l =~ /^\[[^\]]+\]:\s+http/ && i > unreleased_idx
288
+ first_ref = i
289
+ break
290
+ end
291
+ end
292
+ unless first_ref
293
+ # Append at end if no ref block after Unreleased
294
+ first_ref = lines.length
295
+ lines << "\n"
296
+ end
297
+
298
+ # Ensure Unreleased points to GitHub compare from new tag to HEAD
299
+ if owner && repo
300
+ unreleased_ref = "[Unreleased]: https://github.com/#{owner}/#{repo}/compare/v#{new_version}...HEAD\n"
301
+ # Update an existing Unreleased ref only if it appears after Unreleased heading; otherwise append
302
+ idx = nil
303
+ lines.each_with_index do |l, i|
304
+ if l.start_with?("[Unreleased]:") && i >= first_ref
305
+ idx = i
306
+ break
307
+ end
308
+ end
309
+ if idx
310
+ lines[idx] = unreleased_ref
311
+ else
312
+ lines << unreleased_ref
313
+ end
314
+ end
315
+
316
+ if owner && repo
317
+ # Add compare link for the new version
318
+ from = prev_version ? "v#{prev_version}" : detect_initial_compare_base(lines)
319
+ new_compare = "[#{new_version}]: https://github.com/#{owner}/#{repo}/compare/#{from}...v#{new_version}\n"
320
+ unless lines.any? { |l| l.start_with?("[#{new_version}]:") }
321
+ lines << new_compare
322
+ end
323
+ # Add tag link for the new version
324
+ new_tag = "[#{new_version}t]: https://github.com/#{owner}/#{repo}/releases/tag/v#{new_version}\n"
325
+ unless lines.any? { |l| l.start_with?("[#{new_version}t]:") }
326
+ lines << new_tag
327
+ end
328
+ end
329
+
330
+ # Rebuild and sort the reference block so newest is at the bottom, preserving everything above first_ref
331
+ ref_lines = lines[first_ref..-1].select { |l| l =~ /^\[[^\]]+\]:\s+http/ }
332
+ # Deduplicate by key (text inside the square brackets)
333
+ by_key = {}
334
+ ref_lines.each do |l|
335
+ if l =~ /^\[([^\]]+)\]:\s+/
336
+ by_key[$1] = l
337
+ end
338
+ end
339
+ unreleased_line = by_key.delete("Unreleased")
340
+ # Separate version compare and tag links
341
+ compares = {}
342
+ tags = {}
343
+ by_key.each do |k, v|
344
+ if k =~ /^(\d+\.\d+\.\d+)$/
345
+ compares[$1] = v
346
+ elsif k =~ /^(\d+\.\d+\.\d+)t$/
347
+ tags[$1] = v
348
+ end
349
+ end
350
+ # Sort versions ascending so newest at bottom
351
+ sorted_versions = compares.keys.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
352
+ # In case some versions only have tags or only compares, include them as well
353
+ (tags.keys - compares.keys).each { |s| sorted_versions |= [s] }
354
+ sorted_versions = sorted_versions.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
355
+
356
+ new_ref_block = []
357
+ new_ref_block << unreleased_line if unreleased_line
358
+ sorted_versions.each do |v|
359
+ new_ref_block << compares[v] if compares[v]
360
+ new_ref_block << tags[v] if tags[v]
361
+ end
362
+ # Replace the old block
363
+ rebuilt = lines[0...first_ref] + new_ref_block + ["\n"]
364
+ rebuilt.join
365
+ end
366
+
367
+ def detect_initial_compare_base(lines)
368
+ # Fallback when prev_version is unknown: try to find the first compare base used historically
369
+ # e.g., for 1.0.0 it may be a commit SHA instead of a tag
370
+ ref = lines.find { |l| l =~ /^\[1\.0\.0\]:\s+https:\/\/github\.com\// }
371
+ if ref && (m = ref.match(%r{compare/([^\.]+)\.\.\.v\d+})).is_a?(MatchData)
372
+ m[1]
373
+ else
374
+ # Default to previous tag name if none found (unlikely to be correct, but better than empty)
375
+ "HEAD^"
376
+ end
377
+ end
378
+ end
379
+ end
380
+ end
381
+
382
+ begin
383
+ if ARGV.include?("-h") || ARGV.include?("--help")
384
+ puts <<~USAGE
385
+ Usage: kettle-changelog
386
+
387
+ Generates a new CHANGELOG.md entry for the current version detected from lib/**/version.rb.
388
+ Moves entries from [Unreleased] into the new section, adds coverage and documentation stats,
389
+ and updates bottom link references to GitHub style, adding new compare/tag links.
390
+
391
+ Prerequisites:
392
+ - coverage/coverage.json present (run: K_SOUP_COV_FORMATTERS="json" bin/rspec)
393
+ - yard installed and available via bin/yard
394
+ USAGE
395
+ exit(0)
396
+ end
397
+ Kettle::Dev::ChangelogCLI.new.run
398
+ rescue SystemExit
399
+ raise
400
+ rescue StandardError => e
401
+ warn("kettle-changelog: unexpected error: #{e.class}: #{e.message}")
402
+ warn(e.backtrace.join("\n")) if ENV["DEBUG"]
403
+ exit(1)
404
+ end
@@ -13,6 +13,8 @@ require "erb"
13
13
 
14
14
  require "kettle/dev"
15
15
 
16
+ puts "== kettle-commit-msg v#{Kettle::Dev::Version::VERSION} =="
17
+
16
18
  # ENV variable control (set in .envrc, or .env.local)
17
19
  # BRANCH_RULE_TYPE = jira, or another type of branch rule validation, or false to disable
18
20
  # FOOTER_APPEND = true/false append commit message footer
@@ -10,4 +10,6 @@ require "bundler/setup"
10
10
 
11
11
  require "kettle/dev"
12
12
 
13
+ puts "== kettle-readme-backers v#{Kettle::Dev::Version::VERSION} =="
14
+
13
15
  Kettle::Dev::ReadmeBackers.new.run!
data/exe/kettle-release CHANGED
@@ -19,21 +19,18 @@ $stdout.sync = true
19
19
  # Depending library or project must be using bundler
20
20
  require "bundler/setup"
21
21
 
22
- begin
23
- require "kettle/dev"
24
- rescue LoadError => e
25
- warn("kettle/dev: failed to load: #{e.message}")
26
- warn("Hint: Ensure the host project includes kettle-dev and run bundle install.")
27
- exit(1)
28
- end
22
+ require "kettle/dev"
29
23
 
30
24
  # Always execute when this file is loaded (e.g., via a Bundler binstub).
31
25
  # Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
32
26
  if ARGV.include?("-h") || ARGV.include?("--help")
33
27
  puts <<~USAGE
34
- Usage: kettle-release
28
+ Usage: kettle-release [start_step=<number>]
35
29
 
36
30
  Automates the release flow for a Ruby gem in the host project:
31
+
32
+ Options:
33
+ start_step=<number> # skip directly to the numbered step (e.g., 10 for CI validation)
37
34
  - Runs bin/setup and bin/rake sanity checks
38
35
  - Prompts to confirm version and changelog updates
39
36
  - Commits a release prep change
@@ -51,8 +48,12 @@ if ARGV.include?("-h") || ARGV.include?("--help")
51
48
  exit 0
52
49
  end
53
50
 
51
+ puts "== kettle-release v#{Kettle::Dev::Version::VERSION} =="
52
+ # Parse start_step=<n> from ARGV
53
+ start_step_arg = ARGV.find { |a| a.start_with?("start_step=") }
54
+ start_step = start_step_arg ? start_step_arg.split("=", 2)[1].to_i : 1
54
55
  begin
55
- Kettle::Dev::ReleaseCLI.new.run
56
+ Kettle::Dev::ReleaseCLI.new(start_step: start_step).run
56
57
  rescue LoadError => e
57
58
  warn("kettle-release: could not load dependency: #{e.message}")
58
59
  warn(e.backtrace.join("\n")) if ENV["DEBUG"]
@@ -0,0 +1 @@
1
+ # Optional dependencies are not dependended on directly, but may be used if present.
@@ -177,10 +177,29 @@ module Kettle
177
177
  data = JSON.parse(res.body)
178
178
  pipe = data&.first
179
179
  return unless pipe
180
+ # Attempt to enrich with failure_reason by querying the single pipeline endpoint
181
+ begin
182
+ if pipe["id"]
183
+ detail_uri = URI("https://#{host}/api/v4/projects/#{project}/pipelines/#{pipe["id"]}")
184
+ dreq = Net::HTTP::Get.new(detail_uri)
185
+ dreq["User-Agent"] = "kettle-dev/ci-helpers"
186
+ dreq["PRIVATE-TOKEN"] = token if token && !token.empty?
187
+ dres = Net::HTTP.start(detail_uri.hostname, detail_uri.port, use_ssl: true) { |http| http.request(dreq) }
188
+ if dres.is_a?(Net::HTTPSuccess)
189
+ det = JSON.parse(dres.body)
190
+ pipe["failure_reason"] = det["failure_reason"] if det.is_a?(Hash)
191
+ pipe["status"] = det["status"] if det["status"]
192
+ pipe["web_url"] = det["web_url"] if det["web_url"]
193
+ end
194
+ end
195
+ rescue StandardError
196
+ # ignore enrichment errors; fall back to basic fields
197
+ end
180
198
  {
181
199
  "status" => pipe["status"],
182
200
  "web_url" => pipe["web_url"],
183
201
  "id" => pipe["id"],
202
+ "failure_reason" => pipe["failure_reason"],
184
203
  }
185
204
  rescue StandardError
186
205
  nil
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ # External stdlib
4
+ require "uri"
5
+ require "json"
6
+ require "net/http"
7
+
8
+ # Internal
9
+ require "kettle/dev/ci_helpers"
10
+ require "kettle/dev/exit_adapter"
11
+ require "kettle/dev/git_adapter"
12
+
13
+ module Kettle
14
+ module Dev
15
+ # CIMonitor centralizes CI monitoring logic (GitHub Actions and GitLab pipelines)
16
+ # so it can be reused by both kettle-release and Rake tasks (e.g., ci:act).
17
+ #
18
+ # Public API is intentionally small and based on environment/project introspection
19
+ # via CIHelpers, matching the behavior historically implemented in ReleaseCLI.
20
+ module CIMonitor
21
+ module_function
22
+
23
+ # Abort helper (delegates through ExitAdapter so specs can trap exits)
24
+ def abort(msg)
25
+ Kettle::Dev::ExitAdapter.abort(msg)
26
+ end
27
+ module_function :abort
28
+
29
+ # Monitor both GitHub and GitLab CI for the current project/branch.
30
+ # This mirrors ReleaseCLI behavior.
31
+ #
32
+ # @param restart_hint [String] guidance command shown on failure
33
+ # @return [void]
34
+ def monitor_all!(restart_hint: "bundle exec kettle-release start_step=10")
35
+ checks_any = false
36
+ checks_any |= monitor_github_internal!(restart_hint: restart_hint)
37
+ checks_any |= monitor_gitlab_internal!(restart_hint: restart_hint)
38
+ abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
39
+ end
40
+
41
+ # Monitor only the GitLab pipeline for current project/branch.
42
+ # Used by ci:act after running 'act'.
43
+ #
44
+ # @param restart_hint [String] guidance command shown on failure
45
+ # @return [Boolean] true if check performed (gitlab configured), false otherwise
46
+ def monitor_gitlab!(restart_hint: "bundle exec rake ci:act")
47
+ monitor_gitlab_internal!(restart_hint: restart_hint)
48
+ end
49
+
50
+ # -- internals --
51
+
52
+ def monitor_github_internal!(restart_hint:)
53
+ root = Kettle::Dev::CIHelpers.project_root
54
+ workflows = Kettle::Dev::CIHelpers.workflows_list(root)
55
+ gh_remote = preferred_github_remote
56
+ return false unless gh_remote && !workflows.empty?
57
+
58
+ branch = Kettle::Dev::CIHelpers.current_branch
59
+ abort("Could not determine current branch for CI checks.") unless branch
60
+
61
+ url = remote_url(gh_remote)
62
+ owner, repo = parse_github_owner_repo(url)
63
+ return false unless owner && repo
64
+
65
+ total = workflows.size
66
+ abort("No GitHub workflows found under .github/workflows; aborting.") if total.zero?
67
+
68
+ passed = {}
69
+ puts "Ensuring GitHub Actions workflows pass on #{branch} (#{owner}/#{repo}) via remote '#{gh_remote}'"
70
+ pbar = if defined?(ProgressBar)
71
+ ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
72
+ end
73
+ idx = 0
74
+ loop do
75
+ wf = workflows[idx]
76
+ run = Kettle::Dev::CIHelpers.latest_run(owner: owner, repo: repo, workflow_file: wf, branch: branch)
77
+ if run
78
+ if Kettle::Dev::CIHelpers.success?(run)
79
+ unless passed[wf]
80
+ passed[wf] = true
81
+ pbar&.increment
82
+ end
83
+ elsif Kettle::Dev::CIHelpers.failed?(run)
84
+ puts
85
+ wf_url = run["html_url"] || "https://github.com/#{owner}/#{repo}/actions/workflows/#{wf}"
86
+ abort("Workflow failed: #{wf} -> #{wf_url} Fix the workflow, then restart this tool from CI validation with: #{restart_hint}")
87
+ end
88
+ end
89
+ break if passed.size == total
90
+ idx = (idx + 1) % total
91
+ sleep(1)
92
+ end
93
+ pbar&.finish unless pbar&.finished?
94
+ puts "\nAll GitHub workflows passing (#{passed.size}/#{total})."
95
+ true
96
+ end
97
+ module_function :monitor_github_internal!
98
+
99
+ def monitor_gitlab_internal!(restart_hint:)
100
+ root = Kettle::Dev::CIHelpers.project_root
101
+ gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
102
+ gl_remote = gitlab_remote_candidates.first
103
+ return false unless gitlab_ci && gl_remote
104
+
105
+ branch = Kettle::Dev::CIHelpers.current_branch
106
+ abort("Could not determine current branch for CI checks.") unless branch
107
+
108
+ owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
109
+ return false unless owner && repo
110
+
111
+ puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
112
+ pbar = if defined?(ProgressBar)
113
+ ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
114
+ end
115
+ loop do
116
+ pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
117
+ if pipe
118
+ if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
119
+ pbar&.increment unless pbar&.finished?
120
+ break
121
+ elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
122
+ # Special-case: if failure is due to exhausted minutes/insufficient quota, treat as unknown and continue
123
+ reason = (pipe["failure_reason"] || "").to_s
124
+ if reason =~ /insufficient|quota|minute/i
125
+ puts "\nGitLab reports pipeline cannot run due to quota/minutes exhaustion. Result is unknown; continuing."
126
+ pbar&.finish unless pbar&.finished?
127
+ break
128
+ else
129
+ puts
130
+ url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
131
+ abort("Pipeline failed: #{url} Fix the pipeline, then restart this tool from CI validation with: #{restart_hint}")
132
+ end
133
+ elsif pipe["status"] == "blocked"
134
+ # Blocked pipeline (e.g., awaiting approvals) — treat as unknown and continue
135
+ puts "\nGitLab pipeline is blocked. Result is unknown; continuing."
136
+ pbar&.finish unless pbar&.finished?
137
+ break
138
+ end
139
+ end
140
+ sleep(1)
141
+ end
142
+ pbar&.finish unless pbar&.finished?
143
+ puts "\nGitLab pipeline passing."
144
+ true
145
+ end
146
+ module_function :monitor_gitlab_internal!
147
+
148
+ # -- tiny wrappers around GitAdapter-like helpers used by ReleaseCLI --
149
+ def remotes_with_urls
150
+ Kettle::Dev::GitAdapter.new.remotes_with_urls
151
+ end
152
+ module_function :remotes_with_urls
153
+
154
+ def remote_url(name)
155
+ Kettle::Dev::GitAdapter.new.remote_url(name)
156
+ end
157
+ module_function :remote_url
158
+
159
+ def github_remote_candidates
160
+ remotes_with_urls.select { |n, u| u.include?("github.com") }.keys
161
+ end
162
+ module_function :github_remote_candidates
163
+
164
+ def gitlab_remote_candidates
165
+ remotes_with_urls.select { |n, u| u.include?("gitlab.com") }.keys
166
+ end
167
+ module_function :gitlab_remote_candidates
168
+
169
+ def preferred_github_remote
170
+ cands = github_remote_candidates
171
+ return if cands.empty?
172
+ explicit = cands.find { |n| n == "github" } || cands.find { |n| n == "gh" }
173
+ return explicit if explicit
174
+ return "origin" if cands.include?("origin")
175
+ cands.first
176
+ end
177
+ module_function :preferred_github_remote
178
+
179
+ def parse_github_owner_repo(url)
180
+ return [nil, nil] unless url
181
+ if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
182
+ [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
183
+ elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
184
+ [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
185
+ else
186
+ [nil, nil]
187
+ end
188
+ end
189
+ module_function :parse_github_owner_repo
190
+ end
191
+ end
192
+ end