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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdate
4
+ VERSION = "0.1.0"
5
+ end
data/lib/bundle_update.rb CHANGED
@@ -1,4 +1,14 @@
1
- # Placeholder gem. Name reserved; no functionality.
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
- VERSION = "0.0.1"
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.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: This gem is a placeholder to reserve the bundle_update name. It has no
13
- functionality.
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
- homepage: https://www.deadbro.com
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.deadbro.com
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: 2.7.0
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: Placeholder gem. Name reserved.
68
+ summary: Reports what changed after `bundle update` — free local summaries, optional
69
+ hosted changelog reports.
44
70
  test_files: []