commiti 1.3.0 → 1.3.2

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,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commiti
4
+ module PrBrowserOpener
5
+ def self.open_in_browser(url)
6
+ success = if windows?
7
+ open_windows_browser(url)
8
+ elsif mac?
9
+ system('open', url)
10
+ else
11
+ system('xdg-open', url)
12
+ end
13
+
14
+ raise 'Failed to open browser for PR URL.' unless success
15
+
16
+ nil
17
+ end
18
+
19
+ def self.open_windows_browser(url)
20
+ cleaned_url = url.to_s.strip.sub(/\A\\+/, '')
21
+
22
+ # Prefer shell protocol handler. This bypasses cmd/explorer parsing of '&'.
23
+ return true if system('rundll32', 'url.dll,FileProtocolHandler', cleaned_url)
24
+
25
+ # PowerShell fallback, passing URL as an argument to avoid command parsing.
26
+ system(
27
+ 'powershell',
28
+ '-NoProfile',
29
+ '-Command',
30
+ '$u=$args[0]; Start-Process -FilePath $u',
31
+ '--',
32
+ cleaned_url
33
+ )
34
+ end
35
+
36
+ def self.windows?
37
+ RUBY_PLATFORM.include?('mingw') || RUBY_PLATFORM.include?('mswin')
38
+ end
39
+
40
+ def self.mac?
41
+ RUBY_PLATFORM.include?('darwin')
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require_relative 'remote_parser'
7
+
8
+ module Commiti
9
+ module PrCreator
10
+ extend PrRemoteParser
11
+
12
+ def self.create(origin_url:, base_branch:, head_branch:, title:, body:, config:)
13
+ remote = extract_remote_info(origin_url)
14
+ return { url: nil, reason: :unsupported_provider } if remote.nil?
15
+
16
+ token = token_for_provider(remote[:provider], config)
17
+ return { url: nil, reason: :missing_token, provider: remote[:provider] } if token.nil?
18
+
19
+ url = case remote[:provider]
20
+ when :github, :gitbucket
21
+ create_github_like_pr(remote: remote, base_branch: base_branch, head_branch: head_branch,
22
+ title: title, body: body, token: token)
23
+ when :gitlab
24
+ create_gitlab_mr(remote: remote, base_branch: base_branch, head_branch: head_branch,
25
+ title: title, body: body, token: token)
26
+ end
27
+
28
+ return { url: nil, reason: :unsupported_provider } if url.nil?
29
+
30
+ { url: url, reason: :created }
31
+ rescue StandardError => e
32
+ { url: nil, reason: :api_error, provider: remote && remote[:provider], error: e.message }
33
+ end
34
+
35
+ def self.create_github_like_pr(remote:, base_branch:, head_branch:, title:, body:, token:)
36
+ uri = github_api_uri(remote, "/repos/#{remote[:namespace]}/#{remote[:repo]}/pulls")
37
+ payload = {
38
+ title: title.to_s,
39
+ head: head_branch.to_s,
40
+ base: base_branch.to_s,
41
+ body: body.to_s
42
+ }
43
+
44
+ response = post_json(
45
+ uri,
46
+ payload,
47
+ {
48
+ 'Authorization' => "token #{token}",
49
+ 'Accept' => 'application/vnd.github+json'
50
+ }
51
+ )
52
+
53
+ parsed = parse_json(response)
54
+ url = parsed['html_url']
55
+ raise 'PR API response did not include html_url.' if url.to_s.strip.empty?
56
+
57
+ url
58
+ end
59
+ private_class_method :create_github_like_pr
60
+
61
+ # github.com uses api.github.com; GitHub Enterprise uses /api/v3 on the same host.
62
+ def self.github_api_uri(remote, path)
63
+ base = if remote[:host].to_s.downcase == 'github.com'
64
+ 'https://api.github.com'
65
+ else
66
+ "#{remote[:web_scheme]}://#{remote[:host]}/api/v3"
67
+ end
68
+
69
+ URI("#{base}#{path}")
70
+ end
71
+ private_class_method :github_api_uri
72
+
73
+ def self.create_gitlab_mr(remote:, base_branch:, head_branch:, title:, body:, token:)
74
+ encoded_project = URI.encode_www_form_component("#{remote[:namespace]}/#{remote[:repo]}")
75
+ uri = URI("#{remote_base(remote)}/api/v4/projects/#{encoded_project}/merge_requests")
76
+ payload = {
77
+ source_branch: head_branch.to_s,
78
+ target_branch: base_branch.to_s,
79
+ title: title.to_s,
80
+ description: body.to_s
81
+ }
82
+
83
+ response = post_json(
84
+ uri,
85
+ payload,
86
+ {
87
+ 'PRIVATE-TOKEN' => token,
88
+ 'Accept' => 'application/json'
89
+ }
90
+ )
91
+
92
+ parsed = parse_json(response)
93
+ url = parsed['web_url']
94
+ raise 'MR API response did not include web_url.' if url.to_s.strip.empty?
95
+
96
+ url
97
+ end
98
+ private_class_method :create_gitlab_mr
99
+
100
+ def self.post_json(uri, payload, headers, redirect_limit: 5)
101
+ raise 'Too many redirects' if redirect_limit.zero?
102
+
103
+ request = Net::HTTP::Post.new(uri)
104
+ request['Content-Type'] = 'application/json'
105
+ headers.each { |k, v| request[k] = v }
106
+ request.body = JSON.generate(payload)
107
+
108
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
109
+ response = http.request(request)
110
+ code = response.code.to_i
111
+
112
+ if [301, 302, 307, 308].include?(code)
113
+ location = response['Location'].to_s.strip
114
+ raise 'Redirect with no Location header' if location.empty?
115
+
116
+ new_uri = URI(location)
117
+ new_uri = URI("#{uri.scheme}://#{uri.host}#{location}") unless new_uri.host
118
+
119
+ # Never follow to a different host — that means the API pushed us to the web UI
120
+ raise "API redirected to a different host (#{new_uri.host}), check namespace/repo in origin URL" \
121
+ if new_uri.host != uri.host
122
+
123
+ return post_json(new_uri, payload, headers, redirect_limit: redirect_limit - 1)
124
+ end
125
+
126
+ return response if code.between?(200, 299)
127
+
128
+ parsed = parse_json(response)
129
+ errors = parsed['errors']&.map { |e| e.slice('field', 'code', 'message').values.join(': ') }&.join(', ')
130
+ base_msg = parsed['message'] || parsed['error'] || response.body.to_s.strip
131
+ detail = errors ? "#{base_msg} — #{errors}" : base_msg
132
+ raise "PR API request failed (HTTP #{code}): #{detail}"
133
+ end
134
+ end
135
+ private_class_method :post_json
136
+
137
+ def self.parse_json(response)
138
+ JSON.parse(response.body.to_s)
139
+ rescue JSON::ParserError
140
+ {}
141
+ end
142
+ private_class_method :parse_json
143
+
144
+ def self.remote_base(remote)
145
+ "#{remote[:web_scheme]}://#{remote[:host]}"
146
+ end
147
+ private_class_method :remote_base
148
+
149
+ def self.token_for_provider(provider, config)
150
+ case provider
151
+ when :github
152
+ present_or_nil(config[:github_token])
153
+ when :gitlab
154
+ present_or_nil(config[:gitlab_token])
155
+ when :gitbucket
156
+ present_or_nil(config[:gitbucket_token])
157
+ end
158
+ end
159
+ private_class_method :token_for_provider
160
+
161
+ def self.present_or_nil(value)
162
+ normalized = value.to_s.strip
163
+ normalized.empty? ? nil : normalized
164
+ end
165
+ private_class_method :present_or_nil
166
+ end
167
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'uri'
4
+ require_relative 'remote_parser'
5
+ require_relative 'browser_opener'
4
6
 
5
7
  module Commiti
6
8
  module PrOpener
@@ -8,42 +10,120 @@ module Commiti
8
10
  MAX_PREFILLED_URL_LENGTH = 1800
9
11
  MAX_PREFILLED_TITLE_LENGTH = 120
10
12
 
13
+ extend PrRemoteParser
14
+
11
15
  def self.compare_url(origin_url:, base_branch:, head_branch:, title:, body:)
12
16
  remote = extract_remote_info(origin_url)
13
17
  raise 'Supported providers for browser PR opening are GitHub, GitLab, and GitBucket.' if remote.nil?
14
18
 
15
- full_url = prefilled_url(
19
+ compare_url_candidates(
16
20
  remote: remote,
17
21
  base_branch: base_branch,
18
22
  head_branch: head_branch,
19
23
  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
24
+ body: body
25
+ ).find { |url| url.length <= MAX_PREFILLED_URL_LENGTH } || compare_url_candidates(
26
+ remote: remote,
27
+ base_branch: base_branch,
28
+ head_branch: head_branch,
29
+ title: title,
30
+ body: body
31
+ ).last
32
+ end
25
33
 
26
- without_body = prefilled_url(
34
+ def self.compare_url_candidates(remote:, base_branch:, head_branch:, title:, body:)
35
+ truncated_body = truncate_body_to_fit(
27
36
  remote: remote,
28
37
  base_branch: base_branch,
29
38
  head_branch: head_branch,
30
39
  title: title,
31
- body: body,
32
- include_title: true,
33
- include_body: false
40
+ body: body
34
41
  )
35
- return without_body if without_body.length <= MAX_PREFILLED_URL_LENGTH
36
42
 
37
- prefilled_url(
43
+ [
44
+ prefilled_url(
45
+ remote: remote,
46
+ base_branch: base_branch,
47
+ head_branch: head_branch,
48
+ title: title,
49
+ body: body,
50
+ include_title: true,
51
+ include_body: true
52
+ ),
53
+ prefilled_url(
54
+ remote: remote,
55
+ base_branch: base_branch,
56
+ head_branch: head_branch,
57
+ title: title,
58
+ body: truncated_body,
59
+ include_title: true,
60
+ include_body: !truncated_body.to_s.empty?
61
+ ),
62
+ prefilled_url(
63
+ remote: remote,
64
+ base_branch: base_branch,
65
+ head_branch: head_branch,
66
+ title: title,
67
+ body: body,
68
+ include_title: true,
69
+ include_body: false
70
+ ),
71
+ prefilled_url(
72
+ remote: remote,
73
+ base_branch: base_branch,
74
+ head_branch: head_branch,
75
+ title: title,
76
+ body: body,
77
+ include_title: false,
78
+ include_body: false
79
+ )
80
+ ]
81
+ end
82
+ private_class_method :compare_url_candidates
83
+
84
+ def self.truncate_body_to_fit(remote:, base_branch:, head_branch:, title:, body:)
85
+ text = body.to_s
86
+ return '' if text.empty?
87
+
88
+ full_url = prefilled_url(
38
89
  remote: remote,
39
90
  base_branch: base_branch,
40
91
  head_branch: head_branch,
41
92
  title: title,
42
- body: body,
43
- include_title: false,
44
- include_body: false
93
+ body: text,
94
+ include_title: true,
95
+ include_body: true
45
96
  )
97
+ return text if full_url.length <= MAX_PREFILLED_URL_LENGTH
98
+
99
+ low = 0
100
+ high = text.length
101
+ best = ''
102
+
103
+ while low <= high
104
+ mid = (low + high) / 2
105
+ candidate_body = text[0, mid]
106
+ candidate_url = prefilled_url(
107
+ remote: remote,
108
+ base_branch: base_branch,
109
+ head_branch: head_branch,
110
+ title: title,
111
+ body: candidate_body,
112
+ include_title: true,
113
+ include_body: !candidate_body.empty?
114
+ )
115
+
116
+ if candidate_url.length <= MAX_PREFILLED_URL_LENGTH
117
+ best = candidate_body
118
+ low = mid + 1
119
+ else
120
+ high = mid - 1
121
+ end
122
+ end
123
+
124
+ best
46
125
  end
126
+ private_class_method :truncate_body_to_fit
47
127
 
48
128
  def self.prefilled_url(remote:, base_branch:, head_branch:, title:, body:, include_title:, include_body:)
49
129
  if remote[:provider] == :gitlab
@@ -115,68 +195,6 @@ module Commiti
115
195
  URI.encode_www_form_component(branch.to_s).gsub('+', '%20')
116
196
  end
117
197
 
118
- def self.extract_remote_info(origin_url)
119
- remote_text = origin_url.to_s.strip
120
- return nil if remote_text.empty?
121
-
122
- parsed = parse_uri_remote(remote_text) || parse_scp_remote(remote_text)
123
- return nil if parsed.nil?
124
-
125
- normalized = normalize_repo_path(parsed[:path])
126
- return nil if normalized.nil?
127
-
128
- provider = detect_provider(parsed[:host])
129
- return nil if provider.nil?
130
-
131
- {
132
- provider: provider,
133
- host: parsed[:host],
134
- web_scheme: parsed[:web_scheme],
135
- namespace: normalized[:namespace],
136
- repo: normalized[:repo]
137
- }
138
- end
139
-
140
- def self.parse_uri_remote(remote_text)
141
- uri = URI.parse(remote_text)
142
- return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) || uri.scheme == 'ssh'
143
- return nil if uri.host.to_s.strip.empty?
144
-
145
- web_scheme = uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) ? uri.scheme : 'https'
146
- { host: uri.host, path: uri.path, web_scheme: web_scheme }
147
- rescue URI::InvalidURIError
148
- nil
149
- end
150
-
151
- def self.parse_scp_remote(remote_text)
152
- match = remote_text.match(SCP_REMOTE)
153
- return nil if match.nil?
154
-
155
- { host: match[:host], path: match[:path], web_scheme: 'https' }
156
- end
157
-
158
- def self.normalize_repo_path(raw_path)
159
- clean = raw_path.to_s.strip
160
- clean = clean.sub(%r{\A/+}, '').sub(%r{/+\z}, '')
161
- clean = clean.sub(/\.git\z/, '')
162
- segments = clean.split('/').reject(&:empty?)
163
- return nil if segments.length < 2
164
-
165
- {
166
- namespace: segments[0..-2].join('/'),
167
- repo: segments[-1]
168
- }
169
- end
170
-
171
- def self.detect_provider(host)
172
- normalized = host.to_s.downcase
173
- return :gitlab if normalized.include?('gitlab')
174
- return :gitbucket if normalized.include?('gitbucket')
175
- return :github if normalized.include?('github')
176
-
177
- nil
178
- end
179
-
180
198
  def self.suggest_title(pr_body, head_branch:)
181
199
  in_summary = false
182
200
  pr_body.to_s.each_line do |line|
@@ -197,34 +215,7 @@ module Commiti
197
215
  end
198
216
 
199
217
  def self.open_in_browser(url)
200
- success = if windows?
201
- open_windows_browser(url)
202
- elsif mac?
203
- system('open', url)
204
- else
205
- system('xdg-open', url)
206
- end
207
-
208
- raise 'Failed to open browser for PR URL.' unless success
209
-
210
- true
211
- end
212
-
213
- def self.open_windows_browser(url)
214
- cleaned_url = url.to_s.strip.sub(/\A\\+/, '')
215
-
216
- # Prefer shell protocol handler. This bypasses cmd/explorer parsing of '&'.
217
- return true if system('rundll32', 'url.dll,FileProtocolHandler', cleaned_url)
218
-
219
- # PowerShell fallback, passing URL as an argument to avoid command parsing.
220
- system(
221
- 'powershell',
222
- '-NoProfile',
223
- '-Command',
224
- '$u=$args[0]; Start-Process -FilePath $u',
225
- '--',
226
- cleaned_url
227
- )
218
+ Commiti::PrBrowserOpener.open_in_browser(url)
228
219
  end
229
220
 
230
221
  def self.extract_owner_repo(origin_url)
@@ -233,13 +224,5 @@ module Commiti
233
224
 
234
225
  { owner: info[:namespace], repo: info[:repo] }
235
226
  end
236
-
237
- def self.windows?
238
- RUBY_PLATFORM.include?('mingw') || RUBY_PLATFORM.include?('mswin')
239
- end
240
-
241
- def self.mac?
242
- RUBY_PLATFORM.include?('darwin')
243
- end
244
227
  end
245
228
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Commiti
6
+ module PrRemoteParser
7
+ SCP_REMOTE = %r{\A(?<user>[^@]+)@(?<host>[^:\s/]+):(?<path>[^\s]+)\z}
8
+
9
+ def extract_remote_info(origin_url)
10
+ remote_text = origin_url.to_s.strip
11
+ return nil if remote_text.empty?
12
+
13
+ parsed = parse_uri_remote(remote_text) || parse_scp_remote(remote_text)
14
+ return nil if parsed.nil?
15
+
16
+ normalized = normalize_repo_path(parsed[:path])
17
+ return nil if normalized.nil?
18
+
19
+ provider = detect_provider(parsed[:host])
20
+ return nil if provider.nil?
21
+
22
+ {
23
+ provider: provider,
24
+ host: parsed[:host],
25
+ web_scheme: parsed[:web_scheme],
26
+ namespace: normalized[:namespace],
27
+ repo: normalized[:repo]
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ def parse_uri_remote(remote_text)
34
+ uri = URI.parse(remote_text)
35
+ return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) || uri.scheme == 'ssh'
36
+ return nil if uri.host.to_s.strip.empty?
37
+
38
+ web_scheme = uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) ? uri.scheme : 'https'
39
+ { host: uri.host, path: uri.path, web_scheme: web_scheme }
40
+ rescue URI::InvalidURIError
41
+ nil
42
+ end
43
+
44
+ def parse_scp_remote(remote_text)
45
+ match = remote_text.match(SCP_REMOTE)
46
+ return nil if match.nil?
47
+
48
+ { host: match[:host], path: match[:path], web_scheme: 'https' }
49
+ end
50
+
51
+ def normalize_repo_path(raw_path)
52
+ clean = raw_path.to_s.strip
53
+ clean = clean.sub(%r{\A/+}, '').sub(%r{/+\z}, '')
54
+ clean = clean.sub(/\.git\z/, '')
55
+ segments = clean.split('/').reject(&:empty?)
56
+ return nil if segments.length < 2
57
+
58
+ {
59
+ namespace: segments[0..-2].join('/'),
60
+ repo: segments[-1]
61
+ }
62
+ end
63
+
64
+ def detect_provider(host)
65
+ normalized = host.to_s.downcase
66
+ return :gitlab if normalized.include?('gitlab')
67
+ return :gitbucket if normalized.include?('gitbucket')
68
+ return :github if normalized.include?('github')
69
+
70
+ nil
71
+ end
72
+ end
73
+ end
@@ -49,18 +49,10 @@ module Commiti
49
49
  raise 'Google API key is missing. Set GOOGLE_API_KEY (or GEMINI_API_KEY) in your environment.'
50
50
  end
51
51
 
52
- def normalize_float(value, fallback)
52
+ def normalize_numeric(value, fallback)
53
53
  return fallback if value.nil? || value.to_s.strip.empty?
54
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)
55
+ yield(value)
64
56
  rescue ArgumentError
65
57
  fallback
66
58
  end
@@ -87,9 +79,10 @@ module Commiti
87
79
  {
88
80
  api_key: normalize_api_key(api_key || @config[:google_api_key]),
89
81
  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)
82
+ temperature: normalize_numeric(temperature || @config[:temperature], DEFAULT_TEMPERATURE) { |raw| Float(raw) },
83
+ timeout_seconds: normalize_numeric(timeout_seconds || @config[:timeout_seconds], DEFAULT_TIMEOUT_SECONDS) { |raw| Integer(raw) },
84
+ open_timeout_seconds: normalize_numeric(open_timeout_seconds || @config[:open_timeout_seconds],
85
+ DEFAULT_OPEN_TIMEOUT_SECONDS) { |raw| Integer(raw) }
93
86
  }
94
87
  end
95
88
 
@@ -6,22 +6,18 @@ module Commiti
6
6
  case platform
7
7
  when :mac
8
8
  IO.popen('pbcopy', 'w') { |io| io.write(text) }
9
- true
9
+ :copied
10
10
  when :linux
11
11
  if command_exists?('xclip')
12
12
  IO.popen('xclip -selection clipboard', 'w') { |io| io.write(text) }
13
- true
13
+ :copied
14
14
  elsif command_exists?('xsel')
15
15
  IO.popen('xsel --clipboard --input', 'w') { |io| io.write(text) }
16
- true
17
- else
18
- false # no clipboard tool found
16
+ :copied
19
17
  end
20
18
  when :windows
21
19
  IO.popen('clip', 'w') { |io| io.write(text) }
22
- true
23
- else
24
- false
20
+ :copied
25
21
  end
26
22
  end
27
23
 
@@ -1,9 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'yaml'
4
+ require_relative '../text_generation_style'
5
+
3
6
  module Commiti
4
7
  class ConfigLoader
8
+ DEFAULT_TEXT_GENERATION_CONFIG = Commiti::TextGenerationStyle::DEFAULT_CONFIG
9
+
5
10
  DEFAULT_CONFIG = {
6
11
  google_api_key: nil,
12
+ github_token: nil,
13
+ gitlab_token: nil,
14
+ gitbucket_token: nil,
7
15
  model: Commiti::GoogleClient::DEFAULT_MODEL,
8
16
  candidates: 1,
9
17
  base_branch: 'main',
@@ -11,14 +19,18 @@ module Commiti
11
19
  auto_split: false,
12
20
  temperature: Commiti::GoogleClient::DEFAULT_TEMPERATURE,
13
21
  timeout_seconds: Commiti::GoogleClient::DEFAULT_TIMEOUT_SECONDS,
14
- open_timeout_seconds: Commiti::GoogleClient::DEFAULT_OPEN_TIMEOUT_SECONDS
22
+ open_timeout_seconds: Commiti::GoogleClient::DEFAULT_OPEN_TIMEOUT_SECONDS,
23
+ text_generation: DEFAULT_TEXT_GENERATION_CONFIG
15
24
  }.freeze
16
25
 
17
26
  # Loads configuration from environment variables.
18
27
  # Keys are returned as symbols with parsed values.
19
- def self.load(env: ENV)
20
- {
28
+ def self.load(env: ENV, cwd: Dir.pwd)
29
+ DEFAULT_CONFIG.merge(
21
30
  google_api_key: google_api_key_from_env(env),
31
+ github_token: present_or_nil(env.fetch('COMMITI_GITHUB_TOKEN', nil)),
32
+ gitlab_token: present_or_nil(env.fetch('COMMITI_GITLAB_TOKEN', nil)),
33
+ gitbucket_token: present_or_nil(env.fetch('COMMITI_GITBUCKET_TOKEN', nil)),
22
34
  model: present_or_default(env.fetch('COMMITI_MODEL', nil), DEFAULT_CONFIG[:model]),
23
35
  candidates: integer_or_default(env.fetch('COMMITI_CANDIDATES', nil), DEFAULT_CONFIG[:candidates]),
24
36
  base_branch: present_or_default(env.fetch('COMMITI_BASE_BRANCH', nil), DEFAULT_CONFIG[:base_branch]),
@@ -27,9 +39,34 @@ module Commiti
27
39
  temperature: float_or_default(env.fetch('COMMITI_MODEL_TEMPERATURE', nil), DEFAULT_CONFIG[:temperature]),
28
40
  timeout_seconds: integer_or_default(env.fetch('COMMITI_MODEL_TIMEOUT_SECONDS', nil), DEFAULT_CONFIG[:timeout_seconds]),
29
41
  open_timeout_seconds: integer_or_default(env.fetch('COMMITI_MODEL_OPEN_TIMEOUT_SECONDS', nil),
30
- DEFAULT_CONFIG[:open_timeout_seconds])
31
- }
42
+ DEFAULT_CONFIG[:open_timeout_seconds]),
43
+ text_generation: load_text_generation_config(env: env, cwd: cwd)
44
+ )
45
+ end
46
+
47
+ def self.load_text_generation_config(env:, cwd:)
48
+ config_path = configured_path(env: env, cwd: cwd)
49
+ project_config = read_yaml_config(config_path)
50
+ Commiti::TextGenerationStyle.normalize(project_config)
51
+ end
52
+ private_class_method :load_text_generation_config
53
+
54
+ def self.configured_path(env:, cwd:)
55
+ configured = present_or_nil(env.fetch('COMMITI_CONFIG', nil))
56
+ path = configured || File.join(cwd, '.commiti.yml')
57
+ File.expand_path(path, cwd)
58
+ end
59
+ private_class_method :configured_path
60
+
61
+ def self.read_yaml_config(path)
62
+ return {} unless File.file?(path)
63
+
64
+ raw = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], aliases: false)
65
+ raw.is_a?(Hash) ? raw : {}
66
+ rescue StandardError
67
+ {}
32
68
  end
69
+ private_class_method :read_yaml_config
33
70
 
34
71
  def self.google_api_key_from_env(env)
35
72
  present_or_nil(env.fetch('GOOGLE_API_KEY', nil)) ||