Module: Kettle::Jem::MaintenanceChangelog

Defined in:
lib/kettle/jem/maintenance_changelog.rb

Constant Summary collapse

TEMPLATE_CHANGELOG_INTERNAL_PATHS =
%w[
  CHANGELOG.md
  .structuredmerge/kettle-jem.lock
  .kettle-jem.lock
].freeze
TEMPLATE_CHANGE_CATEGORIES =
{
  workflows: ->(path) { path.start_with?(".github/workflows/") },
  dependencies: ->(path) {
    path == "Gemfile" || path == "Appraisals" || path.end_with?(".gemspec", ".gemfile", ".gemfile.lock") ||
      path.start_with?("gemfiles/")
  },
  documentation: ->(path) { %w[README.md CONTRIBUTING.md SECURITY.md].include?(path) || path.start_with?("docs/") },
  configuration: ->(path) { path.start_with?(".structuredmerge/") || path == "mise.toml" || path == ".gitignore" },
  code_and_tests: ->(path) { path.start_with?("lib/", "spec/", "test/", "bin/") || path == "Rakefile" }
}.freeze
TEMPLATE_CHANGELOG_KEYS =
{
  "Apply kettle-jem templates" => "kettle-jem/template",
  "Prepare project for kettle-jem templates" => "kettle-jem/prepare"
}.freeze
BUNDLER_ENVIRONMENT =
%w[
  BUNDLE_BIN_PATH
  BUNDLE_FROZEN
  BUNDLE_GEMFILE
  BUNDLE_LOCKFILE
  BUNDLER_SETUP
  BUNDLER_VERSION
  RUBYLIB
  RUBYOPT
].freeze
CHANGELOG_PATH_MUTEX =
Mutex.new

Class Method Summary collapse

Class Method Details

.add_unreleased_entry(project_root:, section:, entry:) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/kettle/jem/maintenance_changelog.rb', line 44

def add_unreleased_entry(project_root:, section:, entry:)
  command = [
    RbConfig.ruby,
    "-rrubygems",
    "-e",
    'exec Gem.ruby, Gem.bin_path("kettle-changelog", "kettle-changelog"), *ARGV',
    "--",
    "--add-unreleased-entry",
    "--json",
    "--section",
    section.to_s,
    "--entry",
    entry.to_s
  ]
  environment = BUNDLER_ENVIRONMENT.to_h { |key| [key, nil] }
  stdout, stderr, status = Open3.capture3(environment, *command, chdir: project_root)
  unless status.success?
    detail = [stdout, stderr].reject(&:empty?).join("\n")
    raise "kettle-changelog failed (exit #{status.exitstatus}): #{detail}"
  end

  result = JSON.parse(stdout)
  {
    status: result.fetch("changed") ? "updated" : "unchanged",
    section: result.fetch("section"),
    entry: result.fetch("entry")
  }
rescue JSON::ParserError => error
  raise "kettle-changelog returned invalid JSON: #{error.message}"
rescue Gem::Exception => error
  raise "kettle-changelog executable is not installed: #{error.message}"
end

.bootstrap_only_report?(report) ⇒ Boolean

Returns:

  • (Boolean)


159
160
161
# File 'lib/kettle/jem/maintenance_changelog.rb', line 159

def bootstrap_only_report?(report)
  report.fetch(:setup_status, "").to_s.start_with?("bootstrap_")
end

.record_template_run(project_root:, report:, run_options: {}, label: "Apply kettle-jem templates") ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/kettle/jem/maintenance_changelog.rb', line 90

def record_template_run(project_root:, report:, run_options: {}, label: "Apply kettle-jem templates")
  report = report.to_h
  if template_changelog_disabled?(run_options) || bootstrap_only_report?(report)
    reason = template_changelog_disabled?(run_options) ? "disabled" : "bootstrap_only"
    return report.merge(changelog: {status: "skipped", reason: reason})
  end

  changed_files = template_changed_files(report.fetch(:changed_files, []))
  if changed_files.empty?
    return report.merge(changelog: {status: "skipped", reason: "no_template_changes", changed_files: []})
  end

  unless File.file?(File.join(project_root, "CHANGELOG.md"))
    return report.merge(changelog: {status: "skipped", reason: "missing_changelog"})
  end

  key = TEMPLATE_CHANGELOG_KEYS.fetch(label, "kettle-jem/template")
  tracked_paths = nil
  entry = lambda do |matches|
    existing_paths = matches.empty? ? [] : TemplateLock.maintenance_changelog_paths(project_root: project_root, key: key).to_a
    tracked_paths = (existing_paths + changed_files).uniq.sort
    template_run_entry(tracked_paths)
  end
  result = upsert_unreleased_entry(project_root: project_root, section: "Changed", key: key, entry: entry)
  state_changed = TemplateLock.update_maintenance_changelog_paths(
    project_root: project_root,
    key: key,
    paths: tracked_paths || changed_files
  )
  changelog = result.merge(changed_files: changed_files, tracked_paths: tracked_paths, entry: result.fetch(:entry))
  changed_files_with_changelog = if result.fetch(:status) == "updated"
    (Array(report.fetch(:changed_files, [])) + ["CHANGELOG.md"]).uniq.sort
  else
    Array(report.fetch(:changed_files, [])).uniq.sort
  end
  changed_files_with_changelog << KETTLE_LOCK_PATH if state_changed
  report.merge(changelog: changelog, changed_files: changed_files_with_changelog)
end

.template_change_category(path) ⇒ Object



147
148
149
150
151
152
# File 'lib/kettle/jem/maintenance_changelog.rb', line 147

def template_change_category(path)
  TEMPLATE_CHANGE_CATEGORIES.each do |category, matcher|
    return category if matcher.call(path)
  end
  :other
end

.template_changed_files(changed_files) ⇒ Object



129
130
131
132
133
# File 'lib/kettle/jem/maintenance_changelog.rb', line 129

def template_changed_files(changed_files)
  Array(changed_files).map(&:to_s).uniq.sort.reject do |path|
    TEMPLATE_CHANGELOG_INTERNAL_PATHS.include?(path)
  end
end

.template_changelog_disabled?(run_options) ⇒ Boolean

Returns:

  • (Boolean)


154
155
156
157
# File 'lib/kettle/jem/maintenance_changelog.rb', line 154

def template_changelog_disabled?(run_options)
  options = run_options.to_h
  DecisionPolicy.value_to_boolean(options[:skip_changelog] || options["skip_changelog"])
end

.template_run_entry(changed_files) ⇒ Object



135
136
137
138
139
140
141
142
143
144
145
# File 'lib/kettle/jem/maintenance_changelog.rb', line 135

def template_run_entry(changed_files)
  counts = {}
  changed_files.group_by { |path| template_change_category(path) }.each do |category, paths|
    counts[category] = counts.fetch(category, 0) + paths.length
  end
  total = counts.values.sum
  details = counts.sort_by { |category, _count| category.to_s }.map do |category, count|
    "  - #{category.to_s.tr("_", " ")} (#{count})"
  end
  ["updated #{total} project file#{"s" unless total == 1}:", *details].join("\n")
end

.upsert_unreleased_entry(project_root:, section:, key:, entry:, legacy_prefixes: []) ⇒ Object



77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/kettle/jem/maintenance_changelog.rb', line 77

def upsert_unreleased_entry(project_root:, section:, key:, entry:, legacy_prefixes: [])
  result = with_project_changelog_path(project_root) do
    Kettle::Changelog::KeyedEntryUpserter.new(
      root: project_root,
      section: section,
      key: key,
      entry: entry,
      legacy_prefixes: legacy_prefixes
    ).run
  end
  result.merge(status: result.fetch(:changed) ? "updated" : "unchanged")
end

.with_project_changelog_path(project_root) ⇒ Object



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/kettle/jem/maintenance_changelog.rb', line 163

def with_project_changelog_path(project_root)
  CHANGELOG_PATH_MUTEX.synchronize do
    previous_path = ENV.fetch("K_CHANGELOG_PATH", nil)
    # The destination root is explicit here; inherited mise settings belong to
    # the caller and must not redirect a template maintenance entry.
    # rubocop:disable Env/Assign
    ENV["K_CHANGELOG_PATH"] = File.join(project_root, "CHANGELOG.md")
    yield
  ensure
    if previous_path.nil?
      ENV.delete("K_CHANGELOG_PATH")
    else
      ENV["K_CHANGELOG_PATH"] = previous_path
    end
    # rubocop:enable Env/Assign
  end
end