gem_maintainer 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 54bd80a6adcda9d4ea287bf7b9d0390d60d0fbad24189c08a58b607ca9043dbe
4
+ data.tar.gz: 7ce8d01f55319c5f5e49a05db27a181d9729d752717a8a4a84ea0e8cd9f55474
5
+ SHA512:
6
+ metadata.gz: 9dbaebd2459dfcc6dbc7ccdcd7a6984ccaa763369daaf949050bc467fbe96404a1aa35e5ff94e75ec9829cb6284bb82cd6d530d5478b790f70e7a23549d40259
7
+ data.tar.gz: 79a8b2c2b068eea85142016516d5a139b0cfaca888bcb8712d112880d35a6afb0eaa6445bde3462e18e2e0f58c4afb76339884344c85beb700aa962f007815da
data/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # gem_maintainer
2
+
3
+ A tool for keeping your Ruby project's gems up to date, one at a time.
4
+
5
+ This gem is designed around following a weekly updates process to ensure a gemfile is kept largely up to date and does not fall behind. When updating weekly it's assumed that a lot of gem upgrades wouldn't need any changes, and so they can be batched together quickly. The most time consuming part of this process is finding and checking changelogs, which this gem speeds up greatly.
6
+
7
+ `gem_maintainer` walks you through each available upgrade interactively. You can review the changelog link for each gem, decide whether to approve or skip it, and at the end it updates everything you approved in one go.
8
+
9
+ ## Installation
10
+
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "gem_maintainer"
15
+ ```
16
+
17
+ Then run:
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ Run from within your project directory:
26
+
27
+ ```bash
28
+ gem_maintain
29
+ ```
30
+
31
+ You'll be walked through each outdated gem one by one. For each one you'll see the current and new version, the bump type (patch, minor, or major), and a link to the changelog. You can then choose to:
32
+
33
+ - **approve** — queue it for updating
34
+ - **skip** — leave it at the current version
35
+ - **quit** — save your progress and exit
36
+
37
+ Once you've worked through the list, all approved gems are updated together with a single `bundle update`. Afterward, a summary is printed showing each updated gem with its version bump and changelog link, which skipped gems were left alone, and any gems that Bundler attempted to update but whose version didn't change (e.g. due to Gemfile constraints).
38
+
39
+ The intention here is that any gem requiring changes could be skipped, worked on and committed separately. But any gems with no changes can be upgraded all at once in a single commit.
40
+
41
+ ### Filtering by version bump type
42
+
43
+ To only see patch-level upgrades (e.g. `1.2.3` → `1.2.4`):
44
+
45
+ ```bash
46
+ gem_maintain --patch
47
+ ```
48
+
49
+ To only see minor-level upgrades (e.g. `1.2.3` → `1.3.0`):
50
+
51
+ ```bash
52
+ gem_maintain --minor
53
+ ```
54
+
55
+ To only see major-level upgrades (e.g. `1.2.3` → `2.0.0`):
56
+
57
+ ```bash
58
+ gem_maintain --major
59
+ ```
60
+
61
+ ### Inline changelogs
62
+
63
+ To fetch and render relevant changelog content directly in the terminal for each gem:
64
+
65
+ ```bash
66
+ gem_maintain --changelog
67
+ ```
68
+
69
+ This shows the changelog entries relevant to the upgrade alongside the changelog URL, so you can review what changed without leaving the terminal.
70
+
71
+ ### Resuming a session
72
+
73
+ If you quit part way through, your progress is saved automatically. Pick up where you left off with:
74
+
75
+ ```bash
76
+ gem_maintain --resume
77
+ ```
78
+
79
+ ### Ignoring gems
80
+
81
+ To permanently skip certain gems (e.g. ones you never want to update), create a `.gem_maintainer.yml` file in your project root:
82
+
83
+ ```yaml
84
+ ignore:
85
+ - rubocop
86
+ - bundler-audit
87
+ ```
88
+
89
+ Gems in this list will be excluded from the outdated check entirely and never appear in the interactive session.
90
+
91
+ ## Development
92
+
93
+ Clone the repo and install dependencies:
94
+
95
+ ```bash
96
+ git clone git@github.com:WillRogers727/gem_maintainer.git
97
+ cd gem_maintainer
98
+ bundle install
99
+ ```
100
+
101
+ To build and install the gem locally for testing, use the provided build script:
102
+
103
+ ```bash
104
+ bin/build
105
+ ```
106
+
107
+ This builds the gem, installs it, and cleans up the `.gem` file in one step.
108
+
109
+ To release a new version, update the version number in `lib/gem_maintainer/version.rb`, then run `bin/build`.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
data/bin/gem_maintain ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/gem_maintainer"
5
+
6
+ GemMaintainer::CLI.new(ARGV).run
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module GemMaintainer
6
+ # Runs `bundle update` for the approved set of gems.
7
+ class BundleUpdater
8
+ def initialize(approved_gems)
9
+ @gems = approved_gems
10
+ end
11
+
12
+ # Returns an array of gem names that Bundler attempted to update but whose
13
+ # version stayed the same.
14
+ def run
15
+ names = @gems.map { |g| g[:name] }.join(" ")
16
+ puts "Running: bundle update #{names}"
17
+ puts
18
+
19
+ unchanged = []
20
+
21
+ Open3.popen2e("bundle update #{names}") do |_stdin, stdout_err, wait_thr|
22
+ stdout_err.each_line do |line|
23
+ print line
24
+ match = line.match(/Bundler attempted to update (\S+) but its version stayed the same/)
25
+ unchanged << match[1] if match
26
+ end
27
+ wait_thr.value
28
+ end
29
+
30
+ unchanged
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module GemMaintainer
8
+ # Fetches the changelog URL for a gem.
9
+ # Strategy (mirrors bin/gem-changelog logic):
10
+ # 1. changelog_uri from RubyGems metadata
11
+ # 2. source_code_uri — search GitHub for a changelog file
12
+ # 3. homepage_uri — search GitHub for a changelog file
13
+ # 4. Locally installed gem directory
14
+ class ChangelogFetcher
15
+ RUBYGEMS_API = "https://rubygems.org/api/v1/gems/%s.json"
16
+ CHANGELOG_FILES = %w[CHANGELOG.md CHANGELOG.txt CHANGELOG CHANGES.md CHANGES History.md].freeze
17
+ GITHUB_BRANCHES = %w[main master].freeze
18
+
19
+ def fetch(gem_name)
20
+ metadata = rubygems_metadata(gem_name)
21
+ return nil unless metadata
22
+
23
+ changelog_uri = metadata["changelog_uri"]
24
+ source_code_uri = metadata["source_code_uri"]
25
+ homepage_uri = metadata["homepage_uri"]
26
+
27
+ # 1. Explicit changelog_uri
28
+ if changelog_uri && !changelog_uri.empty?
29
+ return changelog_uri if url_exists?(changelog_uri)
30
+ end
31
+
32
+ # 2. source_code_uri → GitHub search
33
+ if source_code_uri && source_code_uri.include?("github.com")
34
+ url = find_github_changelog(source_code_uri)
35
+ return url if url
36
+ end
37
+
38
+ # 3. homepage_uri → GitHub search
39
+ if homepage_uri && homepage_uri.include?("github.com")
40
+ url = find_github_changelog(homepage_uri)
41
+ return url if url
42
+ end
43
+
44
+ # 4. Locally installed gem
45
+ local = find_local_changelog(gem_name)
46
+ return local if local
47
+
48
+ # Fallback: RubyGems page
49
+ "https://rubygems.org/gems/#{gem_name}"
50
+ end
51
+
52
+ private
53
+
54
+ def rubygems_metadata(gem_name)
55
+ uri = URI(RUBYGEMS_API % gem_name)
56
+ response = Net::HTTP.get_response(uri)
57
+ return nil unless response.is_a?(Net::HTTPSuccess)
58
+
59
+ JSON.parse(response.body)
60
+ rescue StandardError
61
+ nil
62
+ end
63
+
64
+ def find_github_changelog(url)
65
+ repo = url.match(%r{github\.com/([^/?#]+/[^/?#]+)})&.captures&.first
66
+ return nil unless repo
67
+
68
+ repo = repo.delete_suffix(".git")
69
+
70
+ GITHUB_BRANCHES.each do |branch|
71
+ CHANGELOG_FILES.each do |file|
72
+ candidate = "https://github.com/#{repo}/blob/#{branch}/#{file}"
73
+ raw = "https://raw.githubusercontent.com/#{repo}/#{branch}/#{file}"
74
+ return candidate if url_exists?(raw)
75
+ end
76
+ end
77
+
78
+ nil
79
+ end
80
+
81
+ def find_local_changelog(gem_name)
82
+ gem_dir = Gem::Specification.find_by_name(gem_name)&.gem_dir
83
+ return nil unless gem_dir && Dir.exist?(gem_dir)
84
+
85
+ CHANGELOG_FILES.each do |file|
86
+ path = File.join(gem_dir, file)
87
+ return "file://#{path}" if File.exist?(path)
88
+ end
89
+
90
+ nil
91
+ rescue Gem::MissingSpecError
92
+ nil
93
+ end
94
+
95
+ def url_exists?(url)
96
+ uri = URI(url)
97
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
98
+ http.head(uri.request_uri)
99
+ end
100
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
101
+ rescue StandardError
102
+ false
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module GemMaintainer
7
+ # Fetches a changelog file and extracts the section for a given version,
8
+ # then renders it to the terminal.
9
+ class ChangelogRenderer
10
+ # Number of lines to show before truncating
11
+ MAX_LINES = 30
12
+
13
+ def render(version, url, pastel)
14
+ return unless url
15
+
16
+ content = if url.start_with?("file://")
17
+ fetch_local(url)
18
+ elsif url.start_with?("https://github.com")
19
+ fetch_raw(url)
20
+ end
21
+
22
+ return unless content
23
+
24
+ section = extract_section(content, version)
25
+ return unless section
26
+
27
+ print_section(section, pastel)
28
+ end
29
+
30
+ private
31
+
32
+ def fetch_raw(github_url)
33
+ raw_url = to_raw_url(github_url)
34
+ return nil unless raw_url
35
+
36
+ uri = URI(raw_url)
37
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 5) do |http|
38
+ http.get(uri.request_uri)
39
+ end
40
+
41
+ response.is_a?(Net::HTTPSuccess) ? response.body : nil
42
+ rescue StandardError
43
+ nil
44
+ end
45
+
46
+ def to_raw_url(github_url)
47
+ # https://github.com/user/repo/blob/main/CHANGELOG.md
48
+ # → https://raw.githubusercontent.com/user/repo/main/CHANGELOG.md
49
+ github_url
50
+ .sub("https://github.com", "https://raw.githubusercontent.com")
51
+ .sub("/blob/", "/")
52
+ end
53
+
54
+ def fetch_local(file_url)
55
+ path = file_url.sub("file://", "")
56
+ File.exist?(path) ? File.read(path) : nil
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def extract_section(content, version)
62
+ lines = content.lines
63
+ # Match plain version, v-prefixed, bracketed, and with trailing date
64
+ # e.g. 1.2.3 / v1.2.3 / [1.2.3] / [1.2.3] - 2024-01-01
65
+ escaped = Regexp.escape(version)
66
+ version_pattern = /v?#{escaped}|\[#{escaped}\]/
67
+
68
+ # Find the line index of the heading that contains the target version
69
+ start_idx = lines.index { |line| heading?(line) && line.match?(version_pattern) }
70
+ return nil unless start_idx
71
+
72
+ heading_level = heading_level_of(lines[start_idx])
73
+
74
+ # Collect lines until the next heading of the same or higher level
75
+ section = []
76
+ lines[(start_idx + 1)..].each do |line|
77
+ break if heading?(line) && heading_level_of(line) <= heading_level
78
+
79
+ section << line
80
+ end
81
+
82
+ section.empty? ? nil : section
83
+ end
84
+
85
+ def print_section(lines, pastel)
86
+ truncated = lines.length > MAX_LINES
87
+ display = truncated ? lines.first(MAX_LINES) : lines
88
+
89
+ puts
90
+ display.each { |line| print " #{line}" }
91
+ puts pastel.dim(" ... (truncated, see full changelog link above)") if truncated
92
+ puts
93
+ end
94
+
95
+ def heading?(line)
96
+ line.match?(/\A#{Regexp.escape("#")}+\s/)
97
+ end
98
+
99
+ def heading_level_of(line)
100
+ line.match(/\A(#+)/)[1].length
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module GemMaintainer
6
+ class CLI
7
+ def initialize(argv)
8
+ @argv = argv
9
+ @options = {}
10
+ end
11
+
12
+ def run
13
+ parse_options!
14
+
15
+ session = Session.load_or_new(@options)
16
+
17
+ if session.resuming?
18
+ puts pastel.cyan("Resuming session (#{session.remaining.size} gems remaining)...")
19
+ else
20
+ puts pastel.cyan("Resolving outdated gems...")
21
+ candidates = OutdatedResolver.new.resolve
22
+ candidates = SemverFilter.new(candidates).filter(@options[:level])
23
+
24
+ if candidates.empty?
25
+ puts pastel.green("No outdated gems found for the selected filter.")
26
+ return
27
+ end
28
+
29
+ session.start(candidates)
30
+ end
31
+
32
+ Prompt.new(session, pastel, inline_changelog: @options[:inline_changelog]).run
33
+
34
+ if session.approved.any?
35
+ puts
36
+ unchanged = BundleUpdater.new(session.approved).run
37
+ session.record_unchanged(unchanged)
38
+ puts
39
+ Summary.new(session).print
40
+ session.clear
41
+ else
42
+ puts pastel.yellow("No gems approved. Nothing to update.")
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def parse_options!
49
+ OptionParser.new do |opts|
50
+ opts.banner = "Usage: gem_maintain [options]"
51
+
52
+ opts.on("--patch", "Review patch upgrades only") do
53
+ @options[:level] = :patch
54
+ end
55
+
56
+ opts.on("--minor", "Review minor upgrades only") do
57
+ @options[:level] = :minor
58
+ end
59
+
60
+ opts.on("--major", "Review major upgrades only") do
61
+ @options[:level] = :major
62
+ end
63
+
64
+ opts.on("--changelog", "Render relevant changelog information inline for each gem") do
65
+ @options[:inline_changelog] = true
66
+ end
67
+
68
+ opts.on("--resume", "Resume a saved session") do
69
+ @options[:resume] = true
70
+ end
71
+
72
+ opts.on("-h", "--help", "Show this help") do
73
+ puts opts
74
+ exit
75
+ end
76
+ end.parse!(@argv)
77
+
78
+ @options[:level] ||= :all
79
+ end
80
+
81
+ def pastel
82
+ @pastel ||= Pastel.new
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "yaml"
5
+
6
+ module GemMaintainer
7
+ # Runs `bundle outdated` and parses the output into a list of gem candidates.
8
+ # Each candidate is a hash: { name:, current:, latest: }
9
+ class OutdatedResolver
10
+ OUTDATED_LINE = /\A\s*\*\s+(\S+)\s+\(newest\s+([\d.]+).*?installed\s+([\d.]+)/
11
+
12
+ def resolve
13
+ stdout, _stderr, _status = Open3.capture3("bundle outdated --parseable")
14
+ parse(stdout)
15
+ end
16
+
17
+ private
18
+
19
+ def parse(output)
20
+ candidates = []
21
+
22
+ output.each_line do |line|
23
+ # parseable format: gemname (newest X.Y.Z, installed A.B.C, ...)
24
+ match = line.match(/\A(\S+)\s+\(newest\s+([\d.]+).*?installed\s+([\d.]+)/)
25
+ next unless match
26
+ next if ignore_list.include?(match[1])
27
+
28
+ candidates << {
29
+ name: match[1],
30
+ latest: match[2],
31
+ current: match[3]
32
+ }
33
+ end
34
+
35
+ candidates
36
+ end
37
+
38
+ def ignore_list
39
+ config_path = File.join(Dir.pwd, ".gem_maintainer.yml")
40
+ return [] unless File.exist?(config_path)
41
+
42
+ config = YAML.load_file(config_path)
43
+ Array(config&.dig("ignore"))
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+
5
+ module GemMaintainer
6
+ # Drives the interactive per-gem loop.
7
+ class Prompt
8
+ DIVIDER = ("━" * 50).freeze
9
+
10
+ def initialize(session, pastel, inline_changelog: false)
11
+ @session = session
12
+ @pastel = pastel
13
+ @inline_changelog = inline_changelog
14
+ @tty = TTY::Prompt.new(interrupt: :exit)
15
+ @fetcher = ChangelogFetcher.new
16
+ @renderer = ChangelogRenderer.new
17
+ end
18
+
19
+ def run
20
+ gems = @session.remaining
21
+ total = @session.candidates.size
22
+ offset = total - gems.size
23
+
24
+ gems.each_with_index do |gem, idx|
25
+ display_gem(gem, offset + idx + 1, total)
26
+
27
+ choice = @tty.select("", choices, cycle: true, per_page: 3, show_help: :never)
28
+
29
+ case choice
30
+ when :approve
31
+ @session.approve(gem)
32
+ puts @pastel.green(" Queued for upgrade.")
33
+ when :skip
34
+ @session.skip(gem)
35
+ puts @pastel.yellow(" Skipped.")
36
+ when :quit
37
+ puts @pastel.cyan("\nSession saved. Run with --resume to continue.")
38
+ break
39
+ end
40
+
41
+ puts
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def display_gem(gem, position, total)
48
+ bump = SemverFilter.new(nil).bump_type(gem[:current], gem[:latest])
49
+ bump_label = bump_colour(bump)
50
+
51
+ puts
52
+ puts @pastel.bold(DIVIDER)
53
+ puts " #{@pastel.bold("(#{position}/#{total})")} " \
54
+ "#{@pastel.bold.cyan(gem[:name])} " \
55
+ "#{@pastel.dim(gem[:current])} → #{@pastel.green(gem[:latest])} #{bump_label}"
56
+ puts @pastel.bold(DIVIDER)
57
+
58
+ changelog = fetch_changelog(gem[:name])
59
+ if changelog
60
+ puts " #{@pastel.dim("Changelog:")} #{changelog}"
61
+ else
62
+ puts " #{@pastel.dim("Changelog: not found")}"
63
+ end
64
+
65
+ if @inline_changelog && changelog
66
+ @renderer.render(gem[:latest], changelog, @pastel)
67
+ else
68
+ puts
69
+ end
70
+ end
71
+
72
+ def fetch_changelog(gem_name)
73
+ cached = @session.changelog_for(gem_name)
74
+ return cached if cached
75
+
76
+ print @pastel.dim(" Fetching changelog...")
77
+ url = @fetcher.fetch(gem_name)
78
+ print "\r#{" " * 30}\r"
79
+ @session.store_changelog(gem_name, url) if url
80
+ url
81
+ end
82
+
83
+ def choices
84
+ [
85
+ { name: "approve", value: :approve },
86
+ { name: "skip", value: :skip },
87
+ { name: "quit (save & exit)", value: :quit }
88
+ ]
89
+ end
90
+
91
+ def bump_colour(bump)
92
+ case bump
93
+ when :patch then @pastel.green("[patch]")
94
+ when :minor then @pastel.yellow("[minor]")
95
+ when :major then @pastel.red("[major]")
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemMaintainer
4
+ # Filters gem candidates by semver bump type.
5
+ # level: :patch → only patch bumps (x.y.Z)
6
+ # :minor → only minor bumps (x.Y.z)
7
+ # :all → everything including major
8
+ class SemverFilter
9
+ def initialize(candidates)
10
+ @candidates = candidates
11
+ end
12
+
13
+ BUMP_RANK = { patch: 0, minor: 1, major: 2 }.freeze
14
+
15
+ def filter(level)
16
+ return @candidates if level == :all
17
+
18
+ @candidates.select do |gem|
19
+ bump_type(gem[:current], gem[:latest]) == level
20
+ end
21
+ end
22
+
23
+ # Returns :patch, :minor, or :major
24
+ def self.bump_type(current, latest)
25
+ new(nil).bump_type(current, latest)
26
+ end
27
+
28
+ def bump_type(current, latest)
29
+ cur = version_parts(current)
30
+ lat = version_parts(latest)
31
+
32
+ if lat[0] != cur[0]
33
+ :major
34
+ elsif lat[1] != cur[1]
35
+ :minor
36
+ else
37
+ :patch
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def version_parts(version)
44
+ version.to_s.split(".").map(&:to_i)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module GemMaintainer
6
+ # Persists session state to ~/.gem_maintainer_session.json so a run can be
7
+ # resumed if interrupted.
8
+ class Session
9
+ SESSION_FILE = File.join(Dir.home, ".gem_maintainer_session.json")
10
+
11
+ attr_reader :candidates, :approved, :skipped, :unchanged, :options
12
+
13
+ def self.load_or_new(options)
14
+ if options[:resume] && File.exist?(SESSION_FILE)
15
+ from_file(options)
16
+ else
17
+ new(options)
18
+ end
19
+ end
20
+
21
+ def self.from_file(options)
22
+ data = JSON.parse(File.read(SESSION_FILE), symbolize_names: true)
23
+ session = new(options)
24
+ session.restore(data)
25
+ session
26
+ end
27
+
28
+ def initialize(options)
29
+ @options = options
30
+ @candidates = []
31
+ @approved = []
32
+ @skipped = []
33
+ @unchanged = []
34
+ @changelogs = {}
35
+ @resuming = false
36
+ end
37
+
38
+ def start(candidates)
39
+ @candidates = candidates
40
+ save
41
+ end
42
+
43
+ def restore(data)
44
+ @candidates = data[:candidates].map { |c| c.transform_keys(&:to_sym) }
45
+ @approved = data[:approved].map { |c| c.transform_keys(&:to_sym) }
46
+ @skipped = data[:skipped].map { |c| c.transform_keys(&:to_sym) }
47
+ @changelogs = (data[:changelogs] || {}).transform_keys(&:to_s)
48
+ @resuming = true
49
+ end
50
+
51
+ def resuming?
52
+ @resuming
53
+ end
54
+
55
+ # Gems not yet actioned
56
+ def remaining
57
+ actioned_names = (@approved + @skipped).map { |g| g[:name] }
58
+ @candidates.reject { |g| actioned_names.include?(g[:name]) }
59
+ end
60
+
61
+ def approve(gem)
62
+ @approved << gem
63
+ save
64
+ end
65
+
66
+ def skip(gem)
67
+ @skipped << gem
68
+ save
69
+ end
70
+
71
+ def changelog_for(gem_name)
72
+ @changelogs[gem_name]
73
+ end
74
+
75
+ def record_unchanged(names)
76
+ @unchanged = names
77
+ end
78
+
79
+ def store_changelog(gem_name, url)
80
+ @changelogs[gem_name] = url
81
+ save
82
+ end
83
+
84
+ def clear
85
+ File.delete(SESSION_FILE) if File.exist?(SESSION_FILE)
86
+ end
87
+
88
+ private
89
+
90
+ def save
91
+ File.write(SESSION_FILE, JSON.pretty_generate({
92
+ candidates: @candidates,
93
+ approved: @approved,
94
+ skipped: @skipped,
95
+ changelogs: @changelogs
96
+ }))
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemMaintainer
4
+ # Prints a commit-message-ready summary after the batch update.
5
+ class Summary
6
+ def initialize(session)
7
+ @session = session
8
+ end
9
+
10
+ def print
11
+ puts "Gem updates:\n\n"
12
+
13
+ @session.approved.each do |gem|
14
+ next if @session.unchanged.include?(gem[:name])
15
+
16
+ bump = SemverFilter.new(nil).bump_type(gem[:current], gem[:latest])
17
+ changelog = @session.changelog_for(gem[:name])
18
+
19
+ puts "* #{gem[:name]} #{gem[:current]} → #{gem[:latest]} [#{bump}]"
20
+ puts " #{changelog}" if changelog
21
+ puts
22
+ end
23
+
24
+ if @session.skipped.any?
25
+ skipped_names = @session.skipped.map { |g| g[:name] }.join(", ")
26
+ puts "Skipped: #{skipped_names}"
27
+ end
28
+
29
+ if @session.unchanged.any?
30
+ puts "Didn't update: #{@session.unchanged.join(", ")}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemMaintainer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gem_maintainer/version"
4
+ require_relative "gem_maintainer/cli"
5
+ require_relative "gem_maintainer/outdated_resolver"
6
+ require_relative "gem_maintainer/semver_filter"
7
+ require_relative "gem_maintainer/changelog_fetcher"
8
+ require_relative "gem_maintainer/changelog_renderer"
9
+ require_relative "gem_maintainer/session"
10
+ require_relative "gem_maintainer/prompt"
11
+ require_relative "gem_maintainer/bundle_updater"
12
+ require_relative "gem_maintainer/summary"
13
+
14
+ module GemMaintainer
15
+ class Error < StandardError; end
16
+ end
@@ -0,0 +1,4 @@
1
+ module GemMaintainer
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gem_maintainer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Will Rogers
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: tty-prompt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.23'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.23'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pastel
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.8'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ description: Walk through outdated gems one by one, view changelog links, approve
56
+ or skip, then batch update at the end.
57
+ email:
58
+ - whmrogers@googlemail.com
59
+ executables:
60
+ - gem_maintain
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - README.md
65
+ - Rakefile
66
+ - bin/gem_maintain
67
+ - lib/gem_maintainer.rb
68
+ - lib/gem_maintainer/bundle_updater.rb
69
+ - lib/gem_maintainer/changelog_fetcher.rb
70
+ - lib/gem_maintainer/changelog_renderer.rb
71
+ - lib/gem_maintainer/cli.rb
72
+ - lib/gem_maintainer/outdated_resolver.rb
73
+ - lib/gem_maintainer/prompt.rb
74
+ - lib/gem_maintainer/semver_filter.rb
75
+ - lib/gem_maintainer/session.rb
76
+ - lib/gem_maintainer/summary.rb
77
+ - lib/gem_maintainer/version.rb
78
+ - sig/gem_maintainer.rbs
79
+ homepage: https://github.com/WillRogers727/gem_maintainer
80
+ licenses: []
81
+ metadata:
82
+ homepage_uri: https://github.com/WillRogers727/gem_maintainer
83
+ post_install_message:
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 3.0.0
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.5.11
99
+ signing_key:
100
+ specification_version: 4
101
+ summary: Interactive gem upgrade tool with changelog links and session persistence
102
+ test_files: []