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.
@@ -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
- next
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.each do |line|
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
- return output if hunks.empty?
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
- hunk_header = hunk.first
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
- output
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