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 +7 -0
- data/README.md +109 -0
- data/Rakefile +4 -0
- data/bin/gem_maintain +6 -0
- data/lib/gem_maintainer/bundle_updater.rb +33 -0
- data/lib/gem_maintainer/changelog_fetcher.rb +105 -0
- data/lib/gem_maintainer/changelog_renderer.rb +103 -0
- data/lib/gem_maintainer/cli.rb +85 -0
- data/lib/gem_maintainer/outdated_resolver.rb +46 -0
- data/lib/gem_maintainer/prompt.rb +99 -0
- data/lib/gem_maintainer/semver_filter.rb +47 -0
- data/lib/gem_maintainer/session.rb +99 -0
- data/lib/gem_maintainer/summary.rb +34 -0
- data/lib/gem_maintainer/version.rb +5 -0
- data/lib/gem_maintainer.rb +16 -0
- data/sig/gem_maintainer.rbs +4 -0
- metadata +102 -0
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
data/bin/gem_maintain
ADDED
|
@@ -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,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
|
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: []
|