commiti 1.3.1 → 1.3.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 +4 -4
- data/README.md +74 -8
- data/bin/commiti +6 -0
- data/lib/commiti.rb +3 -0
- data/lib/flows/base_flow.rb +7 -2
- data/lib/flows/commit_flow.rb +7 -3
- 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/change_grouping.rb +6 -0
- data/lib/services/git/commit/commit_execution.rb +45 -26
- data/lib/services/git/commit/commit_staging.rb +1 -1
- data/lib/services/git/commit/group_editor.rb +254 -0
- 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 +19 -9
- data/lib/services/helpers/prompt_builder.rb +44 -3
- data/lib/services/helpers/spinner.rb +4 -2
- data/lib/services/helpers/terminal_ui.rb +104 -8
- data/lib/services/message_generator.rb +46 -135
- data/lib/services/message_generator_support.rb +111 -0
- data/lib/services/message_presenter.rb +3 -8
- data/lib/services/text_generation_style.rb +172 -0
- metadata +10 -4
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commiti
|
|
4
|
+
module GroupEditor
|
|
5
|
+
HELP_TEXT = <<~TEXT.freeze
|
|
6
|
+
Select files by number, then choose where to move them.
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
1,3,5 (select files 1, 3, 5)
|
|
10
|
+
2-4 (select files 2 through 4)
|
|
11
|
+
|
|
12
|
+
Targets:
|
|
13
|
+
1..N move to an existing group
|
|
14
|
+
n new group
|
|
15
|
+
a auto-reassign (best matching group or new)
|
|
16
|
+
c cancel
|
|
17
|
+
TEXT
|
|
18
|
+
|
|
19
|
+
def self.edit(groups)
|
|
20
|
+
return groups if groups.empty?
|
|
21
|
+
return groups unless Commiti::InteractivePrompt.ask_yes_no('Edit auto-split groups before committing?', default: :no)
|
|
22
|
+
|
|
23
|
+
working = deep_copy(groups)
|
|
24
|
+
chunk_map = build_chunk_map(working)
|
|
25
|
+
all_paths = working.flat_map { |group| group[:files] }.uniq
|
|
26
|
+
path_order = all_paths.each_with_index.to_h
|
|
27
|
+
|
|
28
|
+
puts "\n#{Commiti::TerminalUI.panel('Group editor', HELP_TEXT)}\n"
|
|
29
|
+
|
|
30
|
+
loop do
|
|
31
|
+
index_map, indexed_groups = index_groups(working)
|
|
32
|
+
print_groups(indexed_groups, total: working.length)
|
|
33
|
+
|
|
34
|
+
input = Commiti::InteractivePrompt.ask_text('Move which files? (numbers, Enter to finish)')
|
|
35
|
+
break if input.nil? || input.empty?
|
|
36
|
+
|
|
37
|
+
selected_indices = parse_indices(input, max_index: index_map.length)
|
|
38
|
+
if selected_indices.empty?
|
|
39
|
+
puts Commiti::TerminalUI.status(:warn, 'No valid file numbers selected.')
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
selected_paths = selected_indices.map { |index| index_map[index] }.compact
|
|
44
|
+
target = Commiti::InteractivePrompt.ask_text(
|
|
45
|
+
"Move to group [1-#{working.length}], n=new, a=auto, c=cancel"
|
|
46
|
+
).to_s.strip.downcase
|
|
47
|
+
|
|
48
|
+
next if target.empty? || target == 'c'
|
|
49
|
+
|
|
50
|
+
case target
|
|
51
|
+
when 'a'
|
|
52
|
+
auto_reassign(working, selected_paths, chunk_map)
|
|
53
|
+
when 'n'
|
|
54
|
+
move_to_new_group(working, selected_paths, chunk_map)
|
|
55
|
+
else
|
|
56
|
+
group_index = integer_or_nil(target)
|
|
57
|
+
unless group_index && working[group_index - 1]
|
|
58
|
+
puts Commiti::TerminalUI.status(:warn, "Group #{target} does not exist.")
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
move_to_group(working, group_index, selected_paths)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
normalize_groups(working, chunk_map, path_order)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
normalize_groups(working, chunk_map, path_order)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.print_groups(indexed_groups, total:)
|
|
72
|
+
panels = indexed_groups.map do |group|
|
|
73
|
+
title = "Group #{group[:index]}/#{total}"
|
|
74
|
+
body = if group[:entries].empty?
|
|
75
|
+
Commiti::TerminalUI.muted('No files')
|
|
76
|
+
else
|
|
77
|
+
group[:entries].map { |entry| "#{entry[:index]}. #{entry[:path]}" }.join("\n")
|
|
78
|
+
end
|
|
79
|
+
Commiti::TerminalUI.panel(title, body)
|
|
80
|
+
end
|
|
81
|
+
puts "\n#{panels.join("\n\n")}\n"
|
|
82
|
+
end
|
|
83
|
+
private_class_method :print_groups
|
|
84
|
+
|
|
85
|
+
def self.index_groups(groups)
|
|
86
|
+
index_map = {}
|
|
87
|
+
indexed_groups = []
|
|
88
|
+
counter = 1
|
|
89
|
+
|
|
90
|
+
groups.each_with_index do |group, index|
|
|
91
|
+
entries = group[:files].map do |path|
|
|
92
|
+
entry = { index: counter, path: path }
|
|
93
|
+
index_map[counter] = path
|
|
94
|
+
counter += 1
|
|
95
|
+
entry
|
|
96
|
+
end
|
|
97
|
+
indexed_groups << { index: index + 1, entries: entries }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
[index_map, indexed_groups]
|
|
101
|
+
end
|
|
102
|
+
private_class_method :index_groups
|
|
103
|
+
|
|
104
|
+
def self.parse_indices(text, max_index:)
|
|
105
|
+
raw = text.to_s.strip
|
|
106
|
+
return [] if raw.empty?
|
|
107
|
+
|
|
108
|
+
indices = []
|
|
109
|
+
invalid_tokens = []
|
|
110
|
+
|
|
111
|
+
raw.split(/[,\s]+/).each do |token|
|
|
112
|
+
next if token.empty?
|
|
113
|
+
|
|
114
|
+
if token.match?(/\A\d+-\d+\z/)
|
|
115
|
+
start_value, end_value = token.split('-').map(&:to_i)
|
|
116
|
+
range = start_value <= end_value ? (start_value..end_value) : (end_value..start_value)
|
|
117
|
+
indices.concat(range.to_a)
|
|
118
|
+
elsif token.match?(/\A\d+\z/)
|
|
119
|
+
indices << token.to_i
|
|
120
|
+
else
|
|
121
|
+
invalid_tokens << token
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
invalid_tokens.each do |token|
|
|
126
|
+
puts Commiti::TerminalUI.status(:warn, "Invalid token: #{token}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
indices = indices.uniq
|
|
130
|
+
valid = indices.select { |value| value.between?(1, max_index) }
|
|
131
|
+
(indices - valid).each do |value|
|
|
132
|
+
puts Commiti::TerminalUI.status(:warn, "File number #{value} is out of range.")
|
|
133
|
+
end
|
|
134
|
+
valid
|
|
135
|
+
end
|
|
136
|
+
private_class_method :parse_indices
|
|
137
|
+
|
|
138
|
+
def self.move_to_group(groups, group_index, paths)
|
|
139
|
+
target = groups[group_index - 1]
|
|
140
|
+
paths.each do |path|
|
|
141
|
+
remove_path_from_groups(groups, path)
|
|
142
|
+
target[:files] << path unless target[:files].include?(path)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
private_class_method :move_to_group
|
|
146
|
+
|
|
147
|
+
def self.move_to_new_group(groups, paths, chunk_map)
|
|
148
|
+
paths.each { |path| remove_path_from_groups(groups, path) }
|
|
149
|
+
new_groups = build_groups_for_paths(paths, chunk_map)
|
|
150
|
+
groups.concat(new_groups)
|
|
151
|
+
end
|
|
152
|
+
private_class_method :move_to_new_group
|
|
153
|
+
|
|
154
|
+
def self.auto_reassign(groups, paths, chunk_map)
|
|
155
|
+
origin_groups = find_origin_groups(groups, paths)
|
|
156
|
+
paths.each { |path| remove_path_from_groups(groups, path) }
|
|
157
|
+
|
|
158
|
+
remaining = []
|
|
159
|
+
paths.each do |path|
|
|
160
|
+
excluded = origin_groups[path] ? [origin_groups[path]] : []
|
|
161
|
+
target = best_matching_group(path, groups, excluded_groups: excluded)
|
|
162
|
+
if target
|
|
163
|
+
target[:files] << path unless target[:files].include?(path)
|
|
164
|
+
else
|
|
165
|
+
remaining << path
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
groups.concat(build_groups_for_paths(remaining, chunk_map)) if remaining.any?
|
|
170
|
+
end
|
|
171
|
+
private_class_method :auto_reassign
|
|
172
|
+
|
|
173
|
+
def self.find_origin_groups(groups, paths)
|
|
174
|
+
origin = {}
|
|
175
|
+
groups.each do |group|
|
|
176
|
+
group[:files].each do |path|
|
|
177
|
+
origin[path] = group if paths.include?(path)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
origin
|
|
181
|
+
end
|
|
182
|
+
private_class_method :find_origin_groups
|
|
183
|
+
|
|
184
|
+
def self.remove_path_from_groups(groups, path)
|
|
185
|
+
groups.each { |group| group[:files].delete(path) }
|
|
186
|
+
end
|
|
187
|
+
private_class_method :remove_path_from_groups
|
|
188
|
+
|
|
189
|
+
def self.best_matching_group(path, groups, excluded_groups:)
|
|
190
|
+
candidates = groups - excluded_groups
|
|
191
|
+
best = nil
|
|
192
|
+
best_score = 0
|
|
193
|
+
|
|
194
|
+
candidates.each do |group|
|
|
195
|
+
score = group[:files].count { |existing| Commiti::ChangeGrouping.related?(path, existing) }
|
|
196
|
+
next if score <= best_score
|
|
197
|
+
|
|
198
|
+
best_score = score
|
|
199
|
+
best = group
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
best_score.positive? ? best : nil
|
|
203
|
+
end
|
|
204
|
+
private_class_method :best_matching_group
|
|
205
|
+
|
|
206
|
+
def self.build_groups_for_paths(paths, chunk_map)
|
|
207
|
+
line_chunks = paths.map { |path| chunk_map[path] }.compact
|
|
208
|
+
return [] if line_chunks.empty?
|
|
209
|
+
|
|
210
|
+
Commiti::ChangeGrouping.group(line_chunks).map do |group|
|
|
211
|
+
{
|
|
212
|
+
id: group[:id],
|
|
213
|
+
files: group[:files],
|
|
214
|
+
chunks: group[:chunks]
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
private_class_method :build_groups_for_paths
|
|
219
|
+
|
|
220
|
+
def self.build_chunk_map(groups)
|
|
221
|
+
groups.flat_map { |group| group[:chunks] }
|
|
222
|
+
.each_with_object({}) { |chunk, acc| acc[chunk[:path]] = chunk }
|
|
223
|
+
end
|
|
224
|
+
private_class_method :build_chunk_map
|
|
225
|
+
|
|
226
|
+
def self.normalize_groups(groups, chunk_map, path_order)
|
|
227
|
+
groups.reject! { |group| group[:files].empty? }
|
|
228
|
+
groups.each do |group|
|
|
229
|
+
group[:files] = group[:files].uniq.sort_by { |path| path_order[path] || path_order.length }
|
|
230
|
+
group[:chunks] = group[:files].filter_map { |path| chunk_map[path] }
|
|
231
|
+
end
|
|
232
|
+
groups.each_with_index { |group, index| group[:id] = index + 1 }
|
|
233
|
+
end
|
|
234
|
+
private_class_method :normalize_groups
|
|
235
|
+
|
|
236
|
+
def self.integer_or_nil(value)
|
|
237
|
+
return nil unless value.to_s.match?(/\A\d+\z/)
|
|
238
|
+
|
|
239
|
+
value.to_i
|
|
240
|
+
end
|
|
241
|
+
private_class_method :integer_or_nil
|
|
242
|
+
|
|
243
|
+
def self.deep_copy(groups)
|
|
244
|
+
groups.map do |group|
|
|
245
|
+
{
|
|
246
|
+
id: group[:id],
|
|
247
|
+
files: group[:files].dup,
|
|
248
|
+
chunks: group[:chunks].map(&:dup)
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
private_class_method :deep_copy
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -124,34 +124,44 @@ module Commiti
|
|
|
124
124
|
output = +''
|
|
125
125
|
return output if max_bytes <= 0
|
|
126
126
|
|
|
127
|
+
header_lines, hunks = partition_chunk_lines(lines)
|
|
128
|
+
append_lines_with_limit(output, header_lines, max_bytes: max_bytes)
|
|
129
|
+
return output if hunks.empty?
|
|
130
|
+
|
|
131
|
+
append_hunks_with_limit(output, hunks, max_bytes: max_bytes)
|
|
132
|
+
output
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.partition_chunk_lines(lines)
|
|
127
136
|
header_lines = []
|
|
128
137
|
hunks = []
|
|
129
138
|
current_hunk = nil
|
|
130
|
-
in_hunks = false
|
|
131
139
|
|
|
132
140
|
lines.each do |line|
|
|
133
141
|
if line.start_with?('@@')
|
|
134
|
-
in_hunks = true
|
|
135
142
|
current_hunk = [line]
|
|
136
143
|
hunks << current_hunk
|
|
137
|
-
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
if in_hunks
|
|
144
|
+
elsif current_hunk
|
|
141
145
|
current_hunk << line
|
|
142
146
|
else
|
|
143
147
|
header_lines << line
|
|
144
148
|
end
|
|
145
149
|
end
|
|
146
150
|
|
|
147
|
-
header_lines
|
|
151
|
+
[header_lines, hunks]
|
|
152
|
+
end
|
|
153
|
+
private_class_method :partition_chunk_lines
|
|
154
|
+
|
|
155
|
+
def self.append_lines_with_limit(output, lines, max_bytes:)
|
|
156
|
+
lines.each do |line|
|
|
148
157
|
break if output.bytesize + line.bytesize > max_bytes
|
|
149
158
|
|
|
150
159
|
output << line
|
|
151
160
|
end
|
|
161
|
+
end
|
|
162
|
+
private_class_method :append_lines_with_limit
|
|
152
163
|
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
def self.append_hunks_with_limit(output, hunks, max_bytes:)
|
|
155
165
|
hunks.each do |hunk|
|
|
156
166
|
hunk_text = hunk.join
|
|
157
167
|
if output.bytesize + hunk_text.bytesize <= max_bytes
|
|
@@ -159,20 +169,20 @@ module Commiti
|
|
|
159
169
|
next
|
|
160
170
|
end
|
|
161
171
|
|
|
162
|
-
|
|
163
|
-
break if output.bytesize + hunk_header.bytesize > max_bytes
|
|
164
|
-
|
|
165
|
-
output << hunk_header
|
|
166
|
-
hunk[1..].to_a.each do |line|
|
|
167
|
-
break if output.bytesize + line.bytesize > max_bytes
|
|
168
|
-
|
|
169
|
-
output << line
|
|
170
|
-
end
|
|
172
|
+
append_partial_hunk(output, hunk, max_bytes: max_bytes)
|
|
171
173
|
break
|
|
172
174
|
end
|
|
175
|
+
end
|
|
176
|
+
private_class_method :append_hunks_with_limit
|
|
173
177
|
|
|
174
|
-
|
|
178
|
+
def self.append_partial_hunk(output, hunk, max_bytes:)
|
|
179
|
+
hunk_header = hunk.first
|
|
180
|
+
return if output.bytesize + hunk_header.bytesize > max_bytes
|
|
181
|
+
|
|
182
|
+
output << hunk_header
|
|
183
|
+
append_lines_with_limit(output, hunk[1..].to_a, max_bytes: max_bytes)
|
|
175
184
|
end
|
|
185
|
+
private_class_method :append_partial_hunk
|
|
176
186
|
|
|
177
187
|
def self.append_notice(clipped_diff, max_bytes:)
|
|
178
188
|
safe_clipped = clipped_diff.to_s
|
|
@@ -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
|