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.
- checksums.yaml +4 -4
- data/README.md +70 -8
- data/lib/commiti.rb +3 -0
- data/lib/flows/base_flow.rb +7 -2
- data/lib/flows/commit_flow.rb +6 -4
- data/lib/flows/pr_flow.rb +39 -11
- data/lib/services/diff_summarization/fallback_builder.rb +69 -30
- data/lib/services/flow_context_builder.rb +3 -2
- data/lib/services/git/commit/commit_execution.rb +47 -28
- data/lib/services/git/commit/commit_staging.rb +3 -3
- data/lib/services/git/git_reader.rb +29 -19
- data/lib/services/git/pr/browser_opener.rb +44 -0
- data/lib/services/git/pr/pr_creator.rb +167 -0
- data/lib/services/git/pr/pr_opener.rb +96 -113
- data/lib/services/git/pr/remote_parser.rb +73 -0
- data/lib/services/google_client.rb +6 -13
- data/lib/services/helpers/clipboard.rb +4 -8
- data/lib/services/helpers/config_loader.rb +42 -5
- data/lib/services/helpers/interactive_prompt.rb +5 -5
- data/lib/services/helpers/prompt_builder.rb +44 -3
- data/lib/services/helpers/spinner.rb +19 -11
- data/lib/services/helpers/terminal_ui.rb +58 -0
- data/lib/services/message_generator.rb +43 -132
- data/lib/services/message_generator_support.rb +111 -0
- data/lib/services/message_presenter.rb +10 -8
- data/lib/services/text_generation_style.rb +172 -0
- metadata +10 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
43
|
-
include_title:
|
|
44
|
-
include_body:
|
|
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
|
-
|
|
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
|
|
52
|
+
def normalize_numeric(value, fallback)
|
|
53
53
|
return fallback if value.nil? || value.to_s.strip.empty?
|
|
54
54
|
|
|
55
|
-
|
|
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:
|
|
91
|
-
timeout_seconds:
|
|
92
|
-
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
|
-
|
|
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
|
-
|
|
13
|
+
:copied
|
|
14
14
|
elsif command_exists?('xsel')
|
|
15
15
|
IO.popen('xsel --clipboard --input', 'w') { |io| io.write(text) }
|
|
16
|
-
|
|
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
|
-
|
|
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)) ||
|