n2b 0.5.1 → 0.7.1
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 +374 -23
- data/bin/n2b-diff +5 -0
- data/bin/n2b-test-jira +17 -14
- data/lib/n2b/cli.rb +137 -72
- data/lib/n2b/jira_client.rb +377 -17
- data/lib/n2b/llm/claude.rb +6 -19
- data/lib/n2b/llm/gemini.rb +8 -7
- data/lib/n2b/llm/open_ai.rb +26 -20
- data/lib/n2b/merge_cli.rb +694 -0
- data/lib/n2b/merge_conflict_parser.rb +70 -0
- data/lib/n2b/template_engine.rb +105 -0
- data/lib/n2b/templates/diff_json_instruction.txt +27 -0
- data/lib/n2b/templates/diff_system_prompt.txt +23 -0
- data/lib/n2b/templates/jira_comment.txt +67 -0
- data/lib/n2b/templates/merge_conflict_prompt.txt +20 -0
- data/lib/n2b/version.rb +1 -1
- data/lib/n2b.rb +3 -0
- metadata +34 -7
@@ -0,0 +1,694 @@
|
|
1
|
+
require 'shellwords'
|
2
|
+
require 'rbconfig'
|
3
|
+
|
4
|
+
module N2B
|
5
|
+
class MergeCLI < Base
|
6
|
+
COLOR_RED = "\e[31m"
|
7
|
+
COLOR_GREEN = "\e[32m"
|
8
|
+
COLOR_YELLOW= "\e[33m"
|
9
|
+
COLOR_BLUE = "\e[34m"
|
10
|
+
COLOR_GRAY = "\e[90m"
|
11
|
+
COLOR_RESET = "\e[0m"
|
12
|
+
|
13
|
+
def self.run(args)
|
14
|
+
new(args).execute
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(args)
|
18
|
+
@args = args
|
19
|
+
@options = parse_options
|
20
|
+
@file_path = @args.shift
|
21
|
+
end
|
22
|
+
|
23
|
+
def execute
|
24
|
+
if @file_path.nil?
|
25
|
+
show_usage_and_unresolved
|
26
|
+
exit 1
|
27
|
+
end
|
28
|
+
|
29
|
+
unless File.exist?(@file_path)
|
30
|
+
puts "File not found: #{@file_path}"
|
31
|
+
exit 1
|
32
|
+
end
|
33
|
+
|
34
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
35
|
+
|
36
|
+
parser = MergeConflictParser.new(context_lines: @options[:context_lines])
|
37
|
+
blocks = parser.parse(@file_path)
|
38
|
+
if blocks.empty?
|
39
|
+
puts "No merge conflicts found."
|
40
|
+
return
|
41
|
+
end
|
42
|
+
|
43
|
+
lines = File.readlines(@file_path, chomp: true)
|
44
|
+
log_entries = []
|
45
|
+
aborted = false
|
46
|
+
|
47
|
+
blocks.reverse_each do |block|
|
48
|
+
result = resolve_block(block, config, lines.join("\n"))
|
49
|
+
log_entries << result.merge({
|
50
|
+
base_content: block.base_content,
|
51
|
+
incoming_content: block.incoming_content,
|
52
|
+
base_label: block.base_label,
|
53
|
+
incoming_label: block.incoming_label
|
54
|
+
})
|
55
|
+
if result[:abort]
|
56
|
+
aborted = true
|
57
|
+
break
|
58
|
+
elsif result[:accepted]
|
59
|
+
replacement = result[:merged_code].to_s.split("\n")
|
60
|
+
lines[(block.start_line-1)...block.end_line] = replacement
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
unless aborted
|
65
|
+
File.write(@file_path, lines.join("\n") + "\n")
|
66
|
+
|
67
|
+
# Show summary
|
68
|
+
accepted_count = log_entries.count { |entry| entry[:accepted] }
|
69
|
+
skipped_count = log_entries.count { |entry| !entry[:accepted] && !entry[:abort] }
|
70
|
+
|
71
|
+
puts "\n#{COLOR_BLUE}📊 Resolution Summary:#{COLOR_RESET}"
|
72
|
+
puts "#{COLOR_GREEN}✅ Accepted: #{accepted_count}#{COLOR_RESET}"
|
73
|
+
puts "#{COLOR_YELLOW}⏭️ Skipped: #{skipped_count}#{COLOR_RESET}" if skipped_count > 0
|
74
|
+
|
75
|
+
# Only auto-mark as resolved if ALL conflicts were accepted (none skipped)
|
76
|
+
if accepted_count > 0 && skipped_count == 0
|
77
|
+
mark_file_as_resolved(@file_path)
|
78
|
+
puts "#{COLOR_GREEN}🎉 All conflicts resolved! File marked as resolved in VCS.#{COLOR_RESET}"
|
79
|
+
elsif accepted_count > 0 && skipped_count > 0
|
80
|
+
puts "#{COLOR_YELLOW}⚠️ Some conflicts were skipped - file NOT marked as resolved#{COLOR_RESET}"
|
81
|
+
puts "#{COLOR_GRAY}💡 Resolve remaining conflicts or manually mark: hg resolve --mark #{@file_path}#{COLOR_RESET}"
|
82
|
+
else
|
83
|
+
puts "#{COLOR_YELLOW}⚠️ No conflicts were accepted - file NOT marked as resolved#{COLOR_RESET}"
|
84
|
+
end
|
85
|
+
else
|
86
|
+
puts "\n#{COLOR_YELLOW}⚠️ Resolution aborted - no changes made#{COLOR_RESET}"
|
87
|
+
end
|
88
|
+
|
89
|
+
if config['merge_log_enabled'] && log_entries.any?
|
90
|
+
dir = '.n2b_merge_log'
|
91
|
+
FileUtils.mkdir_p(dir)
|
92
|
+
timestamp = Time.now.strftime('%Y-%m-%d-%H%M%S')
|
93
|
+
log_path = File.join(dir, "#{timestamp}.json")
|
94
|
+
File.write(log_path, JSON.pretty_generate({file: @file_path, timestamp: Time.now, entries: log_entries}))
|
95
|
+
puts "#{COLOR_GRAY}📝 Merge log saved to #{log_path}#{COLOR_RESET}"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def parse_options
|
102
|
+
options = { context_lines: MergeConflictParser::DEFAULT_CONTEXT_LINES }
|
103
|
+
parser = OptionParser.new do |opts|
|
104
|
+
opts.banner = 'Usage: n2b-diff FILE [options]'
|
105
|
+
opts.on('--context N', Integer, 'Context lines (default: 10)') { |v| options[:context_lines] = v }
|
106
|
+
opts.on('-h', '--help', 'Show this help') { puts opts; exit }
|
107
|
+
end
|
108
|
+
parser.parse!(@args)
|
109
|
+
options
|
110
|
+
end
|
111
|
+
|
112
|
+
def resolve_block(block, config, full_file_content)
|
113
|
+
comment = nil
|
114
|
+
|
115
|
+
# Display file and line information
|
116
|
+
puts "\n#{COLOR_BLUE}📁 File: #{@file_path}#{COLOR_RESET}"
|
117
|
+
puts "#{COLOR_BLUE}📍 Lines: #{block.start_line}-#{block.end_line} (#{block.base_label} ↔ #{block.incoming_label})#{COLOR_RESET}"
|
118
|
+
puts "#{COLOR_GRAY}💡 You can check this conflict in your editor at the specified line numbers#{COLOR_RESET}\n"
|
119
|
+
|
120
|
+
puts "#{COLOR_YELLOW}🤖 AI is analyzing the conflict...#{COLOR_RESET}"
|
121
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
122
|
+
puts "#{COLOR_GREEN}✅ Initial suggestion ready!#{COLOR_RESET}\n"
|
123
|
+
|
124
|
+
loop do
|
125
|
+
print_conflict(block)
|
126
|
+
print_suggestion(suggestion)
|
127
|
+
print "#{COLOR_YELLOW}Accept [y], Skip [n], Comment [c], Edit [e], Abort [a] (explicit choice required): #{COLOR_RESET}"
|
128
|
+
choice = $stdin.gets&.strip&.downcase
|
129
|
+
|
130
|
+
case choice
|
131
|
+
when 'y'
|
132
|
+
return {accepted: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
133
|
+
when 'n'
|
134
|
+
return {accepted: false, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
135
|
+
when 'c'
|
136
|
+
puts 'Enter comment (end with blank line):'
|
137
|
+
comment = read_multiline_input
|
138
|
+
puts "#{COLOR_YELLOW}🤖 AI is analyzing your comment and generating new suggestion...#{COLOR_RESET}"
|
139
|
+
# Re-read file content in case it was edited previously
|
140
|
+
fresh_file_content = File.read(@file_path)
|
141
|
+
suggestion = request_merge_with_spinner(block, config, comment, fresh_file_content)
|
142
|
+
puts "#{COLOR_GREEN}✅ New suggestion ready!#{COLOR_RESET}\n"
|
143
|
+
when 'e'
|
144
|
+
edit_result = handle_editor_workflow(block, config, full_file_content)
|
145
|
+
if edit_result[:resolved]
|
146
|
+
return {accepted: true, merged_code: edit_result[:merged_code], reason: edit_result[:reason], comment: comment}
|
147
|
+
elsif edit_result[:updated_content]
|
148
|
+
# File was changed but conflict not resolved, update content for future LLM calls
|
149
|
+
full_file_content = edit_result[:updated_content]
|
150
|
+
end
|
151
|
+
# Continue the loop with potentially updated content
|
152
|
+
when 'a'
|
153
|
+
return {abort: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
154
|
+
when '', nil
|
155
|
+
puts "#{COLOR_RED}Please enter a valid choice: y/n/c/e/a#{COLOR_RESET}"
|
156
|
+
else
|
157
|
+
puts "#{COLOR_RED}Invalid option. Please enter: y (accept), n (skip), c (comment), e (edit), or a (abort)#{COLOR_RESET}"
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def request_merge(block, config, comment, full_file_content)
|
163
|
+
prompt = build_merge_prompt(block, comment, full_file_content)
|
164
|
+
json_str = call_llm_for_merge(prompt, config)
|
165
|
+
|
166
|
+
begin
|
167
|
+
parsed = JSON.parse(extract_json(json_str))
|
168
|
+
|
169
|
+
# Validate the response structure
|
170
|
+
unless parsed.is_a?(Hash) && parsed.key?('merged_code') && parsed.key?('reason')
|
171
|
+
raise JSON::ParserError, "Response missing required keys 'merged_code' and 'reason'"
|
172
|
+
end
|
173
|
+
|
174
|
+
parsed
|
175
|
+
rescue JSON::ParserError => e
|
176
|
+
# First try automatic JSON repair
|
177
|
+
puts "#{COLOR_YELLOW}⚠️ Invalid JSON detected, attempting automatic repair...#{COLOR_RESET}"
|
178
|
+
repaired_response = attempt_json_repair(json_str, config)
|
179
|
+
|
180
|
+
if repaired_response
|
181
|
+
puts "#{COLOR_GREEN}✅ JSON repair successful!#{COLOR_RESET}"
|
182
|
+
return repaired_response
|
183
|
+
else
|
184
|
+
puts "#{COLOR_RED}❌ JSON repair failed#{COLOR_RESET}"
|
185
|
+
handle_invalid_llm_response(json_str, e, block)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def request_merge_with_spinner(block, config, comment, full_file_content)
|
191
|
+
spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
192
|
+
spinner_thread = Thread.new do
|
193
|
+
i = 0
|
194
|
+
while true
|
195
|
+
print "\r#{COLOR_BLUE}#{spinner_chars[i % spinner_chars.length]} Processing...#{COLOR_RESET}"
|
196
|
+
$stdout.flush
|
197
|
+
sleep(0.1)
|
198
|
+
i += 1
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
begin
|
203
|
+
result = request_merge(block, config, comment, full_file_content)
|
204
|
+
spinner_thread.kill
|
205
|
+
print "\r#{' ' * 20}\r" # Clear the spinner line
|
206
|
+
result
|
207
|
+
rescue => e
|
208
|
+
spinner_thread.kill
|
209
|
+
print "\r#{' ' * 20}\r" # Clear the spinner line
|
210
|
+
{ 'merged_code' => '', 'reason' => "Error: #{e.message}" }
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def build_merge_prompt(block, comment, full_file_content)
|
215
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
216
|
+
template_path = resolve_template_path('merge_conflict_prompt', config)
|
217
|
+
template = File.read(template_path)
|
218
|
+
|
219
|
+
user_comment_text = comment && !comment.empty? ? "User comment: #{comment}" : ""
|
220
|
+
|
221
|
+
template.gsub('{full_file_content}', full_file_content.to_s)
|
222
|
+
.gsub('{context_before}', block.context_before.to_s)
|
223
|
+
.gsub('{base_label}', block.base_label.to_s)
|
224
|
+
.gsub('{base_content}', block.base_content.to_s)
|
225
|
+
.gsub('{incoming_content}', block.incoming_content.to_s)
|
226
|
+
.gsub('{incoming_label}', block.incoming_label.to_s)
|
227
|
+
.gsub('{context_after}', block.context_after.to_s)
|
228
|
+
.gsub('{user_comment}', user_comment_text)
|
229
|
+
end
|
230
|
+
|
231
|
+
def call_llm_for_merge(prompt, config)
|
232
|
+
llm_service_name = config['llm']
|
233
|
+
llm = case llm_service_name
|
234
|
+
when 'openai'
|
235
|
+
N2M::Llm::OpenAi.new(config)
|
236
|
+
when 'claude'
|
237
|
+
N2M::Llm::Claude.new(config)
|
238
|
+
when 'gemini'
|
239
|
+
N2M::Llm::Gemini.new(config)
|
240
|
+
when 'openrouter'
|
241
|
+
N2M::Llm::OpenRouter.new(config)
|
242
|
+
when 'ollama'
|
243
|
+
N2M::Llm::Ollama.new(config)
|
244
|
+
else
|
245
|
+
raise N2B::Error, "Unsupported LLM service: #{llm_service_name}"
|
246
|
+
end
|
247
|
+
llm.analyze_code_diff(prompt)
|
248
|
+
rescue N2B::LlmApiError => e
|
249
|
+
puts "\n#{COLOR_RED}❌ LLM API Error#{COLOR_RESET}"
|
250
|
+
puts "#{COLOR_YELLOW}Failed to communicate with the AI service.#{COLOR_RESET}"
|
251
|
+
puts "#{COLOR_GRAY}Error: #{e.message}#{COLOR_RESET}"
|
252
|
+
|
253
|
+
# Check for common error types and provide specific guidance
|
254
|
+
if e.message.include?('model') || e.message.include?('Model') || e.message.include?('invalid') || e.message.include?('not found')
|
255
|
+
puts "\n#{COLOR_BLUE}💡 This looks like a model configuration issue.#{COLOR_RESET}"
|
256
|
+
puts "#{COLOR_YELLOW}Run 'n2b -c' to reconfigure your model settings.#{COLOR_RESET}"
|
257
|
+
elsif e.message.include?('auth') || e.message.include?('unauthorized') || e.message.include?('401')
|
258
|
+
puts "\n#{COLOR_BLUE}💡 This looks like an authentication issue.#{COLOR_RESET}"
|
259
|
+
puts "#{COLOR_YELLOW}Check your API key configuration with 'n2b -c'.#{COLOR_RESET}"
|
260
|
+
elsif e.message.include?('timeout') || e.message.include?('network')
|
261
|
+
puts "\n#{COLOR_BLUE}💡 This looks like a network issue.#{COLOR_RESET}"
|
262
|
+
puts "#{COLOR_YELLOW}Check your internet connection and try again.#{COLOR_RESET}"
|
263
|
+
end
|
264
|
+
|
265
|
+
'{"merged_code":"","reason":"LLM API error: ' + e.message.gsub('"', '\\"') + '"}'
|
266
|
+
end
|
267
|
+
|
268
|
+
def extract_json(response)
|
269
|
+
JSON.parse(response)
|
270
|
+
response
|
271
|
+
rescue JSON::ParserError
|
272
|
+
start = response.index('{')
|
273
|
+
stop = response.rindex('}')
|
274
|
+
return response unless start && stop
|
275
|
+
response[start..stop]
|
276
|
+
end
|
277
|
+
|
278
|
+
def handle_invalid_llm_response(raw_response, error, block)
|
279
|
+
puts "\n#{COLOR_RED}❌ Invalid LLM Response Error#{COLOR_RESET}"
|
280
|
+
puts "#{COLOR_YELLOW}The AI returned an invalid response that couldn't be parsed.#{COLOR_RESET}"
|
281
|
+
puts "#{COLOR_YELLOW}Automatic JSON repair was attempted but failed.#{COLOR_RESET}"
|
282
|
+
puts "#{COLOR_GRAY}Error: #{error.message}#{COLOR_RESET}"
|
283
|
+
|
284
|
+
# Save problematic response for debugging
|
285
|
+
save_debug_response(raw_response, error)
|
286
|
+
|
287
|
+
# Show truncated raw response for debugging
|
288
|
+
truncated_response = raw_response.length > 200 ? "#{raw_response[0..200]}..." : raw_response
|
289
|
+
puts "\n#{COLOR_GRAY}Raw response (truncated):#{COLOR_RESET}"
|
290
|
+
puts "#{COLOR_GRAY}#{truncated_response}#{COLOR_RESET}"
|
291
|
+
|
292
|
+
puts "\n#{COLOR_BLUE}What would you like to do?#{COLOR_RESET}"
|
293
|
+
puts "#{COLOR_GREEN}[r]#{COLOR_RESET} Retry with the same prompt"
|
294
|
+
puts "#{COLOR_YELLOW}[c]#{COLOR_RESET} Add a comment to guide the AI better"
|
295
|
+
puts "#{COLOR_BLUE}[m]#{COLOR_RESET} Manually choose one side of the conflict"
|
296
|
+
puts "#{COLOR_RED}[s]#{COLOR_RESET} Skip this conflict"
|
297
|
+
puts "#{COLOR_RED}[a]#{COLOR_RESET} Abort entirely"
|
298
|
+
|
299
|
+
loop do
|
300
|
+
print "#{COLOR_YELLOW}Choose action [r/c/m/s/a]: #{COLOR_RESET}"
|
301
|
+
choice = $stdin.gets&.strip&.downcase
|
302
|
+
|
303
|
+
case choice
|
304
|
+
when 'r'
|
305
|
+
puts "#{COLOR_YELLOW}🔄 Retrying with same prompt...#{COLOR_RESET}"
|
306
|
+
return retry_llm_request(block)
|
307
|
+
when 'c'
|
308
|
+
puts "#{COLOR_YELLOW}💬 Please provide guidance for the AI:#{COLOR_RESET}"
|
309
|
+
comment = read_multiline_input
|
310
|
+
return retry_llm_request_with_comment(block, comment)
|
311
|
+
when 'm'
|
312
|
+
return handle_manual_choice(block)
|
313
|
+
when 's'
|
314
|
+
puts "#{COLOR_YELLOW}⏭️ Skipping this conflict#{COLOR_RESET}"
|
315
|
+
return { 'merged_code' => '', 'reason' => 'Skipped due to invalid LLM response' }
|
316
|
+
when 'a'
|
317
|
+
puts "#{COLOR_RED}🛑 Aborting merge resolution#{COLOR_RESET}"
|
318
|
+
return { 'merged_code' => '', 'reason' => 'Aborted due to invalid LLM response', 'abort' => true }
|
319
|
+
when '', nil
|
320
|
+
puts "#{COLOR_RED}Please enter a valid choice: r/c/m/s/a#{COLOR_RESET}"
|
321
|
+
else
|
322
|
+
puts "#{COLOR_RED}Invalid option. Please enter: r (retry), c (comment), m (manual), s (skip), or a (abort)#{COLOR_RESET}"
|
323
|
+
end
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
def retry_llm_request(block)
|
328
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
329
|
+
# Always re-read file content in case it was edited
|
330
|
+
full_file_content = File.read(@file_path)
|
331
|
+
|
332
|
+
puts "#{COLOR_YELLOW}🤖 Retrying AI analysis...#{COLOR_RESET}"
|
333
|
+
suggestion = request_merge_with_spinner(block, config, nil, full_file_content)
|
334
|
+
puts "#{COLOR_GREEN}✅ Retry successful!#{COLOR_RESET}"
|
335
|
+
suggestion
|
336
|
+
rescue => e
|
337
|
+
puts "#{COLOR_RED}❌ Retry failed: #{e.message}#{COLOR_RESET}"
|
338
|
+
{ 'merged_code' => '', 'reason' => 'Retry failed due to persistent LLM error' }
|
339
|
+
end
|
340
|
+
|
341
|
+
def retry_llm_request_with_comment(block, comment)
|
342
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
343
|
+
# Always re-read file content in case it was edited
|
344
|
+
full_file_content = File.read(@file_path)
|
345
|
+
|
346
|
+
puts "#{COLOR_YELLOW}🤖 Retrying with your guidance...#{COLOR_RESET}"
|
347
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
348
|
+
puts "#{COLOR_GREEN}✅ Retry with comment successful!#{COLOR_RESET}"
|
349
|
+
suggestion
|
350
|
+
rescue => e
|
351
|
+
puts "#{COLOR_RED}❌ Retry with comment failed: #{e.message}#{COLOR_RESET}"
|
352
|
+
{ 'merged_code' => '', 'reason' => 'Retry with comment failed due to persistent LLM error' }
|
353
|
+
end
|
354
|
+
|
355
|
+
def handle_manual_choice(block)
|
356
|
+
puts "\n#{COLOR_BLUE}Manual conflict resolution:#{COLOR_RESET}"
|
357
|
+
puts "#{COLOR_RED}[1] Choose HEAD version (#{block.base_label})#{COLOR_RESET}"
|
358
|
+
puts "#{COLOR_RED}#{block.base_content}#{COLOR_RESET}"
|
359
|
+
puts ""
|
360
|
+
puts "#{COLOR_GREEN}[2] Choose incoming version (#{block.incoming_label})#{COLOR_RESET}"
|
361
|
+
puts "#{COLOR_GREEN}#{block.incoming_content}#{COLOR_RESET}"
|
362
|
+
puts ""
|
363
|
+
puts "#{COLOR_YELLOW}[3] Skip this conflict#{COLOR_RESET}"
|
364
|
+
|
365
|
+
loop do
|
366
|
+
print "#{COLOR_YELLOW}Choose version [1/2/3]: #{COLOR_RESET}"
|
367
|
+
choice = $stdin.gets&.strip
|
368
|
+
|
369
|
+
case choice
|
370
|
+
when '1'
|
371
|
+
puts "#{COLOR_GREEN}✅ Selected HEAD version#{COLOR_RESET}"
|
372
|
+
return {
|
373
|
+
'merged_code' => block.base_content,
|
374
|
+
'reason' => "Manually selected HEAD version (#{block.base_label}) due to LLM error"
|
375
|
+
}
|
376
|
+
when '2'
|
377
|
+
puts "#{COLOR_GREEN}✅ Selected incoming version#{COLOR_RESET}"
|
378
|
+
return {
|
379
|
+
'merged_code' => block.incoming_content,
|
380
|
+
'reason' => "Manually selected incoming version (#{block.incoming_label}) due to LLM error"
|
381
|
+
}
|
382
|
+
when '3'
|
383
|
+
puts "#{COLOR_YELLOW}⏭️ Skipping conflict#{COLOR_RESET}"
|
384
|
+
return { 'merged_code' => '', 'reason' => 'Manually skipped due to LLM error' }
|
385
|
+
when '', nil
|
386
|
+
puts "#{COLOR_RED}Please enter 1, 2, or 3#{COLOR_RESET}"
|
387
|
+
else
|
388
|
+
puts "#{COLOR_RED}Invalid choice. Please enter 1 (HEAD), 2 (incoming), or 3 (skip)#{COLOR_RESET}"
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
def read_multiline_input
|
394
|
+
lines = []
|
395
|
+
puts "#{COLOR_GRAY}(Type your comment, then press Enter on an empty line to finish)#{COLOR_RESET}"
|
396
|
+
while (line = $stdin.gets)
|
397
|
+
line = line.chomp
|
398
|
+
break if line.empty?
|
399
|
+
lines << line
|
400
|
+
end
|
401
|
+
comment = lines.join("\n")
|
402
|
+
if comment.empty?
|
403
|
+
puts "#{COLOR_YELLOW}No comment entered.#{COLOR_RESET}"
|
404
|
+
else
|
405
|
+
puts "#{COLOR_GREEN}Comment received: #{comment.length} characters#{COLOR_RESET}"
|
406
|
+
end
|
407
|
+
comment
|
408
|
+
end
|
409
|
+
|
410
|
+
def print_conflict(block)
|
411
|
+
puts "#{COLOR_RED}<<<<<<< #{block.base_label} (lines #{block.start_line}-#{block.end_line})#{COLOR_RESET}"
|
412
|
+
puts "#{COLOR_RED}#{block.base_content}#{COLOR_RESET}"
|
413
|
+
puts "#{COLOR_YELLOW}=======#{COLOR_RESET}"
|
414
|
+
puts "#{COLOR_GREEN}#{block.incoming_content}#{COLOR_RESET}"
|
415
|
+
puts "#{COLOR_YELLOW}>>>>>>> #{block.incoming_label}#{COLOR_RESET}"
|
416
|
+
end
|
417
|
+
|
418
|
+
def print_suggestion(sug)
|
419
|
+
puts "#{COLOR_BLUE}--- Suggestion ---#{COLOR_RESET}"
|
420
|
+
puts "#{COLOR_BLUE}#{sug['merged_code']}#{COLOR_RESET}"
|
421
|
+
puts "#{COLOR_GRAY}Reason: #{sug['reason']}#{COLOR_RESET}"
|
422
|
+
end
|
423
|
+
|
424
|
+
def resolve_template_path(template_key, config)
|
425
|
+
user_path = config.dig('templates', template_key) if config.is_a?(Hash)
|
426
|
+
return user_path if user_path && File.exist?(user_path)
|
427
|
+
|
428
|
+
File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
|
429
|
+
end
|
430
|
+
|
431
|
+
def mark_file_as_resolved(file_path)
|
432
|
+
# Detect VCS and mark file as resolved
|
433
|
+
if File.exist?('.hg')
|
434
|
+
result = execute_vcs_command_with_timeout("hg resolve --mark #{Shellwords.escape(file_path)}", 10)
|
435
|
+
if result[:success]
|
436
|
+
puts "#{COLOR_GREEN}✅ Marked #{file_path} as resolved in Mercurial#{COLOR_RESET}"
|
437
|
+
else
|
438
|
+
puts "#{COLOR_YELLOW}⚠️ Could not mark #{file_path} as resolved in Mercurial#{COLOR_RESET}"
|
439
|
+
puts "#{COLOR_GRAY}Error: #{result[:error]}#{COLOR_RESET}" if result[:error]
|
440
|
+
puts "#{COLOR_GRAY}💡 You can manually mark it with: hg resolve --mark #{file_path}#{COLOR_RESET}"
|
441
|
+
end
|
442
|
+
elsif File.exist?('.git')
|
443
|
+
result = execute_vcs_command_with_timeout("git add #{Shellwords.escape(file_path)}", 10)
|
444
|
+
if result[:success]
|
445
|
+
puts "#{COLOR_GREEN}✅ Added #{file_path} to Git staging area#{COLOR_RESET}"
|
446
|
+
else
|
447
|
+
puts "#{COLOR_YELLOW}⚠️ Could not add #{file_path} to Git staging area#{COLOR_RESET}"
|
448
|
+
puts "#{COLOR_GRAY}Error: #{result[:error]}#{COLOR_RESET}" if result[:error]
|
449
|
+
puts "#{COLOR_GRAY}💡 You can manually add it with: git add #{file_path}#{COLOR_RESET}"
|
450
|
+
end
|
451
|
+
else
|
452
|
+
puts "#{COLOR_BLUE}ℹ️ No VCS detected - file saved but not marked as resolved#{COLOR_RESET}"
|
453
|
+
end
|
454
|
+
end
|
455
|
+
|
456
|
+
def execute_vcs_command_with_timeout(command, timeout_seconds)
|
457
|
+
require 'open3'
|
458
|
+
|
459
|
+
begin
|
460
|
+
# Use Open3.popen3 with manual timeout handling to avoid thread issues
|
461
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3(command)
|
462
|
+
stdin.close
|
463
|
+
|
464
|
+
# Wait for the process with timeout
|
465
|
+
if wait_thr.join(timeout_seconds)
|
466
|
+
# Process completed within timeout
|
467
|
+
stdout_content = stdout.read
|
468
|
+
stderr_content = stderr.read
|
469
|
+
status = wait_thr.value
|
470
|
+
|
471
|
+
stdout.close
|
472
|
+
stderr.close
|
473
|
+
|
474
|
+
if status.success?
|
475
|
+
{ success: true, stdout: stdout_content, stderr: stderr_content }
|
476
|
+
else
|
477
|
+
{ success: false, error: "Command failed: #{stderr_content.strip}", stdout: stdout_content, stderr: stderr_content }
|
478
|
+
end
|
479
|
+
else
|
480
|
+
# Process timed out, kill it
|
481
|
+
Process.kill('TERM', wait_thr.pid) rescue nil
|
482
|
+
sleep(0.1)
|
483
|
+
Process.kill('KILL', wait_thr.pid) rescue nil
|
484
|
+
|
485
|
+
stdout.close rescue nil
|
486
|
+
stderr.close rescue nil
|
487
|
+
wait_thr.join rescue nil
|
488
|
+
|
489
|
+
{ success: false, error: "Command timed out after #{timeout_seconds} seconds" }
|
490
|
+
end
|
491
|
+
rescue => e
|
492
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
493
|
+
end
|
494
|
+
end
|
495
|
+
|
496
|
+
def show_usage_and_unresolved
|
497
|
+
puts "Usage: n2b-diff FILE [--context N]"
|
498
|
+
puts ""
|
499
|
+
|
500
|
+
# Show unresolved conflicts if in a VCS repository
|
501
|
+
if File.exist?('.hg')
|
502
|
+
puts "#{COLOR_BLUE}📋 Unresolved conflicts in Mercurial:#{COLOR_RESET}"
|
503
|
+
result = execute_vcs_command_with_timeout("hg resolve --list", 5)
|
504
|
+
|
505
|
+
if result[:success]
|
506
|
+
unresolved_files = result[:stdout].lines.select { |line| line.start_with?('U ') }
|
507
|
+
|
508
|
+
if unresolved_files.any?
|
509
|
+
unresolved_files.each do |line|
|
510
|
+
file = line.strip.sub(/^U /, '')
|
511
|
+
puts " #{COLOR_RED}❌ #{file}#{COLOR_RESET}"
|
512
|
+
end
|
513
|
+
puts ""
|
514
|
+
puts "#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve conflicts#{COLOR_RESET}"
|
515
|
+
else
|
516
|
+
puts " #{COLOR_GREEN}✅ No unresolved conflicts#{COLOR_RESET}"
|
517
|
+
end
|
518
|
+
else
|
519
|
+
puts " #{COLOR_YELLOW}⚠️ Could not check Mercurial status: #{result[:error]}#{COLOR_RESET}"
|
520
|
+
end
|
521
|
+
elsif File.exist?('.git')
|
522
|
+
puts "#{COLOR_BLUE}📋 Unresolved conflicts in Git:#{COLOR_RESET}"
|
523
|
+
result = execute_vcs_command_with_timeout("git diff --name-only --diff-filter=U", 5)
|
524
|
+
|
525
|
+
if result[:success]
|
526
|
+
unresolved_files = result[:stdout].lines
|
527
|
+
|
528
|
+
if unresolved_files.any?
|
529
|
+
unresolved_files.each do |file|
|
530
|
+
puts " #{COLOR_RED}❌ #{file.strip}#{COLOR_RESET}"
|
531
|
+
end
|
532
|
+
puts ""
|
533
|
+
puts "#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve conflicts#{COLOR_RESET}"
|
534
|
+
else
|
535
|
+
puts " #{COLOR_GREEN}✅ No unresolved conflicts#{COLOR_RESET}"
|
536
|
+
end
|
537
|
+
else
|
538
|
+
puts " #{COLOR_YELLOW}⚠️ Could not check Git status: #{result[:error]}#{COLOR_RESET}"
|
539
|
+
end
|
540
|
+
end
|
541
|
+
end
|
542
|
+
|
543
|
+
def attempt_json_repair(malformed_response, config)
|
544
|
+
repair_prompt = build_json_repair_prompt(malformed_response)
|
545
|
+
|
546
|
+
begin
|
547
|
+
puts "#{COLOR_BLUE}🔧 Asking AI to fix the JSON...#{COLOR_RESET}"
|
548
|
+
repaired_json_str = call_llm_for_merge(repair_prompt, config)
|
549
|
+
|
550
|
+
# Try to parse the repaired response
|
551
|
+
parsed = JSON.parse(extract_json(repaired_json_str))
|
552
|
+
|
553
|
+
# Validate the repaired response structure
|
554
|
+
if parsed.is_a?(Hash) && parsed.key?('merged_code') && parsed.key?('reason')
|
555
|
+
return parsed
|
556
|
+
else
|
557
|
+
puts "#{COLOR_YELLOW}⚠️ Repaired JSON missing required keys#{COLOR_RESET}"
|
558
|
+
return nil
|
559
|
+
end
|
560
|
+
rescue JSON::ParserError => e
|
561
|
+
puts "#{COLOR_YELLOW}⚠️ JSON repair attempt also returned invalid JSON#{COLOR_RESET}"
|
562
|
+
return nil
|
563
|
+
rescue => e
|
564
|
+
puts "#{COLOR_YELLOW}⚠️ JSON repair failed: #{e.message}#{COLOR_RESET}"
|
565
|
+
return nil
|
566
|
+
end
|
567
|
+
end
|
568
|
+
|
569
|
+
def build_json_repair_prompt(malformed_response)
|
570
|
+
<<~PROMPT
|
571
|
+
The following response was supposed to be valid JSON with keys "merged_code" and "reason", but it has formatting issues. Please fix it and return ONLY the corrected JSON:
|
572
|
+
|
573
|
+
Original response:
|
574
|
+
#{malformed_response}
|
575
|
+
|
576
|
+
Requirements:
|
577
|
+
- Must be valid JSON
|
578
|
+
- Must have "merged_code" key with the code content
|
579
|
+
- Must have "reason" key with explanation
|
580
|
+
- Return ONLY the JSON, no other text
|
581
|
+
|
582
|
+
Fixed JSON:
|
583
|
+
PROMPT
|
584
|
+
end
|
585
|
+
|
586
|
+
def handle_editor_workflow(block, config, full_file_content)
|
587
|
+
original_content = File.read(@file_path)
|
588
|
+
|
589
|
+
puts "#{COLOR_BLUE}🔧 Opening #{@file_path} in editor...#{COLOR_RESET}"
|
590
|
+
open_file_in_editor(@file_path)
|
591
|
+
puts "#{COLOR_BLUE}📁 Editor closed. Checking for changes...#{COLOR_RESET}"
|
592
|
+
|
593
|
+
current_content = File.read(@file_path)
|
594
|
+
|
595
|
+
if file_changed?(original_content, current_content)
|
596
|
+
puts "#{COLOR_YELLOW}📝 File has been modified.#{COLOR_RESET}"
|
597
|
+
print "#{COLOR_YELLOW}Did you resolve this conflict yourself? [y/n]: #{COLOR_RESET}"
|
598
|
+
response = $stdin.gets&.strip&.downcase
|
599
|
+
|
600
|
+
if response == 'y'
|
601
|
+
puts "#{COLOR_GREEN}✅ Conflict marked as resolved by user#{COLOR_RESET}"
|
602
|
+
return {
|
603
|
+
resolved: true,
|
604
|
+
merged_code: "user_resolved",
|
605
|
+
reason: "User resolved conflict manually in editor"
|
606
|
+
}
|
607
|
+
else
|
608
|
+
puts "#{COLOR_BLUE}🔄 Continuing with AI assistance...#{COLOR_RESET}"
|
609
|
+
return {
|
610
|
+
resolved: false,
|
611
|
+
updated_content: current_content
|
612
|
+
}
|
613
|
+
end
|
614
|
+
else
|
615
|
+
puts "#{COLOR_GRAY}📋 No changes detected. Continuing...#{COLOR_RESET}"
|
616
|
+
return {resolved: false, updated_content: nil}
|
617
|
+
end
|
618
|
+
end
|
619
|
+
|
620
|
+
def detect_editor
|
621
|
+
ENV['EDITOR'] || ENV['VISUAL'] || detect_system_editor
|
622
|
+
end
|
623
|
+
|
624
|
+
def detect_system_editor
|
625
|
+
case RbConfig::CONFIG['host_os']
|
626
|
+
when /darwin|mac os/
|
627
|
+
'open'
|
628
|
+
when /linux/
|
629
|
+
'nano'
|
630
|
+
when /mswin|mingw/
|
631
|
+
'notepad'
|
632
|
+
else
|
633
|
+
'vi'
|
634
|
+
end
|
635
|
+
end
|
636
|
+
|
637
|
+
def open_file_in_editor(file_path)
|
638
|
+
editor = detect_editor
|
639
|
+
|
640
|
+
begin
|
641
|
+
case editor
|
642
|
+
when 'open'
|
643
|
+
# macOS: open with default application (non-blocking)
|
644
|
+
result = execute_vcs_command_with_timeout("open #{Shellwords.escape(file_path)}", 5)
|
645
|
+
unless result[:success]
|
646
|
+
puts "#{COLOR_YELLOW}⚠️ Could not open with 'open' command: #{result[:error]}#{COLOR_RESET}"
|
647
|
+
puts "#{COLOR_BLUE}💡 Please open #{file_path} manually in your editor#{COLOR_RESET}"
|
648
|
+
end
|
649
|
+
else
|
650
|
+
# Other editors: open directly (blocking)
|
651
|
+
puts "#{COLOR_BLUE}🔧 Opening with #{editor}...#{COLOR_RESET}"
|
652
|
+
system("#{editor} #{Shellwords.escape(file_path)}")
|
653
|
+
end
|
654
|
+
rescue => e
|
655
|
+
puts "#{COLOR_RED}❌ Failed to open editor: #{e.message}#{COLOR_RESET}"
|
656
|
+
puts "#{COLOR_YELLOW}💡 Try setting EDITOR environment variable to your preferred editor#{COLOR_RESET}"
|
657
|
+
end
|
658
|
+
end
|
659
|
+
|
660
|
+
def file_changed?(original_content, current_content)
|
661
|
+
original_content != current_content
|
662
|
+
end
|
663
|
+
|
664
|
+
def save_debug_response(raw_response, error)
|
665
|
+
begin
|
666
|
+
debug_dir = '.n2b_debug'
|
667
|
+
FileUtils.mkdir_p(debug_dir)
|
668
|
+
timestamp = Time.now.strftime('%Y-%m-%d-%H%M%S')
|
669
|
+
debug_file = File.join(debug_dir, "invalid_response_#{timestamp}.txt")
|
670
|
+
|
671
|
+
debug_content = <<~DEBUG
|
672
|
+
N2B Debug: Invalid LLM Response
|
673
|
+
===============================
|
674
|
+
Timestamp: #{Time.now}
|
675
|
+
File: #{@file_path}
|
676
|
+
Error: #{error.message}
|
677
|
+
Error Class: #{error.class}
|
678
|
+
|
679
|
+
Raw LLM Response:
|
680
|
+
-----------------
|
681
|
+
#{raw_response}
|
682
|
+
|
683
|
+
End of Response
|
684
|
+
===============
|
685
|
+
DEBUG
|
686
|
+
|
687
|
+
File.write(debug_file, debug_content)
|
688
|
+
puts "#{COLOR_GRAY}🐛 Debug info saved to #{debug_file}#{COLOR_RESET}"
|
689
|
+
rescue => e
|
690
|
+
puts "#{COLOR_GRAY}⚠️ Could not save debug info: #{e.message}#{COLOR_RESET}"
|
691
|
+
end
|
692
|
+
end
|
693
|
+
end
|
694
|
+
end
|