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.
data/lib/bruh/cli.rb ADDED
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'bruh/version'
5
+ require 'bruh/cabal'
6
+ require 'bruh/hackage'
7
+ require 'bruh/homebrew'
8
+ require 'bruh/bottle'
9
+ require 'bruh/config'
10
+ require 'bruh/changelog'
11
+
12
+ module Bruh
13
+ # CLI interface for Bruh commands
14
+ class CLI < Thor
15
+ desc 'version', 'Display Bruh version'
16
+ def version
17
+ puts "Bruh version #{Bruh::VERSION}"
18
+ end
19
+
20
+ desc 'release', 'Release a new version of a Haskell package to Hackage and Homebrew'
21
+ option :non_interactive, type: :boolean, desc: 'Run in non-interactive mode (for CI, testing)'
22
+ option :version, type: :string, desc: 'Version to release (for non-interactive mode)'
23
+ option :skip_hackage, type: :boolean, desc: 'Skip uploading to Hackage'
24
+ option :skip_bottles, type: :boolean, desc: 'Skip building Homebrew bottles'
25
+ option :skip_github, type: :boolean, desc: 'Skip uploading to GitHub'
26
+ def release
27
+ interactive = !options[:non_interactive]
28
+
29
+ project_root = Dir.pwd
30
+ cabal_file = find_cabal_file(project_root)
31
+
32
+ if cabal_file.nil?
33
+ puts 'Error: No .cabal file found in current directory or subdirectories'
34
+ exit 1
35
+ end
36
+
37
+ cabal = Cabal.new(cabal_file)
38
+
39
+ current_version = cabal.version
40
+ puts "Current version: #{current_version}"
41
+
42
+ # Determine new version
43
+ if options[:version] && !options[:version].empty?
44
+ new_version = options[:version]
45
+ elsif interactive
46
+ suggested_version = increment_version(current_version)
47
+ new_version = prompt('Enter new version', suggested_version)
48
+ else
49
+ new_version = increment_version(current_version)
50
+ end
51
+
52
+ puts "Preparing release for version #{new_version}"
53
+
54
+ # Update cabal version
55
+ cabal.update_version(new_version)
56
+ puts "Updated .cabal file to version #{new_version}"
57
+
58
+ # Update changelog
59
+ if File.exist?('CHANGELOG.md')
60
+ Changelog.update(new_version, project_root, interactive)
61
+ puts 'Updated CHANGELOG.md with new version entry'
62
+ end
63
+
64
+ # Run tests
65
+ puts 'Running tests to verify everything works...'
66
+ unless system('bundle exec rake test')
67
+ puts 'Tests failed. Aborting release process.'
68
+ exit 1 unless yes_no_prompt('Continue anyway?', default_no: false) && interactive
69
+ end
70
+
71
+ # Commit changes if in interactive mode
72
+ if interactive && yes_no_prompt('Commit version bump and changelog?', default_no: true)
73
+ system("git add #{cabal_file} CHANGELOG.md")
74
+ system("git commit -m \"Bump version to #{new_version}\"")
75
+
76
+ system('git push origin main') if yes_no_prompt('Push changes to origin?', default_no: true)
77
+
78
+ if yes_no_prompt("Create tag v#{new_version}?", default_no: true)
79
+ if system("git rev-parse v#{new_version} >/dev/null 2>&1")
80
+ if yes_no_prompt('Tag already exists. Update it?', default_no: true)
81
+ system("git tag -fa v#{new_version} -m \"Release version #{new_version}\"")
82
+ end
83
+ else
84
+ system("git tag -a v#{new_version} -m \"Release version #{new_version}\"")
85
+ end
86
+
87
+ system("git push origin v#{new_version}") if yes_no_prompt('Push tag to origin?', default_no: true)
88
+ end
89
+ end
90
+
91
+ # Upload to Hackage
92
+ unless options[:skip_hackage]
93
+ puts 'Preparing for Hackage release...'
94
+ hackage = Hackage.new(interactive)
95
+
96
+ if interactive
97
+ uploaded = hackage.publish(new_version)
98
+ else
99
+ # In non-interactive mode, use credentials from config
100
+ config = Config.load
101
+ uploaded = hackage.publish_non_interactive(new_version, config[:hackage_username], config[:hackage_password])
102
+ end
103
+
104
+ if uploaded
105
+ puts 'Successfully published to Hackage!'
106
+
107
+ # Calculate package SHA for Homebrew
108
+ sha256 = hackage.calculate_package_sha256(new_version)
109
+
110
+ # Update Homebrew formula
111
+ if sha256
112
+ puts 'Updating Homebrew formula...'
113
+ formula = Homebrew.new(interactive)
114
+ formula.update(new_version, sha256)
115
+
116
+ # Build bottle if requested
117
+ unless options[:skip_bottles]
118
+ puts 'Building Homebrew bottle...'
119
+ bottle = Bottle.new(interactive)
120
+ bottle_info = bottle.build(new_version)
121
+
122
+ if bottle_info && !options[:skip_github]
123
+ puts 'Uploading bottle to GitHub...'
124
+ bottle.upload_to_github(new_version, bottle_info)
125
+
126
+ puts 'Updating formula with bottle information...'
127
+ formula.update_bottle_info(bottle_info)
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ puts 'Release process complete!'
135
+ end
136
+
137
+ desc 'config_setup', 'Set up configuration'
138
+ def config_setup
139
+ Config.setup_credentials(true)
140
+ puts 'Configuration setup complete.'
141
+ end
142
+
143
+ desc 'config_get', 'Get a configuration value'
144
+ def config_get(key)
145
+ value = Config.get(key.to_sym)
146
+ puts "#{key}: #{value}"
147
+ end
148
+
149
+ desc 'config_set', 'Set a configuration value'
150
+ def config_set(key, value)
151
+ Config.set(key.to_sym, value)
152
+ puts "Set #{key} = #{value}"
153
+ end
154
+
155
+ private
156
+
157
+ def find_cabal_file(dir)
158
+ Dir.glob("#{dir}/**/*.cabal").first
159
+ end
160
+
161
+ def increment_version(version)
162
+ if version =~ /(\d+)\.(\d+)\.(\d+)/
163
+ major = ::Regexp.last_match(1).to_i
164
+ minor = ::Regexp.last_match(2).to_i
165
+ patch = ::Regexp.last_match(3).to_i
166
+ "#{major}.#{minor}.#{patch + 1}"
167
+ else
168
+ "#{version}.1"
169
+ end
170
+ end
171
+
172
+ def prompt(message, default = nil)
173
+ if default
174
+ print "#{message} [#{default}]: "
175
+ else
176
+ print "#{message}: "
177
+ end
178
+ input = $stdin.gets.chomp.strip
179
+ input.empty? ? default : input
180
+ end
181
+
182
+ def yes_no_prompt(message, default_no: true)
183
+ default = default_no ? '[y/N]' : '[Y/n]'
184
+ print "#{message} #{default} "
185
+ response = $stdin.gets.chomp.downcase
186
+ return response.start_with?('y') if default_no
187
+
188
+ !response.start_with?('n')
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,134 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'fileutils'
5
+ require 'toml-rb'
6
+ require 'sorbet-runtime'
7
+
8
+ module Bruh
9
+ # Manages configuration for Bruh
10
+ class Config
11
+ extend T::Sig
12
+
13
+ CONFIG_DIR = T.let(File.expand_path('~/.config/bruh').freeze, String)
14
+ CONFIG_FILE = T.let(File.join(CONFIG_DIR, 'config.toml').freeze, String)
15
+
16
+ sig { returns(T::Hash[Symbol, T.untyped]) }
17
+ def self.load
18
+ ensure_config_exists
19
+
20
+ begin
21
+ config = TomlRB.load_file(CONFIG_FILE)
22
+ symbolize_keys(config)
23
+ rescue StandardError => e
24
+ puts "Error loading config: #{e.message}"
25
+ {}
26
+ end
27
+ end
28
+
29
+ sig { params(config: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
30
+ def self.save(config)
31
+ ensure_config_exists
32
+
33
+ begin
34
+ File.write(CONFIG_FILE, TomlRB.dump(stringify_keys(config)))
35
+ true
36
+ rescue StandardError => e
37
+ puts "Error saving config: #{e.message}"
38
+ false
39
+ end
40
+ end
41
+
42
+ sig { params(key: Symbol, value: T.untyped).returns(T::Boolean) }
43
+ def self.set(key, value)
44
+ config = load
45
+ config[key] = value
46
+ save(config)
47
+ end
48
+
49
+ sig { params(key: Symbol).returns(T.untyped) }
50
+ def self.get(key)
51
+ config = load
52
+ config[key]
53
+ end
54
+
55
+ sig { params(interactive: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
56
+ def self.setup_credentials(interactive: true)
57
+ config = load
58
+
59
+ if interactive
60
+ # Prompt for credentials
61
+ puts 'Setting up Hackage credentials:'
62
+ print 'Hackage username: '
63
+ username = gets.chomp
64
+
65
+ print 'Hackage password (input will be hidden): '
66
+ system('stty -echo')
67
+ password = gets.chomp
68
+ system('stty echo')
69
+ puts
70
+
71
+ puts 'Setting up GitHub credentials:'
72
+ print 'GitHub token (for bottle uploads): '
73
+ system('stty -echo')
74
+ github_token = gets.chomp
75
+ system('stty echo')
76
+ puts
77
+
78
+ # Save credentials
79
+ config[:hackage_username] = username
80
+ config[:hackage_password] = password
81
+ config[:github_token] = github_token
82
+
83
+ save(config)
84
+ end
85
+
86
+ config
87
+ end
88
+
89
+ sig { returns(NilClass) }
90
+ def self.ensure_config_exists
91
+ FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
92
+
93
+ unless File.exist?(CONFIG_FILE)
94
+ default_config = {
95
+ hackage_username: '',
96
+ hackage_password: '',
97
+ github_token: '',
98
+ default_formula_template: 'standard',
99
+ bottle_platforms: ['arm64_monterey']
100
+ }
101
+
102
+ File.write(CONFIG_FILE, TomlRB.dump(default_config))
103
+ end
104
+
105
+ nil
106
+ end
107
+
108
+ sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
109
+ def self.symbolize_keys(hash)
110
+ result = {}
111
+ hash.each do |key, value|
112
+ result[key.to_sym] = if value.is_a?(Hash)
113
+ symbolize_keys(value)
114
+ else
115
+ value
116
+ end
117
+ end
118
+ result
119
+ end
120
+
121
+ sig { params(hash: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
122
+ def self.stringify_keys(hash)
123
+ result = {}
124
+ hash.each do |key, value|
125
+ result[key.to_s] = if value.is_a?(Hash)
126
+ stringify_keys(value)
127
+ else
128
+ value
129
+ end
130
+ end
131
+ result
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,159 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'digest'
6
+ require 'sorbet-runtime'
7
+
8
+ module Bruh
9
+ # Handles interaction with Hackage package manager
10
+ class Hackage
11
+ extend T::Sig
12
+
13
+ sig { params(interactive: T::Boolean).void }
14
+ def initialize(interactive: true)
15
+ @interactive = interactive
16
+ end
17
+
18
+ sig { params(version: String).returns(T::Boolean) }
19
+ def publish(version)
20
+ puts '=== Ready to upload to Hackage ==='
21
+
22
+ package_path = build_package(version)
23
+ return false unless package_path
24
+
25
+ if @interactive
26
+ puts 'The following command will upload the package to Hackage:'
27
+ puts " cabal upload --publish #{package_path}"
28
+
29
+ if yes_no_prompt('Do you want to upload to Hackage now?')
30
+ publish_package(package_path)
31
+ else
32
+ puts 'Skipping Hackage upload. Run the command manually when ready.'
33
+ false
34
+ end
35
+ else
36
+ # In non-interactive mode, just publish
37
+ publish_package(package_path)
38
+ end
39
+ end
40
+
41
+ sig { params(version: String, username: String, password: String).returns(T::Boolean) }
42
+ def publish_non_interactive(version, username, password)
43
+ package_path = build_package(version)
44
+ return false unless package_path
45
+
46
+ # Use HTTP Basic auth with credentials
47
+ cmd = "curl -X PUT --data-binary \"@#{package_path}\" " \
48
+ "-H 'Content-Type: application/x-tar' " \
49
+ "-H 'Content-Encoding: gzip' " \
50
+ "--user \"#{username}:#{password}\" " \
51
+ '"https://hackage.haskell.org/packages/upload"'
52
+
53
+ T.must(system(cmd))
54
+ end
55
+
56
+ sig { params(version: String).returns(T.nilable(String)) }
57
+ def calculate_package_sha256(version)
58
+ puts '=== Calculating SHA256 for Hackage package ==='
59
+
60
+ # Wait for package to be available
61
+ puts 'Waiting for Hackage to process the package (10 seconds)...'
62
+ sleep 10 if @interactive
63
+
64
+ package_name = File.basename(Dir.pwd)
65
+ hackage_url = "https://hackage.haskell.org/package/#{package_name}-#{version}/#{package_name}-#{version}.tar.gz"
66
+
67
+ max_attempts = 3
68
+ attempt = 1
69
+ sha256 = T.let(nil, T.nilable(String))
70
+
71
+ while attempt <= max_attempts && sha256.nil?
72
+ puts "Attempt #{attempt} of #{max_attempts} to calculate SHA256..."
73
+
74
+ begin
75
+ uri = URI(hackage_url)
76
+ response = Net::HTTP.get_response(uri)
77
+
78
+ if response.is_a?(Net::HTTPSuccess) && !response.body.empty?
79
+ sha256 = Digest::SHA256.hexdigest(response.body)
80
+
81
+ # Validate SHA256 format (64 hex chars)
82
+ if sha256 =~ /^[0-9a-f]{64}$/
83
+ puts "Valid SHA256 obtained: #{sha256}"
84
+ break
85
+ else
86
+ puts 'Invalid SHA256 obtained. Retrying...'
87
+ sha256 = T.let(nil, T.nilable(String))
88
+ end
89
+ else
90
+ puts 'Package not available yet or empty response.'
91
+ end
92
+ rescue StandardError => e
93
+ puts "Error downloading package: #{e.message}"
94
+ end
95
+
96
+ # Wait before retrying
97
+ wait_time = attempt * 5
98
+ puts "Waiting #{wait_time} seconds before retry..."
99
+ sleep wait_time
100
+ attempt += 1
101
+ end
102
+
103
+ if sha256.nil?
104
+ puts "Error: Failed to calculate SHA256 after #{max_attempts} attempts."
105
+ puts 'The package might not be available on Hackage yet.'
106
+ nil
107
+ else
108
+ sha256
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ sig { params(version: String).returns(T.nilable(String)) }
115
+ def build_package(version)
116
+ # Run necessary steps to build the package
117
+ puts 'Building documentation for Hackage...'
118
+ unless system('cabal haddock --haddock-for-hackage')
119
+ puts 'Failed to build documentation'
120
+ return nil
121
+ end
122
+
123
+ puts 'Creating source distribution...'
124
+ unless system('cabal sdist')
125
+ puts 'Failed to create source distribution'
126
+ return nil
127
+ end
128
+
129
+ # Find the generated package file
130
+ package_name = File.basename(Dir.pwd)
131
+ package_path = "dist-newstyle/sdist/#{package_name}-#{version}.tar.gz"
132
+
133
+ unless File.exist?(package_path)
134
+ puts "Generated package not found at #{package_path}"
135
+ return nil
136
+ end
137
+
138
+ package_path
139
+ end
140
+
141
+ sig { params(package_path: String).returns(T::Boolean) }
142
+ def publish_package(package_path)
143
+ puts 'Uploading package to Hackage...'
144
+ T.must(system("cabal upload --publish \"#{package_path}\""))
145
+ end
146
+
147
+ sig { params(message: String, default_no: T::Boolean).returns(T::Boolean) }
148
+ def yes_no_prompt(message, default_no: true)
149
+ return true unless @interactive
150
+
151
+ default = default_no ? '[y/N]' : '[Y/n]'
152
+ print "#{message} #{default} "
153
+ response = gets.chomp.downcase
154
+ return response.start_with?('y') if default_no
155
+
156
+ !response.start_with?('n')
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,223 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module Bruh
7
+ # Manages Homebrew formula creation and updates
8
+ class Homebrew
9
+ extend T::Sig
10
+
11
+ sig { params(interactive: T::Boolean).void }
12
+ def initialize(interactive: true)
13
+ @interactive = interactive
14
+ @tap_dir = T.let(find_homebrew_tap, T.nilable(String))
15
+ @formula_path = T.let(find_formula_path, T.nilable(String))
16
+ end
17
+
18
+ sig { params(version: String, sha256: String).returns(T::Boolean) }
19
+ def update(version, sha256)
20
+ unless @formula_path && File.exist?(@formula_path)
21
+ puts "Error: Formula file not found at #{@formula_path}"
22
+ return false
23
+ end
24
+
25
+ puts "Updating Homebrew formula with new version #{version} and SHA256 #{sha256}..."
26
+
27
+ # Read current formula
28
+ formula_content = File.read(@formula_path)
29
+
30
+ # Verify markers exist
31
+ unless formula_content.include?('url') && formula_content.include?('sha256')
32
+ puts 'ERROR: Required fields not found in formula. Cannot update safely.'
33
+ return false
34
+ end
35
+
36
+ # Update version in URL
37
+ formula_content.gsub!(/url\s+"[^"]+"/, "url \"https://hackage.haskell.org/package/#{package_name}-#{version}/#{package_name}-#{version}.tar.gz\"")
38
+
39
+ # Update SHA256
40
+ formula_content.gsub!(/sha256\s+"[a-f0-9]+"/, "sha256 \"#{sha256}\"")
41
+
42
+ # Write updated formula
43
+ File.write(@formula_path, formula_content)
44
+
45
+ # Verify the update was successful
46
+ updated_content = File.read(@formula_path)
47
+ if !updated_content.include?("#{package_name}-#{version}.tar.gz") ||
48
+ !updated_content.include?("sha256 \"#{sha256}\"")
49
+ puts 'ERROR: Formula update verification failed.'
50
+ return false
51
+ end
52
+
53
+ puts 'Formula updated successfully with new version and SHA256.'
54
+
55
+ # Commit the changes if interactive
56
+ if @interactive && yes_no_prompt('Commit formula changes?')
57
+ Dir.chdir(File.dirname(@formula_path)) do
58
+ system("git add #{File.basename(@formula_path)}")
59
+ system("git commit -m \"Update formula to version #{version}\"")
60
+
61
+ system('git push origin main') if yes_no_prompt('Push formula changes to origin?', default_no: true)
62
+ end
63
+ end
64
+
65
+ true
66
+ end
67
+
68
+ sig { params(bottle_info: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
69
+ def update_bottle_info(bottle_info)
70
+ unless @formula_path && File.exist?(@formula_path)
71
+ puts 'Error: Formula file not found'
72
+ return false
73
+ end
74
+
75
+ macos_version = bottle_info[:macos_version]
76
+ bottle_sha = bottle_info[:sha256]
77
+ rebuild_num = bottle_info[:rebuild] || 0
78
+
79
+ formula_content = File.read(@formula_path)
80
+
81
+ # Check if bottle block exists
82
+ if formula_content.include?('bottle do')
83
+ # Update existing bottle block
84
+
85
+ # Update rebuild directive if needed
86
+ if rebuild_num.positive?
87
+ if formula_content =~ /\s+rebuild\s+\d+/
88
+ formula_content.gsub!(/(\s+rebuild\s+)\d+/, "\\1#{rebuild_num}")
89
+ else
90
+ formula_content.gsub!(/(\s+root_url.*)$/, "\\1\n rebuild #{rebuild_num}")
91
+ end
92
+ else
93
+ # Remove rebuild directive if it exists
94
+ formula_content.gsub!(/\s+rebuild\s+\d+\n/, "\n")
95
+ end
96
+
97
+ # Update SHA256 for the specific platform
98
+ if formula_content =~ /sha256\s+cellar:[^,]+,\s+arm64_[^:]+:/
99
+ formula_content.gsub!(/sha256\s+cellar:[^,]+,\s+arm64_[^:]+:\s+"[a-f0-9]+"/,
100
+ "sha256 cellar: :any, arm64_#{macos_version}: \"#{bottle_sha}\"")
101
+ else
102
+ # Add line for this platform
103
+ formula_content.gsub!(/(\s+root_url\s+"[^"]+".*$)/,
104
+ "\\1\n sha256 cellar: :any, arm64_#{macos_version}: \"#{bottle_sha}\"")
105
+ end
106
+ else
107
+ # Create bottle block after sha256 line
108
+ bottle_block = <<~BOTTLE
109
+
110
+ bottle do
111
+ root_url "https://github.com/#{repo_owner}/#{repo_name}/releases/download/v#{bottle_info[:version]}"
112
+ sha256 cellar: :any, arm64_#{macos_version}: "#{bottle_sha}"
113
+ end
114
+ BOTTLE
115
+
116
+ formula_content.gsub!(/sha256\s+"[a-f0-9]+"/, "\\0#{bottle_block}")
117
+ end
118
+
119
+ # Write updated formula
120
+ File.write(@formula_path, formula_content)
121
+
122
+ # Verify the update was successful
123
+ updated_content = File.read(@formula_path)
124
+ unless updated_content.include?("arm64_#{macos_version}: \"#{bottle_sha}\"")
125
+ puts 'ERROR: Bottle SHA update failed.'
126
+ return false
127
+ end
128
+
129
+ puts 'Bottle information updated successfully.'
130
+
131
+ # Commit the changes if interactive
132
+ if @interactive && yes_no_prompt('Commit bottle changes?')
133
+ Dir.chdir(File.dirname(@formula_path)) do
134
+ system("git add #{File.basename(@formula_path)}")
135
+ system("git commit -m \"Add bottle for version #{bottle_info[:version]}\"")
136
+
137
+ system('git push origin main') if yes_no_prompt('Push bottle changes to origin?', default_no: true)
138
+ end
139
+ end
140
+
141
+ true
142
+ end
143
+
144
+ private
145
+
146
+ sig { returns(T.nilable(String)) }
147
+ def find_homebrew_tap
148
+ # Try common locations
149
+ potential_paths = [
150
+ '../homebrew-tap',
151
+ '~/homebrew-tap',
152
+ '~/Projects/homebrew-tap'
153
+ ]
154
+
155
+ potential_paths.each do |path|
156
+ expanded = File.expand_path(path)
157
+ return expanded if Dir.exist?(expanded)
158
+ end
159
+
160
+ if @interactive
161
+ puts 'Homebrew tap directory not found in common locations.'
162
+ tap_dir = gets.chomp.strip
163
+ return tap_dir if !tap_dir.empty? && Dir.exist?(tap_dir)
164
+ end
165
+
166
+ nil
167
+ end
168
+
169
+ sig { returns(T.nilable(String)) }
170
+ def find_formula_path
171
+ return nil unless @tap_dir
172
+
173
+ # Try to find the formula file
174
+ formula_name = package_name
175
+ formula_path = File.join(@tap_dir, 'Formula', "#{formula_name}.rb")
176
+
177
+ return formula_path if File.exist?(formula_path)
178
+
179
+ # If not found in standard location, try to find elsewhere
180
+ Dir.glob(File.join(@tap_dir, '**', "#{formula_name}.rb")).first
181
+ end
182
+
183
+ sig { returns(String) }
184
+ def package_name
185
+ # Extract package name from current directory
186
+ File.basename(Dir.pwd)
187
+ end
188
+
189
+ sig { returns(T::Array[String]) }
190
+ def repo_info
191
+ # Extract owner and repo from git remote
192
+ remote = `git remote get-url origin`.chomp
193
+ if remote =~ %r{github\.com[:/]([^/]+)/([^/]+)\.git}
194
+ owner = T.let(T.must(::Regexp.last_match(1)), String)
195
+ repo = T.let(T.must(::Regexp.last_match(2)), String)
196
+ return [owner, repo]
197
+ end
198
+
199
+ %w[user repo]
200
+ end
201
+ sig { returns(String) }
202
+ def repo_owner
203
+ T.must(repo_info[0])
204
+ end
205
+
206
+ sig { returns(String) }
207
+ def repo_name
208
+ T.must(repo_info[1])
209
+ end
210
+
211
+ sig { params(message: String, default_no: T::Boolean).returns(T::Boolean) }
212
+ def yes_no_prompt(message, default_no: true)
213
+ return true unless @interactive
214
+
215
+ default = default_no ? '[y/N]' : '[Y/n]'
216
+ print "#{message} #{default} "
217
+ response = gets.chomp.downcase
218
+ return response.start_with?('y') if default_no
219
+
220
+ !response.start_with?('n')
221
+ end
222
+ end
223
+ end