dbwatcher 0.1.5

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.
data/bin/release ADDED
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "time"
6
+ require_relative "../lib/dbwatcher/version"
7
+
8
+ # Module for handling changelog generation from git commits
9
+ # rubocop:disable Metrics/ModuleLength
10
+ module ChangelogGenerator
11
+ # Git commit format for parsing
12
+ COMMIT_FORMAT = "%H|%s|%an|%ad"
13
+
14
+ # Commit type categorization for changelog generation
15
+ COMMIT_CATEGORIES = {
16
+ "feat" => "Added",
17
+ "feature" => "Added",
18
+ "add" => "Added",
19
+ "fix" => "Fixed",
20
+ "bugfix" => "Fixed",
21
+ "bug" => "Fixed",
22
+ "change" => "Changed",
23
+ "update" => "Changed",
24
+ "improve" => "Changed",
25
+ "refactor" => "Changed",
26
+ "perf" => "Changed",
27
+ "remove" => "Removed",
28
+ "delete" => "Removed",
29
+ "deprecate" => "Deprecated",
30
+ "security" => "Security",
31
+ "docs" => "Documentation",
32
+ "doc" => "Documentation",
33
+ "test" => "Testing",
34
+ "tests" => "Testing",
35
+ "ci" => "CI/CD",
36
+ "build" => "Build",
37
+ "chore" => "Maintenance"
38
+ }.freeze
39
+
40
+ # Order of sections in generated changelog
41
+ CHANGELOG_SECTION_ORDER = [
42
+ "Added", "Changed", "Fixed", "Deprecated", "Removed",
43
+ "Security", "Documentation", "Testing", "CI/CD", "Build", "Maintenance"
44
+ ].freeze
45
+
46
+ def generate_auto_changelog_entries
47
+ commits = fetch_commits_for_changelog
48
+
49
+ return "\n### Changes\n\n- No significant changes\n" if commits.empty?
50
+
51
+ categorized_commits = categorize_commits(commits)
52
+ generate_changelog_sections(categorized_commits)
53
+ end
54
+
55
+ private
56
+
57
+ def fetch_commits_for_changelog
58
+ last_tag = get_last_release_tag
59
+
60
+ if last_tag
61
+ puts "📊 Analyzing commits since #{last_tag}..."
62
+ get_commits_since_tag(last_tag)
63
+ else
64
+ puts "📊 No previous release found, analyzing all commits..."
65
+ get_all_commits
66
+ end
67
+ end
68
+
69
+ def get_last_release_tag
70
+ tags = `git tag -l "v*" --sort=-version:refname`.strip.split("\n")
71
+ tags.first
72
+ end
73
+
74
+ def get_commits_since_tag(tag)
75
+ commits_output = `git log #{tag}..HEAD --pretty=format:"#{COMMIT_FORMAT}" --no-merges --date=short`.strip
76
+ parse_commit_output(commits_output)
77
+ end
78
+
79
+ def get_all_commits
80
+ commits_output = `git log --pretty=format:"#{COMMIT_FORMAT}" --no-merges --date=short`.strip
81
+ parse_commit_output(commits_output)
82
+ end
83
+
84
+ def parse_commit_output(commits_output)
85
+ return [] if commits_output.empty?
86
+
87
+ commits_output.split("\n").map do |line|
88
+ hash, subject, author, date = line.split("|", 4)
89
+ {
90
+ hash: hash,
91
+ subject: subject,
92
+ author: author,
93
+ date: date
94
+ }
95
+ end
96
+ end
97
+
98
+ def categorize_commits(commits)
99
+ categories = Hash.new { |hash, key| hash[key] = [] }
100
+
101
+ commits.each do |commit|
102
+ category = determine_commit_category(commit[:subject])
103
+ clean_message = clean_commit_message(commit[:subject])
104
+
105
+ categories[category] << {
106
+ message: clean_message,
107
+ hash: commit[:hash][0..7], # Short hash
108
+ author: commit[:author]
109
+ }
110
+ end
111
+
112
+ categories
113
+ end
114
+
115
+ def determine_commit_category(subject)
116
+ subject_lower = subject.downcase
117
+
118
+ # Check for conventional commit format (type: description)
119
+ if subject_lower.match(/^(\w+)(\(.+\))?\s*:\s*/)
120
+ commit_type = ::Regexp.last_match(1).downcase
121
+ return COMMIT_CATEGORIES[commit_type] || "Changed"
122
+ end
123
+
124
+ # Check for keywords in the commit message
125
+ COMMIT_CATEGORIES.each do |keyword, category|
126
+ return category if subject_lower.include?(keyword)
127
+ end
128
+
129
+ "Changed" # Default category
130
+ end
131
+
132
+ def clean_commit_message(message)
133
+ # Remove conventional commit prefixes
134
+ cleaned = message.gsub(/^(\w+)(\(.+\))?\s*:\s*/, "")
135
+
136
+ return message if cleaned.empty? # Fallback to original if cleaning removes everything
137
+
138
+ # Capitalize first letter and remove trailing periods
139
+ cleaned = capitalize_first_letter(cleaned)
140
+ cleaned.chomp(".")
141
+ end
142
+
143
+ def capitalize_first_letter(text)
144
+ return text if text.empty?
145
+
146
+ text[0].upcase + text[1..]
147
+ end
148
+
149
+ def generate_changelog_sections(categorized_commits)
150
+ sections = build_changelog_sections(categorized_commits)
151
+
152
+ if sections.empty?
153
+ default_changelog_section
154
+ else
155
+ sections.join("\n")
156
+ end
157
+ end
158
+
159
+ def build_changelog_sections(categorized_commits)
160
+ sections = []
161
+
162
+ CHANGELOG_SECTION_ORDER.each do |category|
163
+ commits = categorized_commits[category]
164
+ next unless commits&.any?
165
+
166
+ sections << "\n### #{category}\n"
167
+ commits.each { |commit| sections << "- #{commit[:message]}" }
168
+ sections << ""
169
+ end
170
+
171
+ sections
172
+ end
173
+
174
+ def default_changelog_section
175
+ "\n### Changes\n- Minor improvements and updates\n"
176
+ end
177
+ end
178
+ # rubocop:enable Metrics/ModuleLength
179
+
180
+ # Manages the release process for DB Watcher including version bumping,
181
+ # changelog generation, and git tagging.
182
+ class ReleaseManager
183
+ include ChangelogGenerator
184
+
185
+ VALID_TYPES = %w[major minor patch].freeze
186
+ VERSION_FILE = "lib/dbwatcher/version.rb"
187
+ CHANGELOG_FILE = "CHANGELOG.md"
188
+
189
+ def initialize
190
+ @options = default_options
191
+ end
192
+
193
+ def run(args)
194
+ parse_options(args)
195
+ display_dry_run_warning if @options[:dry_run]
196
+
197
+ current_version = Dbwatcher::VERSION
198
+ new_version = calculate_new_version(current_version, @options[:type])
199
+
200
+ display_version_info(current_version, new_version)
201
+
202
+ unless @options[:dry_run]
203
+ update_version_file(new_version)
204
+ update_changelog(new_version)
205
+ handle_git_operations(new_version) if @options[:push]
206
+ end
207
+
208
+ display_completion_message(new_version)
209
+ end
210
+
211
+ private
212
+
213
+ def default_options
214
+ {
215
+ type: "patch",
216
+ push: false,
217
+ dry_run: false,
218
+ auto_changelog: true
219
+ }
220
+ end
221
+
222
+ def display_dry_run_warning
223
+ puts "🧪 DRY RUN MODE - No changes will be made"
224
+ end
225
+
226
+ def display_version_info(current_version, new_version)
227
+ puts "📦 Current version: #{current_version}"
228
+ puts "🚀 New version: #{new_version}"
229
+ end
230
+
231
+ def display_completion_message(new_version)
232
+ puts "\n✅ Release preparation complete!"
233
+
234
+ if @options[:auto_changelog]
235
+ display_auto_changelog_next_steps(new_version)
236
+ else
237
+ display_manual_changelog_next_steps(new_version)
238
+ end
239
+ end
240
+
241
+ def display_auto_changelog_next_steps(new_version)
242
+ puts "\nNext steps:"
243
+ puts "1. Review and edit #{CHANGELOG_FILE} (auto-generated entries added)"
244
+ puts "2. Commit and push changes"
245
+ puts "3. Create and push tag (v#{new_version})"
246
+ puts "4. GitHub Actions will automatically publish to RubyGems"
247
+ end
248
+
249
+ def display_manual_changelog_next_steps(new_version)
250
+ puts "\nNext steps:"
251
+ puts "1. Add changelog entries to #{CHANGELOG_FILE}"
252
+ puts "2. Commit and push changes"
253
+ puts "3. Create and push tag (v#{new_version})"
254
+ puts "4. GitHub Actions will automatically publish to RubyGems"
255
+ end
256
+
257
+ def handle_git_operations(new_version)
258
+ create_and_push_tag(new_version)
259
+ rescue StandardError => e
260
+ puts "❌ Error during git operations: #{e.message}"
261
+ exit 1
262
+ end
263
+
264
+ def parse_options(args)
265
+ OptionParser.new do |opts|
266
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
267
+
268
+ opts.on("-t", "--type TYPE", VALID_TYPES,
269
+ "Release type: #{VALID_TYPES.join(", ")} (default: patch)") do |type|
270
+ @options[:type] = type
271
+ end
272
+
273
+ opts.on("-p", "--push", "Automatically commit, tag, and push") do
274
+ @options[:push] = true
275
+ end
276
+
277
+ opts.on("-n", "--dry-run", "Show what would be done without making changes") do
278
+ @options[:dry_run] = true
279
+ end
280
+
281
+ opts.on("--[no-]auto-changelog", "Auto-generate changelog from git commits (default: true)") do |auto|
282
+ @options[:auto_changelog] = auto
283
+ end
284
+
285
+ opts.on("-h", "--help", "Show this help") do
286
+ puts opts
287
+ exit
288
+ end
289
+ end.parse!(args)
290
+ end
291
+
292
+ def calculate_new_version(version, type)
293
+ major, minor, patch = version.split(".").map(&:to_i)
294
+
295
+ case type
296
+ when "major"
297
+ "#{major + 1}.0.0"
298
+ when "minor"
299
+ "#{major}.#{minor + 1}.0"
300
+ when "patch"
301
+ "#{major}.#{minor}.#{patch + 1}"
302
+ end
303
+ end
304
+
305
+ def update_version_file(new_version)
306
+ content = File.read(VERSION_FILE)
307
+ updated_content = content.gsub(/VERSION = "[^"]*"/, "VERSION = \"#{new_version}\"")
308
+
309
+ File.write(VERSION_FILE, updated_content)
310
+ puts "📝 Updated #{VERSION_FILE}"
311
+ rescue StandardError => e
312
+ puts "❌ Error updating version file: #{e.message}"
313
+ exit 1
314
+ end
315
+
316
+ def update_changelog(new_version)
317
+ return unless File.exist?(CHANGELOG_FILE)
318
+
319
+ puts "\n📋 Generating changelog from git commits..." if @options[:auto_changelog]
320
+
321
+ content = File.read(CHANGELOG_FILE)
322
+ changelog_entries = generate_changelog_content
323
+ updated_content = insert_changelog_entries(content, new_version, changelog_entries)
324
+
325
+ File.write(CHANGELOG_FILE, updated_content)
326
+ puts "📝 Updated #{CHANGELOG_FILE}"
327
+
328
+ display_changelog_success_message if @options[:auto_changelog] && !@options[:dry_run]
329
+ rescue StandardError => e
330
+ puts "❌ Error updating changelog: #{e.message}"
331
+ exit 1
332
+ end
333
+
334
+ def generate_changelog_content
335
+ if @options[:auto_changelog]
336
+ generate_auto_changelog_entries
337
+ else
338
+ "\n### Changes\n\n- TODO: Add changelog entries\n"
339
+ end
340
+ end
341
+
342
+ def insert_changelog_entries(content, new_version, changelog_entries)
343
+ today = Time.now.strftime("%Y-%m-%d")
344
+
345
+ content.gsub(
346
+ "## [Unreleased]",
347
+ "## [Unreleased]\n\n## [#{new_version}] - #{today}#{changelog_entries}"
348
+ )
349
+ end
350
+
351
+ def display_changelog_success_message
352
+ puts "📋 Generated changelog entries from git commits"
353
+ puts " Please review and edit the changelog before committing"
354
+ end
355
+
356
+ def create_and_push_tag(new_version)
357
+ return display_manual_git_instructions(new_version) unless @options[:push]
358
+
359
+ puts "\n🏷️ Creating and pushing tag..."
360
+ execute_git_commands(new_version)
361
+ puts "✅ Tag v#{new_version} created and pushed"
362
+ end
363
+
364
+ def display_manual_git_instructions(new_version)
365
+ puts "\n🏷️ To create and push the release tag, run:"
366
+ puts " git add -A && git commit -m 'Release v#{new_version}' && git tag v#{new_version} && " \
367
+ "git push origin master && git push origin v#{new_version}"
368
+ end
369
+
370
+ def execute_git_commands(new_version)
371
+ commands = [
372
+ "git add -A",
373
+ "git commit -m 'Release v#{new_version}'",
374
+ "git tag v#{new_version}",
375
+ "git push origin master",
376
+ "git push origin v#{new_version}"
377
+ ]
378
+
379
+ commands.each do |command|
380
+ raise "Failed to execute: #{command}" unless system(command)
381
+ end
382
+ end
383
+ end
384
+
385
+ ReleaseManager.new.run(ARGV) if __FILE__ == $PROGRAM_NAME
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dbwatcher::Engine.routes.draw do
4
+ resources :sessions, only: %i[index show] do
5
+ collection do
6
+ delete :destroy_all
7
+ end
8
+ end
9
+ root to: "sessions#index"
10
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ class Configuration
5
+ attr_accessor :storage_path, :enabled, :max_sessions, :auto_clean_after_days
6
+
7
+ def initialize
8
+ @storage_path = default_storage_path
9
+ @enabled = true
10
+ @max_sessions = 100
11
+ @auto_clean_after_days = 7
12
+ end
13
+
14
+ private
15
+
16
+ def default_storage_path
17
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
18
+ Rails.root.join("tmp", "dbwatcher").to_s
19
+ else
20
+ File.join(Dir.pwd, "tmp", "dbwatcher")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Dbwatcher
6
+
7
+ initializer "dbwatcher.setup" do |app|
8
+ # Auto-include in all models
9
+ ActiveSupport.on_load(:active_record) do
10
+ include Dbwatcher::ModelExtension
11
+ end
12
+
13
+ # Add middleware
14
+ app.middleware.use Dbwatcher::Middleware
15
+ end
16
+
17
+ # Mount the engine routes automatically
18
+ initializer "dbwatcher.routes", after: :add_routing_paths do |app|
19
+ app.routes.append do
20
+ mount Dbwatcher::Engine => "/dbwatcher", as: :dbwatcher
21
+ end
22
+ end
23
+
24
+ # Serve static assets
25
+ initializer "dbwatcher.assets" do |app|
26
+ app.config.assets.paths << root.join("app", "assets", "stylesheets")
27
+ app.config.assets.paths << root.join("app", "assets", "javascripts")
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ class Middleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ if should_track?(env)
11
+ Dbwatcher.track(
12
+ name: "HTTP #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}",
13
+ metadata: build_metadata(env)
14
+ ) do
15
+ @app.call(env)
16
+ end
17
+ else
18
+ @app.call(env)
19
+ end
20
+ rescue StandardError => e
21
+ warn "Dbwatcher middleware error: #{e.message}"
22
+ @app.call(env)
23
+ end
24
+
25
+ private
26
+
27
+ def should_track?(env)
28
+ env["QUERY_STRING"]&.include?("dbwatch=true") || false
29
+ end
30
+
31
+ def build_metadata(env)
32
+ {
33
+ ip: env["REMOTE_ADDR"],
34
+ user_agent: env["HTTP_USER_AGENT"],
35
+ path: env["PATH_INFO"],
36
+ method: env["REQUEST_METHOD"]
37
+ }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module ModelExtension
5
+ def self.included(base)
6
+ extend ActiveSupport::Concern if defined?(ActiveSupport)
7
+
8
+ base.class_eval do
9
+ after_create :dbwatcher_track_create if respond_to?(:after_create)
10
+ after_update :dbwatcher_track_update if respond_to?(:after_update)
11
+ after_destroy :dbwatcher_track_destroy if respond_to?(:after_destroy)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def dbwatcher_track_create
18
+ track_database_change(
19
+ operation: "INSERT",
20
+ changes: attributes.map { |column, value| build_change(column, nil, value) }
21
+ )
22
+ end
23
+
24
+ def dbwatcher_track_update
25
+ return unless saved_changes.any?
26
+
27
+ track_database_change(
28
+ operation: "UPDATE",
29
+ changes: saved_changes.except("updated_at").map do |column, (old_val, new_val)|
30
+ build_change(column, old_val, new_val)
31
+ end
32
+ )
33
+ end
34
+
35
+ def dbwatcher_track_destroy
36
+ track_database_change(
37
+ operation: "DELETE",
38
+ changes: attributes.map { |column, value| build_change(column, value, nil) }
39
+ )
40
+ end
41
+
42
+ def track_database_change(operation:, changes:)
43
+ Dbwatcher::Tracker.record_change({
44
+ table_name: self.class.table_name,
45
+ record_id: id,
46
+ operation: operation,
47
+ timestamp: Time.now.strftime("%Y-%m-%dT%H:%M:%S%z"),
48
+ changes: changes,
49
+ record_snapshot: attributes
50
+ })
51
+ end
52
+
53
+ def build_change(column, old_value, new_value)
54
+ {
55
+ column: column,
56
+ old_value: old_value&.to_s,
57
+ new_value: new_value&.to_s
58
+ }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ class Storage
5
+ class << self
6
+ def save_session(session)
7
+ return unless session&.id
8
+
9
+ ensure_storage_directory
10
+
11
+ # Save individual session file
12
+ session_file = File.join(sessions_path, "#{session.id}.json")
13
+ File.write(session_file, JSON.pretty_generate(session.to_h))
14
+
15
+ # Update index
16
+ update_index(session)
17
+
18
+ # Clean old sessions if needed
19
+ cleanup_old_sessions
20
+ rescue StandardError => e
21
+ warn "Failed to save session #{session&.id}: #{e.message}"
22
+ end
23
+
24
+ def load_session(id)
25
+ return nil if id.nil? || id.empty?
26
+
27
+ session_file = File.join(sessions_path, "#{id}.json")
28
+ return nil unless File.exist?(session_file)
29
+
30
+ data = JSON.parse(File.read(session_file), symbolize_names: true)
31
+ Tracker::Session.new(data)
32
+ rescue JSON::ParserError => e
33
+ warn "Failed to parse session file #{id}: #{e.message}"
34
+ nil
35
+ rescue StandardError => e
36
+ warn "Failed to load session #{id}: #{e.message}"
37
+ nil
38
+ end
39
+
40
+ def all_sessions
41
+ index_file = File.join(storage_path, "index.json")
42
+ return [] unless File.exist?(index_file)
43
+
44
+ JSON.parse(File.read(index_file), symbolize_names: true)
45
+ rescue JSON::ParserError => e
46
+ warn "Failed to parse sessions index: #{e.message}"
47
+ []
48
+ rescue StandardError => e
49
+ warn "Failed to load sessions: #{e.message}"
50
+ []
51
+ end
52
+
53
+ def reset!
54
+ FileUtils.rm_rf(storage_path)
55
+ ensure_storage_directory
56
+ end
57
+
58
+ private
59
+
60
+ def storage_path
61
+ Dbwatcher.configuration.storage_path
62
+ end
63
+
64
+ def sessions_path
65
+ File.join(storage_path, "sessions")
66
+ end
67
+
68
+ def ensure_storage_directory
69
+ FileUtils.mkdir_p(sessions_path)
70
+
71
+ # Create index if it doesn't exist
72
+ index_file = File.join(storage_path, "index.json")
73
+ File.write(index_file, "[]") unless File.exist?(index_file)
74
+ end
75
+
76
+ def update_index(session)
77
+ index_file = File.join(storage_path, "index.json")
78
+ index = JSON.parse(File.read(index_file), symbolize_names: true)
79
+
80
+ # Add new session summary to index
81
+ index.unshift({
82
+ id: session.id,
83
+ name: session.name,
84
+ started_at: session.started_at,
85
+ ended_at: session.ended_at,
86
+ change_count: session.changes.count
87
+ })
88
+
89
+ # Keep only max_sessions
90
+ index = index.first(Dbwatcher.configuration.max_sessions)
91
+
92
+ File.write(index_file, JSON.pretty_generate(index))
93
+ rescue StandardError => e
94
+ warn "Failed to update sessions index: #{e.message}"
95
+ end
96
+
97
+ def cleanup_old_sessions
98
+ return unless Dbwatcher.configuration.auto_clean_after_days
99
+
100
+ cutoff_date = Time.now - (Dbwatcher.configuration.auto_clean_after_days * 24 * 60 * 60)
101
+
102
+ Dir.glob(File.join(sessions_path, "*.json")).each do |file|
103
+ File.delete(file) if File.mtime(file) < cutoff_date
104
+ end
105
+ rescue StandardError => e
106
+ warn "Failed to cleanup old sessions: #{e.message}"
107
+ end
108
+ end
109
+ end
110
+ end