commiti 1.2.3

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,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Commiti
6
+ module PrOpener
7
+ SCP_REMOTE = %r{\A(?<user>[^@]+)@(?<host>[^:\s/]+):(?<path>[^\s]+)\z}
8
+ MAX_PREFILLED_URL_LENGTH = 1800
9
+ MAX_PREFILLED_TITLE_LENGTH = 120
10
+
11
+ def self.compare_url(origin_url:, base_branch:, head_branch:, title:, body:)
12
+ remote = extract_remote_info(origin_url)
13
+ raise 'Supported providers for browser PR opening are GitHub, GitLab, and GitBucket.' if remote.nil?
14
+
15
+ full_url = prefilled_url(
16
+ remote: remote,
17
+ base_branch: base_branch,
18
+ head_branch: head_branch,
19
+ title: title,
20
+ body: body,
21
+ include_title: true,
22
+ include_body: true
23
+ )
24
+ return full_url if full_url.length <= MAX_PREFILLED_URL_LENGTH
25
+
26
+ without_body = prefilled_url(
27
+ remote: remote,
28
+ base_branch: base_branch,
29
+ head_branch: head_branch,
30
+ title: title,
31
+ body: body,
32
+ include_title: true,
33
+ include_body: false
34
+ )
35
+ return without_body if without_body.length <= MAX_PREFILLED_URL_LENGTH
36
+
37
+ prefilled_url(
38
+ remote: remote,
39
+ base_branch: base_branch,
40
+ head_branch: head_branch,
41
+ title: title,
42
+ body: body,
43
+ include_title: false,
44
+ include_body: false
45
+ )
46
+ end
47
+
48
+ def self.prefilled_url(remote:, base_branch:, head_branch:, title:, body:, include_title:, include_body:)
49
+ if remote[:provider] == :gitlab
50
+ gitlab_mr_url(
51
+ remote: remote,
52
+ base_branch: base_branch,
53
+ head_branch: head_branch,
54
+ title: title,
55
+ body: body,
56
+ include_title: include_title,
57
+ include_description: include_body
58
+ )
59
+ else
60
+ github_like_compare_url(
61
+ remote: remote,
62
+ base_branch: base_branch,
63
+ head_branch: head_branch,
64
+ title: title,
65
+ body: body,
66
+ include_title: include_title,
67
+ include_body: include_body
68
+ )
69
+ end
70
+ end
71
+ private_class_method :prefilled_url
72
+
73
+ def self.github_like_compare_url(remote:, base_branch:, head_branch:, title:, body:, include_title: true, include_body: true)
74
+ query_params = { 'expand' => '1' }
75
+ normalized_title = normalize_title(title)
76
+ query_params['title'] = normalized_title if include_title && !normalized_title.empty?
77
+ query_params['body'] = body.to_s if include_body && !body.to_s.empty?
78
+ query = URI.encode_www_form(query_params)
79
+
80
+ base = "#{remote[:web_scheme]}://#{remote[:host]}"
81
+ path = "#{remote[:namespace]}/#{remote[:repo]}"
82
+
83
+ "#{base}/#{path}/compare/#{encode_branch_for_path(base_branch)}...#{encode_branch_for_path(head_branch)}?#{query}"
84
+ end
85
+
86
+ def self.gitlab_mr_url(remote:, base_branch:, head_branch:, title:, body:, include_title: true, include_description: true)
87
+ query_params = {
88
+ 'merge_request[source_branch]' => head_branch,
89
+ 'merge_request[target_branch]' => base_branch
90
+ }
91
+ normalized_title = normalize_title(title)
92
+ query_params['merge_request[title]'] = normalized_title if include_title && !normalized_title.empty?
93
+ query_params['merge_request[description]'] = body.to_s if include_description && !body.to_s.empty?
94
+ query = URI.encode_www_form(query_params)
95
+
96
+ base = "#{remote[:web_scheme]}://#{remote[:host]}"
97
+ path = "#{remote[:namespace]}/#{remote[:repo]}"
98
+
99
+ "#{base}/#{path}/-/merge_requests/new?#{query}"
100
+ end
101
+
102
+ def self.normalize_title(title)
103
+ title.to_s.strip[0, MAX_PREFILLED_TITLE_LENGTH]
104
+ end
105
+ private_class_method :normalize_title
106
+
107
+ def self.encode_branch_for_path(branch)
108
+ URI.encode_www_form_component(branch.to_s).gsub('+', '%20')
109
+ end
110
+
111
+ def self.extract_remote_info(origin_url)
112
+ remote_text = origin_url.to_s.strip
113
+ return nil if remote_text.empty?
114
+
115
+ parsed = parse_uri_remote(remote_text) || parse_scp_remote(remote_text)
116
+ return nil if parsed.nil?
117
+
118
+ normalized = normalize_repo_path(parsed[:path])
119
+ return nil if normalized.nil?
120
+
121
+ provider = detect_provider(parsed[:host])
122
+ return nil if provider.nil?
123
+
124
+ {
125
+ provider: provider,
126
+ host: parsed[:host],
127
+ web_scheme: parsed[:web_scheme],
128
+ namespace: normalized[:namespace],
129
+ repo: normalized[:repo]
130
+ }
131
+ end
132
+
133
+ def self.parse_uri_remote(remote_text)
134
+ uri = URI.parse(remote_text)
135
+ return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) || uri.scheme == 'ssh'
136
+ return nil if uri.host.to_s.strip.empty?
137
+
138
+ web_scheme = uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) ? uri.scheme : 'https'
139
+ { host: uri.host, path: uri.path, web_scheme: web_scheme }
140
+ rescue URI::InvalidURIError
141
+ nil
142
+ end
143
+
144
+ def self.parse_scp_remote(remote_text)
145
+ match = remote_text.match(SCP_REMOTE)
146
+ return nil if match.nil?
147
+
148
+ { host: match[:host], path: match[:path], web_scheme: 'https' }
149
+ end
150
+
151
+ def self.normalize_repo_path(raw_path)
152
+ clean = raw_path.to_s.strip
153
+ clean = clean.sub(%r{\A/+}, '').sub(%r{/+\z}, '')
154
+ clean = clean.sub(/\.git\z/, '')
155
+ segments = clean.split('/').reject(&:empty?)
156
+ return nil if segments.length < 2
157
+
158
+ {
159
+ namespace: segments[0..-2].join('/'),
160
+ repo: segments[-1]
161
+ }
162
+ end
163
+
164
+ def self.detect_provider(host)
165
+ normalized = host.to_s.downcase
166
+ return :gitlab if normalized.include?('gitlab')
167
+ return :gitbucket if normalized.include?('gitbucket')
168
+ return :github if normalized.include?('github')
169
+
170
+ nil
171
+ end
172
+
173
+ def self.suggest_title(pr_body, head_branch:)
174
+ in_summary = false
175
+ pr_body.to_s.each_line do |line|
176
+ stripped = line.strip
177
+ if stripped == '## Summary'
178
+ in_summary = true
179
+ next
180
+ end
181
+
182
+ break if in_summary && stripped.start_with?('## ')
183
+ next unless in_summary
184
+ next if stripped.empty? || stripped.start_with?('-', '*')
185
+
186
+ return stripped[0, 72]
187
+ end
188
+
189
+ "Update #{head_branch}"
190
+ end
191
+
192
+ def self.open_in_browser(url)
193
+ success = if windows?
194
+ open_windows_browser(url)
195
+ elsif mac?
196
+ system('open', url)
197
+ else
198
+ system('xdg-open', url)
199
+ end
200
+
201
+ raise 'Failed to open browser for PR URL.' unless success
202
+
203
+ true
204
+ end
205
+
206
+ def self.open_windows_browser(url)
207
+ cleaned_url = url.to_s.strip.sub(/\A\\+/, '')
208
+
209
+ # Prefer shell protocol handler. This bypasses cmd/explorer parsing of '&'.
210
+ return true if system('rundll32', 'url.dll,FileProtocolHandler', cleaned_url)
211
+
212
+ # PowerShell fallback, passing URL as an argument to avoid command parsing.
213
+ system(
214
+ 'powershell',
215
+ '-NoProfile',
216
+ '-Command',
217
+ '$u=$args[0]; Start-Process -FilePath $u',
218
+ '--',
219
+ cleaned_url
220
+ )
221
+ end
222
+
223
+ def self.extract_owner_repo(origin_url)
224
+ info = extract_remote_info(origin_url)
225
+ return nil if info.nil?
226
+
227
+ { owner: info[:namespace], repo: info[:repo] }
228
+ end
229
+
230
+ def self.windows?
231
+ RUBY_PLATFORM.include?('mingw') || RUBY_PLATFORM.include?('mswin')
232
+ end
233
+
234
+ def self.mac?
235
+ RUBY_PLATFORM.include?('darwin')
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Commiti
8
+ class GoogleClient
9
+ include HTTParty
10
+
11
+ base_uri 'https://generativelanguage.googleapis.com'
12
+ DEFAULT_MODEL = 'gemma-4-31b-it'
13
+ DEFAULT_TEMPERATURE = 0.2
14
+ DEFAULT_TIMEOUT_SECONDS = 180
15
+ DEFAULT_OPEN_TIMEOUT_SECONDS = 10
16
+
17
+ def initialize(config: Commiti::ConfigLoader.load)
18
+ @config = config || {}
19
+ end
20
+
21
+ def generate(system:, user:, api_key: nil, model: nil, temperature: nil, timeout_seconds: nil, open_timeout_seconds: nil)
22
+ settings = request_settings(
23
+ api_key: api_key,
24
+ model: model,
25
+ temperature: temperature,
26
+ timeout_seconds: timeout_seconds,
27
+ open_timeout_seconds: open_timeout_seconds
28
+ )
29
+ response = generate_content(system: system, user: user, settings: settings)
30
+ unless response.success?
31
+ detail = extract_error(response.body)
32
+ message = "Google AI error: #{response.code}"
33
+ message = "#{message} - #{detail}" unless detail.empty?
34
+ raise message
35
+ end
36
+ extract_generated_content(response.body)
37
+ end
38
+
39
+ def normalize_model(model)
40
+ value = model.to_s.strip
41
+ normalized = value.sub(%r{\Amodels/}, '')
42
+ normalized.empty? ? DEFAULT_MODEL : normalized
43
+ end
44
+
45
+ def normalize_api_key(value)
46
+ key = value.to_s.strip
47
+ return key unless key.empty?
48
+
49
+ raise 'Google API key is missing. Set GOOGLE_API_KEY (or GEMINI_API_KEY) in your environment.'
50
+ end
51
+
52
+ def normalize_float(value, fallback)
53
+ return fallback if value.nil? || value.to_s.strip.empty?
54
+
55
+ Float(value)
56
+ rescue ArgumentError
57
+ fallback
58
+ end
59
+
60
+ def normalize_integer(value, fallback)
61
+ return fallback if value.nil? || value.to_s.strip.empty?
62
+
63
+ Integer(value)
64
+ rescue ArgumentError
65
+ fallback
66
+ end
67
+
68
+ def extract_error(body)
69
+ parsed = JSON.parse(body.to_s)
70
+ error = parsed['error']
71
+ return error['message'].to_s.strip if error.is_a?(Hash)
72
+ return error.to_s.strip unless error.nil?
73
+
74
+ ''
75
+ rescue JSON::ParserError
76
+ ''
77
+ end
78
+
79
+ def extract_content(parsed)
80
+ parts = parsed.dig('candidates', 0, 'content', 'parts')
81
+ return '' unless parts.is_a?(Array)
82
+
83
+ parts.map { |part| part['text'].to_s }.join.strip
84
+ end
85
+
86
+ def request_settings(api_key:, model:, temperature:, timeout_seconds:, open_timeout_seconds:)
87
+ {
88
+ api_key: normalize_api_key(api_key || @config[:google_api_key]),
89
+ model: normalize_model(model || @config[:model]),
90
+ temperature: normalize_float(temperature || @config[:temperature], DEFAULT_TEMPERATURE),
91
+ timeout_seconds: normalize_integer(timeout_seconds || @config[:timeout_seconds], DEFAULT_TIMEOUT_SECONDS),
92
+ open_timeout_seconds: normalize_integer(open_timeout_seconds || @config[:open_timeout_seconds], DEFAULT_OPEN_TIMEOUT_SECONDS)
93
+ }
94
+ end
95
+
96
+ def extract_generated_content(body)
97
+ parsed = JSON.parse(body.to_s)
98
+ content = extract_content(parsed)
99
+ raise 'Google AI error: response did not include generated text' if content.empty?
100
+
101
+ content
102
+ rescue JSON::ParserError => e
103
+ raise "Google AI error: invalid JSON response (#{e.message})"
104
+ end
105
+
106
+ def generate_content(system:, user:, settings:)
107
+ self.class.post(
108
+ "/v1beta/models/#{URI.encode_www_form_component(settings[:model])}:generateContent",
109
+ query: { key: settings[:api_key] },
110
+ headers: { 'Content-Type' => 'application/json' },
111
+ timeout: settings[:timeout_seconds],
112
+ open_timeout: settings[:open_timeout_seconds],
113
+ body: request_body(system: system, user: user, settings: settings).to_json
114
+ )
115
+ end
116
+
117
+ def request_body(system:, user:, settings:)
118
+ {
119
+ systemInstruction: {
120
+ parts: [{ text: system.to_s }]
121
+ },
122
+ generationConfig: {
123
+ temperature: settings[:temperature]
124
+ },
125
+ contents: [
126
+ {
127
+ role: 'user',
128
+ parts: [{ text: user.to_s }]
129
+ }
130
+ ]
131
+ }
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module Clipboard
5
+ def self.copy(text)
6
+ case platform
7
+ when :mac
8
+ IO.popen('pbcopy', 'w') { |io| io.write(text) }
9
+ true
10
+ when :linux
11
+ if command_exists?('xclip')
12
+ IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) }
13
+ true
14
+ elsif command_exists?('xsel')
15
+ IO.popen('xsel --clipboard --input', 'w') { |io| io.write(text) }
16
+ true
17
+ else
18
+ false # no clipboard tool found
19
+ end
20
+ when :windows
21
+ IO.popen('clip', 'w') { |io| io.write(text) }
22
+ true
23
+ else
24
+ false
25
+ end
26
+ end
27
+
28
+ def self.platform
29
+ if RUBY_PLATFORM.include?('darwin')
30
+ :mac
31
+ elsif RUBY_PLATFORM.include?('linux')
32
+ :linux
33
+ elsif RUBY_PLATFORM.include?('mingw') || RUBY_PLATFORM.include?('mswin')
34
+ :windows
35
+ else
36
+ :unknown
37
+ end
38
+ end
39
+
40
+ def self.command_exists?(cmd)
41
+ system('which', cmd, out: File::NULL, err: File::NULL)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ class ConfigLoader
5
+ DEFAULT_CONFIG = {
6
+ google_api_key: nil,
7
+ model: Commiti::GoogleClient::DEFAULT_MODEL,
8
+ candidates: 1,
9
+ base_branch: 'main',
10
+ no_copy: false,
11
+ temperature: Commiti::GoogleClient::DEFAULT_TEMPERATURE,
12
+ timeout_seconds: Commiti::GoogleClient::DEFAULT_TIMEOUT_SECONDS,
13
+ open_timeout_seconds: Commiti::GoogleClient::DEFAULT_OPEN_TIMEOUT_SECONDS
14
+ }.freeze
15
+
16
+ # Loads configuration from environment variables.
17
+ # Keys are returned as symbols with parsed values.
18
+ def self.load(env: ENV)
19
+ {
20
+ google_api_key: google_api_key_from_env(env),
21
+ model: present_or_default(env.fetch('COMMITI_MODEL', nil), DEFAULT_CONFIG[:model]),
22
+ candidates: integer_or_default(env.fetch('COMMITI_CANDIDATES', nil), DEFAULT_CONFIG[:candidates]),
23
+ base_branch: present_or_default(env.fetch('COMMITI_BASE_BRANCH', nil), DEFAULT_CONFIG[:base_branch]),
24
+ no_copy: boolean_or_default(env.fetch('COMMITI_NO_COPY', nil), DEFAULT_CONFIG[:no_copy]),
25
+ temperature: float_or_default(env.fetch('COMMITI_MODEL_TEMPERATURE', nil), DEFAULT_CONFIG[:temperature]),
26
+ timeout_seconds: integer_or_default(env.fetch('COMMITI_MODEL_TIMEOUT_SECONDS', nil), DEFAULT_CONFIG[:timeout_seconds]),
27
+ open_timeout_seconds: integer_or_default(env.fetch('COMMITI_MODEL_OPEN_TIMEOUT_SECONDS', nil),
28
+ DEFAULT_CONFIG[:open_timeout_seconds])
29
+ }
30
+ end
31
+
32
+ def self.google_api_key_from_env(env)
33
+ present_or_nil(env.fetch('GOOGLE_API_KEY', nil)) ||
34
+ present_or_nil(env.fetch('GEMINI_API_KEY', nil)) ||
35
+ present_or_nil(env.fetch('GOOGLE_GENERATIVE_AI_API_KEY', nil))
36
+ end
37
+ private_class_method :google_api_key_from_env
38
+
39
+ def self.present_or_nil(value)
40
+ normalized = value.to_s.strip
41
+ normalized.empty? ? nil : normalized
42
+ end
43
+ private_class_method :present_or_nil
44
+
45
+ def self.present_or_default(value, fallback)
46
+ present_or_nil(value) || fallback
47
+ end
48
+ private_class_method :present_or_default
49
+
50
+ def self.integer_or_default(value, fallback)
51
+ return fallback if value.nil? || value.to_s.strip.empty?
52
+
53
+ Integer(value)
54
+ rescue ArgumentError
55
+ fallback
56
+ end
57
+ private_class_method :integer_or_default
58
+
59
+ def self.float_or_default(value, fallback)
60
+ return fallback if value.nil? || value.to_s.strip.empty?
61
+
62
+ Float(value)
63
+ rescue ArgumentError
64
+ fallback
65
+ end
66
+ private_class_method :float_or_default
67
+
68
+ def self.boolean_or_default(value, fallback)
69
+ return fallback if value.nil? || value.to_s.strip.empty?
70
+
71
+ normalized = value.to_s.strip.downcase
72
+ return true if %w[1 true yes on].include?(normalized)
73
+ return false if %w[0 false no off].include?(normalized)
74
+
75
+ fallback
76
+ end
77
+ private_class_method :boolean_or_default
78
+ end
79
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'shellwords'
5
+ require 'tempfile'
6
+ require 'tty-reader'
7
+
8
+ module Commiti
9
+ module InteractivePrompt
10
+ COMMIT_SUBJECT_MAX_LENGTH = 100
11
+ COMMIT_PREFIX = /\A(feat|fix|chore|refactor|docs|style|test|perf|ci|build|revert)(\([^)]+\))?!?:\s+\S/i
12
+
13
+ def self.ask_yes_no(question, default: :no)
14
+ suffix = default == :yes ? '[Y/n]' : '[y/N]'
15
+ input = read_input("#{question} #{suffix} ")
16
+ return default == :yes if input.nil?
17
+
18
+ value = input.strip.downcase
19
+ return true if %w[y yes].include?(value)
20
+ return false if %w[n no].include?(value)
21
+ return default == :yes if value.empty?
22
+
23
+ false
24
+ end
25
+
26
+ def self.ask_commit_action
27
+ input = read_input('Commit with this message? [y/e/N] ')
28
+ return :no if input.nil?
29
+
30
+ value = input.strip.downcase
31
+ return :yes if %w[y yes].include?(value)
32
+ return :edit if %w[e edit].include?(value)
33
+
34
+ :no
35
+ end
36
+
37
+ def self.ask_candidate_selection(count, default: 1)
38
+ return 0 if count <= 1
39
+
40
+ loop do
41
+ input = read_input("Select candidate [1-#{count}] (default: #{default}): ")
42
+ return default - 1 if input.nil?
43
+
44
+ value = input.strip
45
+ return default - 1 if value.empty?
46
+ return value.to_i - 1 if value.match?(/\A\d+\z/) && value.to_i.between?(1, count)
47
+
48
+ puts "Please type a number between 1 and #{count}."
49
+ end
50
+ end
51
+
52
+ def self.edit_message(initial_message)
53
+ # Keep the temp file closed while the external editor runs.
54
+ # On Windows, open handles can prevent editors like Notepad from
55
+ # saving in place, which can make edits appear to be ignored.
56
+ file = Tempfile.new(['commiti-msg', '.txt'])
57
+ begin
58
+ file.write("#{initial_message.to_s.rstrip}\n")
59
+ file.flush
60
+ file.close
61
+
62
+ command = editor_command
63
+ success = system(*command, file.path)
64
+ return nil unless success
65
+
66
+ File.read(file.path, mode: 'r:bom|utf-8').strip
67
+ ensure
68
+ file.unlink
69
+ end
70
+ end
71
+
72
+ def self.commit_message_errors(message)
73
+ cleaned = message.to_s.strip
74
+ return ['Message cannot be empty.'] if cleaned.empty?
75
+
76
+ first_line = cleaned.lines.first.to_s.strip
77
+ errors = []
78
+ errors << 'First line must start with a conventional commit type (feat:, fix:, etc.).' unless first_line.match?(COMMIT_PREFIX)
79
+ errors << "First line should be #{COMMIT_SUBJECT_MAX_LENGTH} characters or fewer." if first_line.length > COMMIT_SUBJECT_MAX_LENGTH
80
+ errors
81
+ end
82
+
83
+ def self.editor_command
84
+ preferred = ENV.fetch('VISUAL', nil)
85
+ preferred = ENV.fetch('EDITOR', nil) if preferred.to_s.strip.empty?
86
+
87
+ if preferred.to_s.strip.empty?
88
+ return ['notepad'] if windows?
89
+
90
+ return ['vi']
91
+ end
92
+
93
+ command = Shellwords.split(preferred)
94
+ command << '--wait' if code_editor_command?(command.first) && !command.include?('--wait')
95
+
96
+ command
97
+ end
98
+
99
+ def self.code_editor_command?(exe)
100
+ name = File.basename(exe.to_s).downcase
101
+ ['code', 'code.cmd', 'code.exe', 'codium', 'codium.cmd', 'codium.exe'].include?(name)
102
+ end
103
+
104
+ def self.windows?
105
+ RUBY_PLATFORM.include?('mingw') || RUBY_PLATFORM.include?('mswin')
106
+ end
107
+
108
+ def self.read_input(prompt)
109
+ if io_console_available?
110
+ reader.read_line(prompt)
111
+ else
112
+ print prompt
113
+ $stdin.gets
114
+ end
115
+ rescue Interrupt
116
+ nil
117
+ end
118
+
119
+ def self.reader
120
+ @reader ||= TTY::Reader.new
121
+ end
122
+
123
+ def self.io_console_available?
124
+ !IO.console.nil?
125
+ rescue StandardError
126
+ false
127
+ end
128
+ end
129
+ end