Class: Reissue::Task

Inherits:
Rake::TaskLib
  • Object
show all
Includes:
Gem
Defined in:
lib/reissue/rake.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name = :reissue, formatter: Reissue, tasker: Rake::Task) ⇒ Task

Returns a new instance of Task.



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/reissue/rake.rb', line 126

def initialize(name = :reissue, formatter: Reissue, tasker: Rake::Task)
  @name = name
  @formatter = formatter
  @tasker = tasker
  @description = "Prepare the code for work on a new version."
  @version_file = nil
  @updated_paths = []
  @changelog_file = "CHANGELOG.md"
  @retain_changelogs = false
  @runbook_file = nil
  @fragment = nil
  @clear_fragments = false
  @commit = true
  @commit_finalize = true
  @commit_clear_fragments = true
  @push_finalize = false
  @version_limit = 2
  @version_redo_proc = nil
  @push_reissue = :branch
  @tag_pattern = nil
  @changelog_sections = nil
  @deferred_versioning = false
end

Instance Attribute Details

#changelog_fileObject

The path to the changelog file.



33
34
35
# File 'lib/reissue/rake.rb', line 33

def changelog_file
  @changelog_file
end

#changelog_sectionsObject

The ordered list of valid changelog sections. Controls both validation (which sections are accepted) and ordering (how they appear). Default: nil (uses Reissue.changelog_sections)



124
125
126
# File 'lib/reissue/rake.rb', line 124

def changelog_sections
  @changelog_sections
end

#clear_fragmentsObject

Whether to clear fragment files after processing. Default: false.



67
68
69
# File 'lib/reissue/rake.rb', line 67

def clear_fragments
  @clear_fragments
end

#commitObject

Whether to commit the changes. Default: true.



77
78
79
# File 'lib/reissue/rake.rb', line 77

def commit
  @commit
end

#commit_clear_fragmentsObject



86
87
88
89
# File 'lib/reissue/rake.rb', line 86

def commit_clear_fragments
  return false if fragment == :git
  @commit_clear_fragments
end

#commit_finalizeObject

Whether to commit the finalize change to the changelog. Default: true.



80
81
82
# File 'lib/reissue/rake.rb', line 80

def commit_finalize
  @commit_finalize
end

#deferred_versioningObject

Whether to defer version bumping until finalize time. Default: false. When true, the reissue task sets VERSION to "Unreleased" instead of bumping, and the finalize task resolves the version from git tags + segment argument.



119
120
121
# File 'lib/reissue/rake.rb', line 119

def deferred_versioning
  @deferred_versioning
end

#descriptionObject

A description of the main task.



21
22
23
# File 'lib/reissue/rake.rb', line 21

def description
  @description
end

#fragmentString?

Note:

Default: nil (disabled)

The fragment configuration for changelog entries.

Examples:

Using directory-based fragments

task.fragment = "changelog_fragments"

Returns:

  • (String, nil)

    nil (disabled) or a directory path string for fragment files



51
52
53
# File 'lib/reissue/rake.rb', line 51

def fragment
  @fragment
end

#nameObject

The name of the main task and the namespace for the other tasks.



18
19
20
# File 'lib/reissue/rake.rb', line 18

def name
  @name
end

#push_finalizeObject

Whether to branch and push the changes. Default: :branch. Requires commit_finialize to be true.

Set this to false to disable pushing. Set this to true to push to the current branch. Set this to :branch to push to a new branch.



97
98
99
# File 'lib/reissue/rake.rb', line 97

def push_finalize
  @push_finalize
end

#push_reissueObject

Whether to push the changes when a new version is created. Default: :branch. Requires commit to be true.

Set this to false to disable pushing. Set this to true to push to the current branch. Set this to :branch to push to a new branch.



105
106
107
# File 'lib/reissue/rake.rb', line 105

def push_reissue
  @push_reissue
end

#retain_changelogsObject

Set to true to retain the changelog files for the previous versions. Default: false. Provide a callable to decide how to store the files.



38
39
40
# File 'lib/reissue/rake.rb', line 38

def retain_changelogs
  @retain_changelogs
end

#runbook_fileObject

The path to a runbook file listing steps to perform after a release. Populated from "Runbook:" git trailers or direct edits, finalized with the release version, and cleared when a new version is prepared. Default: nil (disabled).



44
45
46
# File 'lib/reissue/rake.rb', line 44

def runbook_file
  @runbook_file
end

#tag_patternObject

Optional regex pattern for matching version tags. Must include a capture group for the version number. Examples:

- /^v(\d+\.\d+\.\d+.*)$/ matches "v1.2.3" (default)
- /^myapp-v(\d+\.\d+\.\d+.*)$/ matches "myapp-v1.2.3"
- /^qualified-v(\d+\.\d+\.\d+.*)$/ matches "qualified-v0.3.5"

Default: nil (uses default pattern matching "v1.2.3")



114
115
116
# File 'lib/reissue/rake.rb', line 114

def tag_pattern
  @tag_pattern
end

#updated_pathsObject



72
73
74
# File 'lib/reissue/rake.rb', line 72

def updated_paths
  Array(@updated_paths)
end

#version_fileObject

The path to the version file. Required.



24
25
26
# File 'lib/reissue/rake.rb', line 24

def version_file
  @version_file
end

#version_limitObject

The number of versions to retain in the changelog file. Defaults to 2.



27
28
29
# File 'lib/reissue/rake.rb', line 27

def version_limit
  @version_limit
end

#version_redo_procObject

A proc that can be used to create the new version string.



30
31
32
# File 'lib/reissue/rake.rb', line 30

def version_redo_proc
  @version_redo_proc
end

Class Method Details

.create(name = :reissue, &block) ⇒ Object



9
10
11
12
13
14
15
# File 'lib/reissue/rake.rb', line 9

def self.create name = :reissue, &block
  task = new name
  task.instance_eval(&block) if block
  raise "No Reissue task.version_file specified" unless task.version_file
  task.define
  task
end

Instance Method Details

#bundleObject



183
184
185
186
187
188
189
# File 'lib/reissue/rake.rb', line 183

def bundle
  if defined?(Bundler) && File.exist?("Gemfile")
    Bundler.with_unbundled_env do
      system("bundle install")
    end
  end
end

#changes_to_commit?Boolean

Check if there are staged changes ready to commit.

Returns:

  • (Boolean)


192
193
194
195
# File 'lib/reissue/rake.rb', line 192

def changes_to_commit?
  _, _, status = Open3.capture3("git diff --cached --quiet")
  !status.success?
end

#defineObject



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
# File 'lib/reissue/rake.rb', line 205

def define
  Reissue.changelog_sections = changelog_sections if changelog_sections

  desc description
  task name, [:segment] do |task, args|
    if deferred_versioning
      new_version = formatter.deferred_call(
        version_file:,
        changelog_file:,
        version_limit:,
        fragment: fragment,
        tag_pattern:,
        runbook_file:
      )
    else
      segment = args[:segment] || "patch"
      new_version = formatter.call(
        segment:,
        version_file:,
        changelog_file:,
        version_limit:,
        version_redo_proc:,
        fragment: fragment,
        tag_pattern:,
        runbook_file:
      )
    end
    bundle

    tasker["#{name}:clear_fragments"].invoke

    run_command("git add -u", "Failed to stage updated files")
    stage_updated_paths

    bump_message = if deferred_versioning
      "Prepare for next development version"
    else
      "Bump version to #{new_version}"
    end
    if commit
      if reissue_version_with_branch?
        tasker["#{name}:branch"].reenable
        branch_name = if deferred_versioning
          "reissue/next"
        else
          "reissue/#{new_version}"
        end
        tasker["#{name}:branch"].invoke(branch_name)
      end
      run_command("git commit -m '#{bump_message}'", "Failed to commit version bump")
      tasker["#{name}:push"].invoke if push_reissue?
    else
      puts bump_message
    end

    new_version
  end

  desc "Reformat the changelog file to ensure it is correctly formatted."
  task "#{name}:reformat", [:version_limit] do |task, args|
    version_limit = if args[:version_limit].nil?
      self.version_limit
    else
      args[:version_limit].to_i
    end
    unless File.exist?(changelog_file)
      formatter.generate_changelog(changelog_file)
    end
    formatter.reformat(changelog_file, version_limit:, retain_changelogs:)
  end

  desc "Finalize the changelog for an unreleased version to set the release date."
  task "#{name}:finalize", [:version_or_segment, :date] do |task, args|
    if deferred_versioning
      version_or_segment = args[:version_or_segment]
      date = args[:date] || Time.now.strftime("%Y-%m-%d")

      version_arg = nil
      segment_arg = nil
      if version_or_segment&.match?(/^\d/)
        version_arg = version_or_segment
      elsif version_or_segment
        segment_arg = version_or_segment
      end

      version, date = formatter.deferred_finalize(
        date,
        version: version_arg,
        segment: segment_arg,
        changelog_file:,
        retain_changelogs:,
        fragment: fragment,
        tag_pattern:,
        version_file:,
        version_redo_proc:,
        runbook_file:
      )
    else
      date = args[:version_or_segment] || Time.now.strftime("%Y-%m-%d")
      version, date = formatter.finalize(
        date,
        changelog_file:,
        retain_changelogs:,
        fragment: fragment,
        tag_pattern:,
        version_file:,
        runbook_file:
      )
    end

    finalize_message = "Finalize the changelog for version #{version} on #{date}"
    if commit_finalize
      if finalize_with_branch?
        tasker["#{name}:branch"].invoke("finalize/#{version}")
      end
      run_command("git add -u", "Failed to stage finalized changelog")
      stage_updated_paths
      if changes_to_commit?
        run_command("git commit -m '#{finalize_message}'", "Failed to commit finalized changelog")
        tasker["#{name}:push"].invoke if push_finalize?
      else
        puts finalize_message
      end
    else
      puts finalize_message
    end
  end

  desc "Create or switch to a branch for the release workflow."
  task "#{name}:branch", [:branch_name] do |task, args|
    raise "No branch name specified" unless args[:branch_name]
    branch_name = args[:branch_name]
    run_command("git checkout -B #{branch_name}", "Failed to checkout branch #{branch_name}")
  end

  desc "Push the current branch to the remote repository."
  task "#{name}:push" do
    run_command("git push --force-with-lease origin HEAD", "Failed to push to remote repository")
  end

  desc "Preview changelog entries that will be added from fragments or git trailers"
  task "#{name}:preview" do
    if fragment
      require_relative "fragment_handler"
      handler = Reissue::FragmentHandler.for(fragment, tag_pattern: tag_pattern)

      # Show comparison tag for git trailers
      if fragment == :git && handler.respond_to?(:last_tag)
        last_tag = handler.last_tag
        if last_tag
          puts "Comparing against: #{last_tag}"
          puts "  (Run 'git fetch --tags' if this seems out of date)\n\n"
        else
          puts "No version tags found (comparing against all commits)\n\n"
        end
      end

      entries = handler.read

      if entries.empty?
        puts "No changelog entries found."
        if fragment == :git
          puts "  (No git trailers found since last version tag)"
        else
          puts "  (No fragment files found in '#{fragment}')"
        end
      else
        puts "Changelog entries that will be added:\n\n"
        # Sort sections in configured changelog order
        sorted_sections = entries.keys.sort_by { |k| Reissue.changelog_sections.index(k) || 999 }

        sorted_sections.each do |section|
          items = entries[section]
          puts "### #{section}\n"
          items.each { |item| puts "- #{item}" }
          puts
        end

        puts "Total: #{entries.values.flatten.count} entries across #{entries.keys.count} sections"
      end
    else
      puts "Fragment handling is not configured."
      puts "Set task.fragment to a directory path or :git to enable changelog fragments."
    end

    if runbook_file
      require_relative "fragment_handler"
      runbook_entries = Reissue::FragmentHandler.for(:git, tag_pattern: tag_pattern).read_runbook_entries

      puts
      if runbook_entries.empty?
        puts "No runbook items found."
        puts "  (No Runbook: trailers found since last version tag)"
      else
        puts "Runbook items that will be added to #{runbook_file}:\n\n"
        runbook_entries.each { |item| puts "- [ ] #{item}" }
      end
    end
  end

  desc "Clear fragments"
  task "#{name}:clear_fragments" do
    # Clear fragments after release if configured
    # Only run for directory-based fragments, not :git
    if fragment && clear_fragments && fragment.is_a?(String)
      formatter.clear_fragments(fragment)
      clear_message = "Clear changelog fragments"
      if commit_clear_fragments
        run_command("git add #{fragment}", "Failed to stage cleared fragments")
        run_command("git commit -m '#{clear_message}'", "Failed to commit cleared fragments")
      else
        puts clear_message
      end
    end
  end

  desc "Bump version based on git trailers"
  task "#{name}:bump" do
    next if deferred_versioning
    # Only check for version trailers when using git fragments
    next unless fragment == :git

    require_relative "fragment_handler"
    require_relative "version_updater"

    handler = Reissue::FragmentHandler.for(:git, tag_pattern: tag_pattern)

    # Get current version from version file
    version_content = File.read(version_file)
    current_version = ::Gem::Version.new(version_content.match(Reissue::VersionUpdater::VERSION_MATCH)[0])

    # Get version from last git tag
    tag_version = handler.last_tag_version

    bump = handler.read_version_bump

    if tag_version && current_version == tag_version
      if bump
        updater = Reissue::VersionUpdater.new(version_file, version_redo_proc: version_redo_proc)
        updater.call(bump)
        bundle
        puts "Version bumped (#{bump}) to #{updater.instance_variable_get(:@new_version)}"
      end
    elsif tag_version && current_version != tag_version
      if bump
        updater = Reissue::VersionUpdater.new(version_file, version_redo_proc: version_redo_proc)
        desired_version = updater.redo(tag_version, bump)

        if desired_version > current_version
          updater.call(bump)
          bundle
          puts "Version bumped (#{bump}) to #{updater.instance_variable_get(:@new_version)}"
        else
          puts "Version already bumped (#{tag_version}#{current_version}), skipping"
        end
      else
        puts "Version already bumped (#{tag_version}#{current_version}), skipping"
      end
    elsif bump
      updater = Reissue::VersionUpdater.new(version_file, version_redo_proc: version_redo_proc)
      updater.call(bump)
      bundle
      puts "Version bumped (#{bump}) to #{updater.instance_variable_get(:@new_version)}"
    end
  end
end

#finalize_with_branch?Boolean

Returns:

  • (Boolean)


167
168
169
# File 'lib/reissue/rake.rb', line 167

def finalize_with_branch?
  push_finalize == :branch
end

#fragment_directoryObject

Deprecated.

Use #fragment instead



60
61
62
63
# File 'lib/reissue/rake.rb', line 60

def fragment_directory
  warn "[DEPRECATION] `fragment_directory` is deprecated. Please use `fragment` instead."
  @fragment
end

#fragment_directory=(value) ⇒ Object

Deprecated.

Use #fragment instead



54
55
56
57
# File 'lib/reissue/rake.rb', line 54

def fragment_directory=(value)
  warn "[DEPRECATION] `fragment_directory` is deprecated. Please use `fragment` instead."
  self.fragment = value
end

#push_finalize?Boolean

Returns:

  • (Boolean)


171
172
173
# File 'lib/reissue/rake.rb', line 171

def push_finalize?
  !!push_finalize
end

#push_reissue?Boolean

Returns:

  • (Boolean)


179
180
181
# File 'lib/reissue/rake.rb', line 179

def push_reissue?
  !!push_reissue
end

#reissue_version_with_branch?Boolean

Returns:

  • (Boolean)


175
176
177
# File 'lib/reissue/rake.rb', line 175

def reissue_version_with_branch?
  push_reissue == :branch
end

#run_command(command, error_message) ⇒ Object

Run a shell command and raise an error with details if it fails



154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/reissue/rake.rb', line 154

def run_command(command, error_message)
  stdout, stderr, status = Open3.capture3(command)
  unless status.success?
    error_details = [error_message]
    error_details << "Command: #{command}"
    error_details << "Exit status: #{status.exitstatus}"
    error_details << "STDOUT: #{stdout.strip}" unless stdout.strip.empty?
    error_details << "STDERR: #{stderr.strip}" unless stderr.strip.empty?
    raise error_details.join("\n")
  end
  stdout
end

#stage_updated_pathsObject

Stage updated_paths (and the runbook file) that exist on disk.



198
199
200
201
202
203
# File 'lib/reissue/rake.rb', line 198

def stage_updated_paths
  existing = (updated_paths + [runbook_file].compact).select { |p| File.exist?(p) }
  if existing.any?
    run_command("git add #{existing.join(" ")}", "Failed to stage additional paths: #{existing.join(", ")}")
  end
end