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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +206 -0
- data/bin/commiti +64 -0
- data/lib/commiti.rb +21 -0
- data/lib/flows/base_flow.rb +98 -0
- data/lib/flows/commit_flow.rb +29 -0
- data/lib/flows/pr_flow.rb +45 -0
- data/lib/services/diff_summarization/batch_runner.rb +148 -0
- data/lib/services/diff_summarization/diff_summarizer.rb +89 -0
- data/lib/services/diff_summarization/fallback_builder.rb +61 -0
- data/lib/services/flow_context_builder.rb +38 -0
- data/lib/services/git/commit/commit_execution.rb +80 -0
- data/lib/services/git/commit/commit_staging.rb +37 -0
- data/lib/services/git/diff_parser.rb +63 -0
- data/lib/services/git/git_reader.rb +189 -0
- data/lib/services/git/git_writer.rb +58 -0
- data/lib/services/git/pr/pr_opener.rb +238 -0
- data/lib/services/google_client.rb +134 -0
- data/lib/services/helpers/clipboard.rb +44 -0
- data/lib/services/helpers/config_loader.rb +79 -0
- data/lib/services/helpers/interactive_prompt.rb +129 -0
- data/lib/services/helpers/prompt_builder.rb +122 -0
- data/lib/services/helpers/spinner.rb +49 -0
- data/lib/services/message_generator.rb +174 -0
- data/lib/services/message_presenter.rb +52 -0
- metadata +99 -0
|
@@ -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
|