bundle_update 0.0.1 → 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 +4 -4
- data/CHANGELOG.md +42 -0
- data/DECISIONS.md +129 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +232 -1
- data/bundle_update.gemspec +45 -0
- data/exe/bundle_update +7 -0
- data/lib/bundle_update/api_client.rb +142 -0
- data/lib/bundle_update/cli.rb +125 -0
- data/lib/bundle_update/config.rb +172 -0
- data/lib/bundle_update/diff.rb +103 -0
- data/lib/bundle_update/local_report.rb +124 -0
- data/lib/bundle_update/plugin.rb +126 -0
- data/lib/bundle_update/report_writer.rb +104 -0
- data/lib/bundle_update/runner.rb +120 -0
- data/lib/bundle_update/snapshot.rb +54 -0
- data/lib/bundle_update/version.rb +5 -0
- data/lib/bundle_update.rb +12 -2
- data/plugins.rb +17 -0
- metadata +35 -9
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module BundleUpdate
|
|
8
|
+
# Writes the rendered report to disk, choosing which markdown body to use
|
|
9
|
+
# based on the API response tier (rich/200, basic+link/202, local/nothing),
|
|
10
|
+
# and persists report_id + url to a small state file so `bundle_update
|
|
11
|
+
# report` can poll a still-generating report later.
|
|
12
|
+
class ReportWriter
|
|
13
|
+
Result = Struct.new(:path, :terminal_summary, :hosted_url, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# api_response is an ApiClient::Response, or nil on the free tier / when
|
|
20
|
+
# the API call itself failed open.
|
|
21
|
+
def write(diff:, project_name:, ran_at:, api_response: nil)
|
|
22
|
+
local_report = LocalReport.new(diff: diff, project_name: project_name, ran_at: ran_at)
|
|
23
|
+
markdown = markdown_for(local_report, api_response)
|
|
24
|
+
|
|
25
|
+
path = write_markdown(markdown, ran_at)
|
|
26
|
+
persist_state(api_response) if api_response&.report_id
|
|
27
|
+
|
|
28
|
+
Result.new(path: path, terminal_summary: local_report.terminal_summary, hosted_url: api_response&.url)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Writes an already-rendered markdown string directly, bypassing Diff
|
|
32
|
+
# rendering. Used by `bundle_update report` to persist a report fetched
|
|
33
|
+
# straight from the API.
|
|
34
|
+
def write_raw(markdown, ran_at: Time.now)
|
|
35
|
+
write_markdown(markdown, ran_at)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.read_state(config)
|
|
39
|
+
path = config.state_file_path
|
|
40
|
+
return nil unless File.exist?(path)
|
|
41
|
+
|
|
42
|
+
JSON.parse(File.read(path))
|
|
43
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def markdown_for(local_report, api_response)
|
|
50
|
+
case api_response&.status
|
|
51
|
+
when :ready
|
|
52
|
+
api_response.markdown || local_report.markdown
|
|
53
|
+
when :pending
|
|
54
|
+
pending_markdown(local_report.markdown, api_response.url)
|
|
55
|
+
else
|
|
56
|
+
local_report.markdown
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def pending_markdown(basic_markdown, hosted_url)
|
|
61
|
+
<<~MARKDOWN
|
|
62
|
+
#{basic_markdown}
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
**Full changelog report generating:** #{hosted_url}
|
|
66
|
+
|
|
67
|
+
Run `bundle_update report` to fetch the complete version once it's ready.
|
|
68
|
+
MARKDOWN
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def write_markdown(content, ran_at)
|
|
72
|
+
dir = File.join(@config.project_root, @config.reports_dir)
|
|
73
|
+
FileUtils.mkdir_p(dir)
|
|
74
|
+
path = unique_path(dir, ran_at.strftime("%Y%m%d%H%M%S"))
|
|
75
|
+
File.write(path, content)
|
|
76
|
+
path
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A zero-padded sequence (not "plain, then -2, -3...") so two runs within
|
|
80
|
+
# the same second — back-to-back in a script, or in tests — sort in the
|
|
81
|
+
# same order they were written, instead of a bare filename sorting ahead
|
|
82
|
+
# of its own "-2" suffix.
|
|
83
|
+
def unique_path(dir, timestamp)
|
|
84
|
+
seq = 1
|
|
85
|
+
loop do
|
|
86
|
+
path = File.join(dir, format("%<timestamp>s_%<seq>02d_report.md", timestamp: timestamp, seq: seq))
|
|
87
|
+
return path unless File.exist?(path)
|
|
88
|
+
|
|
89
|
+
seq += 1
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def persist_state(api_response)
|
|
94
|
+
path = @config.state_file_path
|
|
95
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
96
|
+
File.write(path, JSON.pretty_generate(
|
|
97
|
+
report_id: api_response.report_id,
|
|
98
|
+
url: api_response.url,
|
|
99
|
+
recorded_at: Time.now.utc.iso8601
|
|
100
|
+
))
|
|
101
|
+
FileUtils.chmod(0o600, path)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module BundleUpdate
|
|
7
|
+
# The single orchestration entry point both the CLI wrapper and the
|
|
8
|
+
# Bundler plugin hooks call. Owns the fail-open contract: nothing raised
|
|
9
|
+
# from here ever propagates into the caller's `bundle update` run.
|
|
10
|
+
class Runner
|
|
11
|
+
def initialize(config: Config.new)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# lockfile_before/lockfile_after are raw Gemfile.lock content, captured
|
|
16
|
+
# by the caller (CLI wrapper or plugin hook) around the bundler command.
|
|
17
|
+
def report(lockfile_before:, lockfile_after:, ran_at: Time.now)
|
|
18
|
+
return nil if @config.disabled?
|
|
19
|
+
|
|
20
|
+
diff = Diff.new(Snapshot.new(lockfile_before), Snapshot.new(lockfile_after))
|
|
21
|
+
return nil if diff.empty?
|
|
22
|
+
|
|
23
|
+
api_response = fetch_api_response(lockfile_before, lockfile_after, ran_at)
|
|
24
|
+
result = ReportWriter.new(@config).write(
|
|
25
|
+
diff: diff, project_name: @config.project_name, ran_at: ran_at, api_response: api_response
|
|
26
|
+
)
|
|
27
|
+
print_result(result, api_response)
|
|
28
|
+
result
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
# Failure in the local pipeline itself (parsing, rendering, disk I/O):
|
|
31
|
+
# print nothing, exit cleanly. The client's bundle run must never see this.
|
|
32
|
+
debug_log(e)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# `bundle_update report` — re-fetch the report for the most recently
|
|
37
|
+
# recorded run (e.g. one that returned 202 and is now ready).
|
|
38
|
+
def refetch_latest
|
|
39
|
+
unless @config.api_key?
|
|
40
|
+
warn "bundle_update: no API key configured. Run `bundle_update auth KEY` first."
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
state = ReportWriter.read_state(@config)
|
|
45
|
+
unless state && state["report_id"]
|
|
46
|
+
warn "bundle_update: no previous run found. Run `bundle update` first."
|
|
47
|
+
return nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
response = ApiClient.new(@config).fetch_report(state["report_id"])
|
|
51
|
+
handle_refetch_response(response)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
debug_log(e)
|
|
54
|
+
warn "bundle_update: could not fetch the report."
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def fetch_api_response(lockfile_before, lockfile_after, ran_at)
|
|
61
|
+
return nil unless @config.api_key?
|
|
62
|
+
|
|
63
|
+
response = ApiClient.new(@config).create_report(
|
|
64
|
+
lockfile_before: lockfile_before, lockfile_after: lockfile_after, ran_at: ran_at
|
|
65
|
+
)
|
|
66
|
+
warn_on_api_failure(response) if response.error?
|
|
67
|
+
response
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
debug_log(e)
|
|
70
|
+
warn_on_api_failure(nil)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def warn_on_api_failure(_response)
|
|
75
|
+
warn "bundle_update: couldn't reach bundleupdate.com — showing the local summary instead."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_refetch_response(response)
|
|
79
|
+
case response.status
|
|
80
|
+
when :ready
|
|
81
|
+
path = ReportWriter.new(@config).write_raw(response.markdown, ran_at: Time.now)
|
|
82
|
+
puts "Report written to #{path}"
|
|
83
|
+
puts response.url
|
|
84
|
+
response
|
|
85
|
+
when :pending
|
|
86
|
+
puts "Report is still generating: #{response.url}"
|
|
87
|
+
response
|
|
88
|
+
else
|
|
89
|
+
warn "bundle_update: could not fetch the report (#{response.error})."
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def print_result(result, api_response)
|
|
95
|
+
puts result.terminal_summary
|
|
96
|
+
case api_response&.status
|
|
97
|
+
when :ready
|
|
98
|
+
puts "Full report: #{api_response.url}" if api_response.url
|
|
99
|
+
when :pending
|
|
100
|
+
puts "Full report generating: #{api_response.url}"
|
|
101
|
+
puts "Run `bundle_update report` shortly to fetch it."
|
|
102
|
+
end
|
|
103
|
+
puts "Report written to #{result.path}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def debug_log(error)
|
|
107
|
+
return unless @config.debug?
|
|
108
|
+
|
|
109
|
+
path = @config.debug_log_path
|
|
110
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
111
|
+
File.open(path, "a") do |f|
|
|
112
|
+
f.puts "[#{Time.now.utc.iso8601}] #{error.class}: #{error.message}"
|
|
113
|
+
f.puts(error.backtrace || [])
|
|
114
|
+
f.puts "---"
|
|
115
|
+
end
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
|
|
5
|
+
module BundleUpdate
|
|
6
|
+
# A parsed Gemfile.lock at a point in time: raw content plus name => GemSpec.
|
|
7
|
+
class Snapshot
|
|
8
|
+
GemSpec = Struct.new(:name, :version, :source_type, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
attr_reader :raw, :specs
|
|
11
|
+
|
|
12
|
+
def self.from_file(path)
|
|
13
|
+
new(File.exist?(path) ? File.read(path) : "")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(raw)
|
|
17
|
+
@raw = raw.to_s
|
|
18
|
+
@specs = parse(@raw)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def [](name)
|
|
22
|
+
specs[name]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def gem_names
|
|
26
|
+
specs.keys
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def parse(raw)
|
|
32
|
+
return {} if raw.strip.empty?
|
|
33
|
+
|
|
34
|
+
Bundler::LockfileParser.new(raw).specs.to_h do |spec|
|
|
35
|
+
[spec.name, GemSpec.new(
|
|
36
|
+
name: spec.name,
|
|
37
|
+
version: spec.version&.to_s,
|
|
38
|
+
source_type: source_type_for(spec.source)
|
|
39
|
+
)]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def source_type_for(source)
|
|
44
|
+
case source
|
|
45
|
+
when Bundler::Source::Git
|
|
46
|
+
:git
|
|
47
|
+
when Bundler::Source::Path, Bundler::Source::Gemspec
|
|
48
|
+
:path
|
|
49
|
+
else
|
|
50
|
+
:gem
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/bundle_update.rb
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bundle_update/version"
|
|
4
|
+
require_relative "bundle_update/snapshot"
|
|
5
|
+
require_relative "bundle_update/diff"
|
|
6
|
+
require_relative "bundle_update/config"
|
|
7
|
+
require_relative "bundle_update/local_report"
|
|
8
|
+
require_relative "bundle_update/api_client"
|
|
9
|
+
require_relative "bundle_update/report_writer"
|
|
10
|
+
require_relative "bundle_update/runner"
|
|
11
|
+
|
|
2
12
|
module BundleUpdate
|
|
3
|
-
|
|
13
|
+
class Error < StandardError; end
|
|
4
14
|
end
|
data/plugins.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded by Bundler when this gem is installed as a plugin
|
|
4
|
+
# (`bundle plugin install bundle_update`). Wires the zero-effort mode: the
|
|
5
|
+
# reporting pipeline runs automatically after `bundle install`/`bundle
|
|
6
|
+
# update`, no CLI wrapper required.
|
|
7
|
+
#
|
|
8
|
+
# This must never be able to break the host's bundle install/update, so
|
|
9
|
+
# every failure mode here — a missing dependency, an incompatible Bundler
|
|
10
|
+
# plugin API, anything — is swallowed rather than raised.
|
|
11
|
+
begin
|
|
12
|
+
require "bundle_update"
|
|
13
|
+
require "bundle_update/plugin"
|
|
14
|
+
BundleUpdate::Plugin.install!
|
|
15
|
+
rescue StandardError
|
|
16
|
+
nil
|
|
17
|
+
end
|
metadata
CHANGED
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bundle_update
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Emanuel Comsa
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description:
|
|
13
|
-
|
|
12
|
+
description: |
|
|
13
|
+
bundle_update wraps `bundle update` / `bundle install`, diffs your Gemfile.lock
|
|
14
|
+
before and after, and prints a concise summary of what changed (major/minor/patch).
|
|
15
|
+
With a bundleupdate.com API key it also fetches a rich changelog report for every
|
|
16
|
+
updated gem. Works fully offline with no API key. Zero runtime dependencies.
|
|
14
17
|
email:
|
|
15
18
|
- office@rubydev.ro
|
|
16
|
-
executables:
|
|
19
|
+
executables:
|
|
20
|
+
- bundle_update
|
|
17
21
|
extensions: []
|
|
18
22
|
extra_rdoc_files: []
|
|
19
23
|
files:
|
|
24
|
+
- CHANGELOG.md
|
|
25
|
+
- DECISIONS.md
|
|
26
|
+
- Gemfile
|
|
27
|
+
- LICENSE.txt
|
|
20
28
|
- README.md
|
|
29
|
+
- bundle_update.gemspec
|
|
30
|
+
- exe/bundle_update
|
|
21
31
|
- lib/bundle_update.rb
|
|
22
|
-
|
|
32
|
+
- lib/bundle_update/api_client.rb
|
|
33
|
+
- lib/bundle_update/cli.rb
|
|
34
|
+
- lib/bundle_update/config.rb
|
|
35
|
+
- lib/bundle_update/diff.rb
|
|
36
|
+
- lib/bundle_update/local_report.rb
|
|
37
|
+
- lib/bundle_update/plugin.rb
|
|
38
|
+
- lib/bundle_update/report_writer.rb
|
|
39
|
+
- lib/bundle_update/runner.rb
|
|
40
|
+
- lib/bundle_update/snapshot.rb
|
|
41
|
+
- lib/bundle_update/version.rb
|
|
42
|
+
- plugins.rb
|
|
43
|
+
homepage: https://www.bundleupdate.com
|
|
23
44
|
licenses:
|
|
24
45
|
- MIT
|
|
25
46
|
metadata:
|
|
26
|
-
homepage_uri: https://www.
|
|
47
|
+
homepage_uri: https://www.bundleupdate.com
|
|
48
|
+
source_code_uri: https://github.com/deadbro-com/bundle_update
|
|
49
|
+
changelog_uri: https://github.com/deadbro-com/bundle_update/blob/main/CHANGELOG.md
|
|
50
|
+
bug_tracker_uri: https://github.com/deadbro-com/bundle_update/issues
|
|
51
|
+
rubygems_mfa_required: 'true'
|
|
27
52
|
rdoc_options: []
|
|
28
53
|
require_paths:
|
|
29
54
|
- lib
|
|
@@ -31,7 +56,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
31
56
|
requirements:
|
|
32
57
|
- - ">="
|
|
33
58
|
- !ruby/object:Gem::Version
|
|
34
|
-
version:
|
|
59
|
+
version: 3.0.0
|
|
35
60
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
36
61
|
requirements:
|
|
37
62
|
- - ">="
|
|
@@ -40,5 +65,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
40
65
|
requirements: []
|
|
41
66
|
rubygems_version: 4.0.10
|
|
42
67
|
specification_version: 4
|
|
43
|
-
summary:
|
|
68
|
+
summary: Reports what changed after `bundle update` — free local summaries, optional
|
|
69
|
+
hosted changelog reports.
|
|
44
70
|
test_files: []
|