bruh 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: 61ba8a98c7f510ce257e743caf74457ad517b7bdaa6ac3b82e7fc2422efdcc0a
4
+ data.tar.gz: c5cab658a2748857fb15db2cefb89d68410085e0ecba91e8c116022bbab01805
5
+ SHA512:
6
+ metadata.gz: 9f4b12f569e004394f5c295ce42228e296891c7dce96b5b02c7366888321bac81b3c472235eb6ff05512019b099280fcdba2e51ab067be39cf6b724170f397a8
7
+ data.tar.gz: ee4004de98bb5799343aa5ea51ac17b405a4a7609d34fd1d3fed28474303f02de7f60de211503420505073f07f48f8593ae29438172afe07a7caeddeda8b858c
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2025-04-05
6
+
7
+ Initial release of Bruh.
8
+
9
+ ### Added
10
+ - Cabal file parsing and version management
11
+ - Changelog handling for release notes
12
+ - Hackage publishing support with documentation
13
+ - Homebrew formula generation and updates
14
+ - Bottle building and publishing to GitHub
15
+ - Configuration management with TOML
16
+ - Interactive and non-interactive modes
17
+ - CLI interface with Thor
18
+ - Full test coverage
19
+
20
+ ### Features
21
+ - HackagePublisher for authentication and package uploads
22
+ - HomebrewFormula for template-based formula management
23
+ - BottleBuilder for building and uploading bottles
24
+ - CLI interface with Thor
25
+ - ConfigManager for storing settings in TOML
26
+ - ChangelogHandler for release notes
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Fuzz Leonard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # Bruh
2
+
3
+ Bruh is a Ruby gem that automates the release process for Haskell packages to
4
+ both Hackage and Homebrew. It streamlines the workflow of releasing Haskell
5
+ packages by handling version updates, changelog management, Hackage publishing,
6
+ and Homebrew formula updates.
7
+
8
+ ## Features
9
+
10
+ - **Cabal Integration**: Parse and update Cabal files with version bumps
11
+ - **Changelog Management**: Auto-update CHANGELOG.md with new version entries
12
+ - **Hackage Publishing**: Publish packages to Hackage with documentation
13
+ - **Homebrew Formula Generation**: Update Homebrew formulas with new versions and SHAs
14
+ - **Bottle Building**: Build and publish Homebrew bottles to GitHub
15
+ - **Non-interactive Mode**: Support for CI/CD pipelines and automated testing
16
+
17
+ ## Installation
18
+
19
+ Install the gem by executing:
20
+
21
+ ```bash
22
+ gem install bruh
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Command Line Interface
28
+
29
+ The primary interface is through the `bruh` command:
30
+
31
+ ```bash
32
+ # Interactive release process
33
+ bruh release
34
+
35
+ # Non-interactive release with specific version
36
+ bruh release --non-interactive --version 0.1.2
37
+
38
+ # Skip specific steps
39
+ bruh release --skip-hackage --skip-bottles
40
+
41
+ # Show version
42
+ bruh version
43
+ ```
44
+
45
+ ### Non-interactive Release Script
46
+
47
+ For CI/CD pipelines, you can use the included release script:
48
+
49
+ ```bash
50
+ # Basic usage
51
+ bin/release --version 0.1.2
52
+
53
+ # Skip specific steps
54
+ bin/release --version 0.1.2 --skip-hackage --skip-bottles
55
+
56
+ # Run in interactive mode
57
+ bin/release --interactive
58
+ ```
59
+
60
+ ### Configuration
61
+
62
+ Bruh stores configuration in `~/.config/bruh/config.toml`. For non-interactive usage, you should set up your Hackage credentials:
63
+
64
+ ```bash
65
+ # Setup credentials
66
+ bruh config setup
67
+
68
+ # Manually set credentials
69
+ bruh config set hackage_username "your-username"
70
+ bruh config set hackage_password "your-password"
71
+ bruh config set github_token "your-token"
72
+ ```
73
+
74
+ ## Development
75
+
76
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt.
77
+
78
+ ### Running Tests
79
+
80
+ ```bash
81
+ # Run all tests
82
+ bundle exec rake test
83
+
84
+ # Run a specific test
85
+ bundle exec ruby -I lib:test test/test_cabal.rb -n test_name
86
+ ```
87
+
88
+ ## Contributing
89
+
90
+ 1. Fork the repository
91
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
92
+ 3. Commit your changes (`git commit -am 'Add amazing feature'`)
93
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
94
+ 5. Create a new Pull Request
95
+
96
+ ## License
97
+
98
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/haskbrew ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bruh'
5
+ require 'bruh/cli'
6
+
7
+ Bruh::CLI.start(ARGV)
@@ -0,0 +1,209 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'sorbet-runtime'
7
+
8
+ module Bruh
9
+ # Builds and manages Homebrew bottles
10
+ class Bottle
11
+ extend T::Sig
12
+
13
+ sig { params(interactive: T::Boolean).void }
14
+ def initialize(interactive: true)
15
+ @interactive = interactive
16
+ @tap_dir = T.let(find_homebrew_tap, T.nilable(String))
17
+ end
18
+
19
+ sig { params(version: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
20
+ def build(version)
21
+ return nil unless @tap_dir
22
+
23
+ return nil if @interactive && !yes_no_prompt('Do you want to build a Homebrew bottle for this release?')
24
+
25
+ puts "Building bottle for #{package_name} formula..."
26
+
27
+ Dir.chdir(@tap_dir) do
28
+ # Clean environment - uninstall any existing version
29
+ system("brew uninstall --force #{package_name} 2>/dev/null || true")
30
+
31
+ # Ensure tap is properly set up
32
+ tap_name = "#{repo_owner}/tap"
33
+ system("brew untap #{tap_name} 2>/dev/null || true")
34
+ system("brew tap #{tap_name} \"$(pwd)\"")
35
+
36
+ # Install with build-bottle flag
37
+ unless system("brew install --build-bottle #{tap_name}/#{package_name}")
38
+ puts 'Failed to install formula with --build-bottle flag'
39
+ return nil
40
+ end
41
+
42
+ # Create bottle with JSON output
43
+ bottle_cmd = 'brew bottle --json '
44
+ bottle_cmd += "--root-url=\"https://github.com/#{repo_owner}/#{repo_name}/releases/download/v#{version}\" "
45
+ bottle_cmd += "#{tap_name}/#{package_name}"
46
+ unless system(bottle_cmd)
47
+ puts 'Failed to create bottle'
48
+ return nil
49
+ end
50
+
51
+ # Find the JSON file created by brew bottle
52
+ bottle_json = Dir['*.json'].max_by { |f| File.mtime(f) }
53
+
54
+ unless bottle_json && File.exist?(bottle_json)
55
+ puts 'Could not find bottle JSON file'
56
+ return nil
57
+ end
58
+
59
+ # Parse the JSON file
60
+ bottle_data = JSON.parse(File.read(bottle_json))
61
+
62
+ # Extract filenames and other data
63
+ first_entry = bottle_data.values.first
64
+ first_tag = first_entry['bottle']['tags'].values.first
65
+
66
+ bottle_info = {
67
+ version: version,
68
+ expected_filename: first_tag['filename'],
69
+ local_filename: first_tag['local_filename'],
70
+ sha256: first_tag['sha256'],
71
+ macos_version: first_entry['bottle']['tags'].keys.first.to_s.split('_')[1]
72
+ }
73
+
74
+ # Extract rebuild number from filename if present
75
+ bottle_info[:rebuild] = ::Regexp.last_match(1).to_i if bottle_info[:local_filename] =~ /bottle\.(\d+)/
76
+
77
+ # Verify the bottle file exists
78
+ unless File.exist?(bottle_info[:local_filename])
79
+ puts "Bottle file not found: #{bottle_info[:local_filename]}"
80
+ return nil
81
+ end
82
+
83
+ # Create bottles directory and move the file
84
+ FileUtils.mkdir_p('bottles')
85
+ FileUtils.mv(bottle_info[:local_filename], 'bottles/')
86
+
87
+ puts "Bottle created successfully: bottles/#{bottle_info[:local_filename]}"
88
+ bottle_info
89
+ end
90
+ end
91
+
92
+ sig { params(version: String, bottle_info: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
93
+ def upload_to_github(version, bottle_info)
94
+ # Check for GitHub CLI
95
+ unless system('which gh > /dev/null 2>&1')
96
+ puts 'GitHub CLI (gh) not found. Please install it to automate bottle uploads.'
97
+ return false
98
+ end
99
+
100
+ return false if @interactive && !yes_no_prompt('Do you want to upload bottle to GitHub?')
101
+
102
+ # Remember current directory
103
+ original_dir = Dir.pwd
104
+
105
+ # Change to the tap directory
106
+ Dir.chdir(T.must(@tap_dir)) do
107
+ # Check if release exists
108
+ release_exists = system("gh release view \"v#{version}\" &>/dev/null")
109
+
110
+ unless release_exists
111
+ puts "Release v#{version} doesn't exist. Creating it now..."
112
+ release_cmd = "gh release create \"v#{version}\" --title \"Release v#{version}\" "
113
+ release_cmd += "--notes \"Release v#{version} with Homebrew bottle support.\""
114
+ unless system(release_cmd)
115
+ puts 'Failed to create GitHub release'
116
+ return false
117
+ end
118
+ end
119
+
120
+ # Rename the bottle file to expected filename
121
+ bottle_src = File.join('bottles', bottle_info[:local_filename])
122
+ bottle_dst = File.join('bottles', bottle_info[:expected_filename])
123
+
124
+ FileUtils.mv(bottle_src, bottle_dst) unless bottle_src == bottle_dst
125
+
126
+ # Upload to GitHub release
127
+ puts "Uploading bottle to GitHub release v#{version}..."
128
+ unless system("gh release upload \"v#{version}\" \"#{bottle_dst}\" --clobber")
129
+ puts 'Failed to upload bottle to GitHub'
130
+ Dir.chdir(original_dir)
131
+ return false
132
+ end
133
+
134
+ puts 'Bottle uploaded successfully to GitHub release!'
135
+ end
136
+
137
+ # Change back to original directory
138
+ Dir.chdir(original_dir)
139
+ true
140
+ end
141
+
142
+ private
143
+
144
+ sig { returns(T.nilable(String)) }
145
+ def find_homebrew_tap
146
+ # Try common locations
147
+ potential_paths = [
148
+ '../homebrew-tap',
149
+ '~/homebrew-tap',
150
+ '~/Projects/homebrew-tap'
151
+ ]
152
+
153
+ potential_paths.each do |path|
154
+ expanded = File.expand_path(path)
155
+ return expanded if Dir.exist?(expanded)
156
+ end
157
+
158
+ if @interactive
159
+ puts 'Homebrew tap directory not found in common locations.'
160
+ puts 'Please enter the path to your Homebrew tap directory:'
161
+ tap_dir = gets.chomp.strip
162
+ return tap_dir if !tap_dir.empty? && Dir.exist?(tap_dir)
163
+ end
164
+
165
+ nil
166
+ end
167
+
168
+ sig { returns(String) }
169
+ def package_name
170
+ # Extract package name from current directory
171
+ File.basename(Dir.pwd)
172
+ end
173
+
174
+ sig { returns(T::Array[String]) }
175
+ def repo_info
176
+ # Extract owner and repo from git remote
177
+ remote = `git remote get-url origin`.chomp
178
+ if remote =~ %r{github\.com[:/]([^/]+)/([^/]+)\.git}
179
+ owner = T.let(T.must(::Regexp.last_match(1)), String)
180
+ repo = T.let(T.must(::Regexp.last_match(2)), String)
181
+ return [owner, repo]
182
+ end
183
+
184
+ %w[user repo]
185
+ end
186
+
187
+ sig { returns(String) }
188
+ def repo_owner
189
+ T.must(repo_info[0])
190
+ end
191
+
192
+ sig { returns(String) }
193
+ def repo_name
194
+ T.must(repo_info[1])
195
+ end
196
+
197
+ sig { params(message: String, default_no: T::Boolean).returns(T::Boolean) }
198
+ def yes_no_prompt(message, default_no: true)
199
+ return true unless @interactive
200
+
201
+ default = default_no ? '[y/N]' : '[Y/n]'
202
+ print "#{message} #{default} "
203
+ response = gets.chomp.downcase
204
+ return response.start_with?('y') if default_no
205
+
206
+ !response.start_with?('n')
207
+ end
208
+ end
209
+ end
data/lib/bruh/cabal.rb ADDED
@@ -0,0 +1,77 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module Bruh
7
+ # Minimal Cabal file handler for version management
8
+ class Cabal
9
+ extend T::Sig
10
+
11
+ sig { returns(String) }
12
+ attr_reader :version
13
+
14
+ sig { returns(String) }
15
+ attr_reader :name
16
+
17
+ sig { returns(String) }
18
+ attr_reader :file_path
19
+
20
+ sig { params(file_path: String).void }
21
+ def initialize(file_path)
22
+ @file_path = file_path
23
+ @content = T.let(File.read(file_path), String)
24
+
25
+ # Extract name and version with specific regexes
26
+ @name = T.let(extract_field('name'), String)
27
+ @version = T.let(extract_field('version'), String)
28
+ end
29
+
30
+ sig { params(new_version: String).returns(String) }
31
+ def update_version(new_version)
32
+ # Update the version field in the file content
33
+ updated_content = @content.sub(/^version:\s*[0-9.]+/, "version: #{new_version}")
34
+
35
+ # Only write if something actually changed
36
+ if updated_content != @content
37
+ File.write(@file_path, updated_content)
38
+ # These variables are already declared in initialize, so we don't use T.let again
39
+ @content = updated_content
40
+ @version = new_version
41
+ end
42
+
43
+ new_version
44
+ end
45
+
46
+ # Get essential dependencies for version calculations
47
+ sig { returns(T::Array[String]) }
48
+ def dependencies
49
+ # Extract all build-depends lines
50
+ deps = []
51
+
52
+ @content.scan(/build-depends:.*?(?=\n\S|\Z)/m).each do |match|
53
+ # Extract package names without version constraints
54
+ match_str = T.cast(match, String)
55
+ match_str.scan(/\b([a-zA-Z][a-zA-Z0-9-]*)[,\s<>=]/).each do |dep_arr|
56
+ # dep_arr is an array where the first element is the capture group
57
+ deps << dep_arr[0]
58
+ end
59
+ end
60
+
61
+ deps.uniq
62
+ end
63
+
64
+ private
65
+
66
+ sig { params(field: String).returns(String) }
67
+ def extract_field(field)
68
+ # Use a specific regex targeting the field at the beginning of a line
69
+ # followed by colon and whitespace
70
+ if @content =~ /^#{field}:\s*([^\n]+)/
71
+ T.must(::Regexp.last_match(1)).strip
72
+ else
73
+ ''
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,62 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'date'
5
+ require 'sorbet-runtime'
6
+
7
+ module Bruh
8
+ # Manages changelog updates and version entries
9
+ class Changelog
10
+ extend T::Sig
11
+
12
+ sig { params(version: String, project_root: String, interactive: T::Boolean).returns(T::Boolean) }
13
+ def self.update(version, project_root, interactive: true)
14
+ changelog_path = File.join(project_root, 'CHANGELOG.md')
15
+ today = Date.today.strftime('%Y-%m-%d')
16
+
17
+ # Create file if it doesn't exist
18
+ File.write(changelog_path, "# Changelog\n\n") unless File.exist?(changelog_path)
19
+
20
+ content = File.read(changelog_path)
21
+
22
+ # Check if this version already exists in changelog
23
+ return true if content.include?("## [#{version}]")
24
+
25
+ # Insert new version entry after the header
26
+ new_content = content.sub(/^# Changelog/,
27
+ "# Changelog\n\n## [#{version}] - #{today}\n\n- Add your changes here\n\n")
28
+
29
+ File.write(changelog_path, new_content)
30
+
31
+ # Open editor for user to edit changelog if in interactive mode
32
+ if interactive
33
+ editor = ENV['EDITOR'] || 'nano'
34
+ puts 'Opening CHANGELOG.md for editing. Please add your release notes.'
35
+ system("#{editor} #{changelog_path}")
36
+ puts 'Press Enter to continue with the release process...'
37
+ gets
38
+ end
39
+
40
+ true
41
+ end
42
+
43
+ sig { params(project_root: String).returns(T::Hash[String, String]) }
44
+ def self.generate_release_notes(project_root)
45
+ changelog_path = File.join(project_root, 'CHANGELOG.md')
46
+
47
+ return { version: 'unknown', notes: 'No changelog found' } unless File.exist?(changelog_path)
48
+
49
+ content = File.read(changelog_path)
50
+
51
+ # Extract the latest version entry
52
+ if content =~ /## \[([^\]]+)\][^\n]*\n\n(.*?)(?=\n## |\Z)/m
53
+ version = ::Regexp.last_match(1)
54
+ notes = T.must(::Regexp.last_match(2)).strip
55
+
56
+ return { version: version, notes: notes }
57
+ end
58
+
59
+ { version: 'unknown', notes: 'Could not extract release notes' }
60
+ end
61
+ end
62
+ end