n2b 0.5.1 → 2.0.0
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 +635 -111
- data/bin/branch-audit.sh +397 -0
- data/bin/n2b-diff +5 -0
- data/bin/n2b-test-github +22 -0
- data/bin/n2b-test-jira +17 -14
- data/lib/n2b/base.rb +207 -37
- data/lib/n2b/cli.rb +159 -441
- data/lib/n2b/github_client.rb +391 -0
- data/lib/n2b/jira_client.rb +576 -17
- data/lib/n2b/llm/claude.rb +7 -20
- data/lib/n2b/llm/gemini.rb +9 -8
- data/lib/n2b/llm/open_ai.rb +27 -21
- data/lib/n2b/merge_cli.rb +2329 -0
- data/lib/n2b/merge_conflict_parser.rb +70 -0
- data/lib/n2b/message_utils.rb +59 -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 +43 -0
- data/lib/n2b/templates/github_comment.txt +67 -0
- data/lib/n2b/templates/jira_comment.txt +74 -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 +38 -6
@@ -0,0 +1,2329 @@
|
|
1
|
+
require 'shellwords'
|
2
|
+
require 'rbconfig'
|
3
|
+
require 'optparse'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'stringio'
|
6
|
+
require 'cgi'
|
7
|
+
require_relative 'base'
|
8
|
+
require_relative 'merge_conflict_parser'
|
9
|
+
require_relative 'jira_client'
|
10
|
+
require_relative 'github_client'
|
11
|
+
require_relative 'message_utils'
|
12
|
+
|
13
|
+
module N2B
|
14
|
+
class MergeCLI < Base
|
15
|
+
COLOR_RED = "\e[31m"
|
16
|
+
COLOR_GREEN = "\e[32m"
|
17
|
+
COLOR_YELLOW= "\e[33m"
|
18
|
+
COLOR_BLUE = "\e[34m"
|
19
|
+
COLOR_GRAY = "\e[90m"
|
20
|
+
COLOR_RESET = "\e[0m"
|
21
|
+
|
22
|
+
def self.run(args)
|
23
|
+
new(args).execute
|
24
|
+
end
|
25
|
+
|
26
|
+
def initialize(args)
|
27
|
+
@args = args
|
28
|
+
@options = parse_options
|
29
|
+
# @file_path = @args.shift # Moved to execute based on mode
|
30
|
+
end
|
31
|
+
|
32
|
+
def execute
|
33
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
34
|
+
|
35
|
+
if @options[:analyze]
|
36
|
+
# In analyze mode, @args might be used for custom prompt additions later,
|
37
|
+
# similar to how original cli.rb handled it.
|
38
|
+
# For now, custom_message option is the primary way.
|
39
|
+
handle_diff_analysis(config)
|
40
|
+
else
|
41
|
+
@file_path = @args.shift
|
42
|
+
if @file_path.nil?
|
43
|
+
show_help_and_status # Renamed from show_usage_and_unresolved
|
44
|
+
exit 1
|
45
|
+
end
|
46
|
+
|
47
|
+
unless File.exist?(@file_path)
|
48
|
+
puts "File not found: #{@file_path}"
|
49
|
+
exit 1
|
50
|
+
end
|
51
|
+
|
52
|
+
parser = MergeConflictParser.new(context_lines: @options[:context_lines])
|
53
|
+
blocks = parser.parse(@file_path)
|
54
|
+
if blocks.empty?
|
55
|
+
puts "No merge conflicts found."
|
56
|
+
return
|
57
|
+
end
|
58
|
+
|
59
|
+
lines = File.readlines(@file_path, chomp: true)
|
60
|
+
log_entries = []
|
61
|
+
aborted = false
|
62
|
+
|
63
|
+
blocks.reverse_each do |block|
|
64
|
+
result = resolve_block(block, config, lines.join("\n"))
|
65
|
+
log_entries << result.merge({
|
66
|
+
base_content: block.base_content,
|
67
|
+
incoming_content: block.incoming_content,
|
68
|
+
base_label: block.base_label,
|
69
|
+
incoming_label: block.incoming_label,
|
70
|
+
start_line: block.start_line,
|
71
|
+
end_line: block.end_line,
|
72
|
+
resolved_content: result[:merged_code],
|
73
|
+
llm_suggestion: result[:reason],
|
74
|
+
resolution_method: determine_resolution_method(result),
|
75
|
+
action: determine_action(result),
|
76
|
+
timestamp: Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')
|
77
|
+
})
|
78
|
+
if result[:abort]
|
79
|
+
aborted = true
|
80
|
+
break
|
81
|
+
elsif result[:accepted]
|
82
|
+
replacement = result[:merged_code].to_s.split("\n")
|
83
|
+
lines[(block.start_line-1)...block.end_line] = replacement
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
unless aborted
|
88
|
+
File.write(@file_path, lines.join("\n") + "\n")
|
89
|
+
|
90
|
+
# Show summary
|
91
|
+
accepted_count = log_entries.count { |entry| entry[:accepted] }
|
92
|
+
skipped_count = log_entries.count { |entry| !entry[:accepted] && !entry[:abort] }
|
93
|
+
|
94
|
+
puts "\n#{COLOR_BLUE}📊 Resolution Summary:#{COLOR_RESET}"
|
95
|
+
puts "#{COLOR_GREEN}✅ Accepted: #{accepted_count}#{COLOR_RESET}"
|
96
|
+
puts "#{COLOR_YELLOW}⏭️ Skipped: #{skipped_count}#{COLOR_RESET}" if skipped_count > 0
|
97
|
+
|
98
|
+
# Only auto-mark as resolved if ALL conflicts were accepted (none skipped)
|
99
|
+
if accepted_count > 0 && skipped_count == 0
|
100
|
+
mark_file_as_resolved(@file_path)
|
101
|
+
puts "#{COLOR_GREEN}🎉 All conflicts resolved! File marked as resolved in VCS.#{COLOR_RESET}"
|
102
|
+
elsif accepted_count > 0 && skipped_count > 0
|
103
|
+
puts "#{COLOR_YELLOW}⚠️ Some conflicts were skipped - file NOT marked as resolved#{COLOR_RESET}"
|
104
|
+
puts "#{COLOR_GRAY}💡 Resolve remaining conflicts or manually mark: hg resolve --mark #{@file_path}#{COLOR_RESET}"
|
105
|
+
else
|
106
|
+
puts "#{COLOR_YELLOW}⚠️ No conflicts were accepted - file NOT marked as resolved#{COLOR_RESET}"
|
107
|
+
end
|
108
|
+
else
|
109
|
+
puts "\n#{COLOR_YELLOW}⚠️ Resolution aborted - no changes made#{COLOR_RESET}"
|
110
|
+
end
|
111
|
+
|
112
|
+
if config['merge_log_enabled'] && log_entries.any?
|
113
|
+
dir = '.n2b_merge_log'
|
114
|
+
FileUtils.mkdir_p(dir)
|
115
|
+
timestamp = Time.now.strftime('%Y-%m-%d-%H%M%S')
|
116
|
+
log_path = File.join(dir, "#{timestamp}.html")
|
117
|
+
html_content = generate_merge_log_html(log_entries, timestamp)
|
118
|
+
File.write(log_path, html_content)
|
119
|
+
puts "#{COLOR_GRAY}📝 Merge log saved to #{log_path}#{COLOR_RESET}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
def parse_options
|
127
|
+
options = {
|
128
|
+
context_lines: MergeConflictParser::DEFAULT_CONTEXT_LINES,
|
129
|
+
analyze: false,
|
130
|
+
branch: nil,
|
131
|
+
jira_ticket: nil,
|
132
|
+
github_issue: nil,
|
133
|
+
requirements_file: nil,
|
134
|
+
update_issue: nil, # nil means ask, true means update, false means no update
|
135
|
+
custom_message: nil
|
136
|
+
}
|
137
|
+
parser = OptionParser.new do |opts|
|
138
|
+
opts.banner = "Usage: n2b-diff FILE [options] OR n2b-diff --analyze [options]"
|
139
|
+
opts.separator ""
|
140
|
+
opts.separator "Merge Conflict Options:"
|
141
|
+
opts.on('--context N', Integer, 'Context lines for merge conflict analysis (default: 10, affects LLM context)') { |v| options[:context_lines] = v }
|
142
|
+
|
143
|
+
opts.separator ""
|
144
|
+
opts.separator "Diff Analysis Options:"
|
145
|
+
opts.on('-a', '--analyze', 'Analyze git/hg diff with AI') { options[:analyze] = true }
|
146
|
+
opts.on('--branch [BRANCH]', 'Branch to compare against for analysis (default: auto-detect main/master)') do |branch|
|
147
|
+
options[:branch] = branch || 'auto' # 'auto' will trigger detection logic
|
148
|
+
end
|
149
|
+
opts.on('-j', '--jira JIRA_ID_OR_URL', 'Jira ticket ID or URL for context or update') do |jira|
|
150
|
+
options[:jira_ticket] = jira
|
151
|
+
end
|
152
|
+
opts.on('--github GITHUB_ISSUE_ID_OR_URL', 'GitHub issue ID or URL for context or update (e.g., owner/repo/issues/123)') do |gh|
|
153
|
+
options[:github_issue] = gh
|
154
|
+
end
|
155
|
+
opts.on('-r', '--requirements FILEPATH', 'Requirements file for diff analysis context') do |file|
|
156
|
+
options[:requirements_file] = file
|
157
|
+
end
|
158
|
+
opts.on('--update', 'Attempt to update the linked Jira/GitHub issue with the analysis result (will ask for confirmation by default)') do
|
159
|
+
options[:update_issue] = true # Explicitly true
|
160
|
+
end
|
161
|
+
opts.on('--no-update', 'Do not attempt to update the linked Jira/GitHub issue') do
|
162
|
+
options[:update_issue] = false # Explicitly false
|
163
|
+
end
|
164
|
+
opts.on('-m', '--message MESSAGE', '--msg MESSAGE', String, 'Custom instructions for AI analysis (max 500 chars)') do |raw_msg|
|
165
|
+
validated_msg = N2B::MessageUtils.validate_message(raw_msg)
|
166
|
+
# Sanitization happens after validation (e.g. truncation)
|
167
|
+
# No need to log here, will log after all options parsed if message exists
|
168
|
+
options[:custom_message] = validated_msg # Store potentially truncated message
|
169
|
+
end
|
170
|
+
|
171
|
+
opts.separator ""
|
172
|
+
opts.separator "Common Options:"
|
173
|
+
opts.on_tail('-h', '--help', 'Show this help message') { puts opts; exit }
|
174
|
+
opts.on_tail('-v', '--version', 'Show version') { puts N2B::VERSION; exit } # Assuming VERSION is defined
|
175
|
+
|
176
|
+
opts.separator ""
|
177
|
+
opts.separator "Examples:"
|
178
|
+
opts.separator " n2b-diff path/to/your/file_with_conflicts.rb"
|
179
|
+
opts.separator " n2b-diff --analyze"
|
180
|
+
opts.separator " n2b-diff --analyze --branch main"
|
181
|
+
opts.separator " n2b-diff --analyze --jira PROJ-123 -m \"Focus on data validation.\""
|
182
|
+
opts.separator " n2b-diff --analyze --github org/repo/issues/42 --update"
|
183
|
+
end
|
184
|
+
|
185
|
+
begin
|
186
|
+
parser.parse!(@args)
|
187
|
+
rescue OptionParser::InvalidOption => e
|
188
|
+
puts "#{COLOR_RED}Error: #{e.message}#{COLOR_RESET}"
|
189
|
+
puts parser.help
|
190
|
+
exit 1
|
191
|
+
end
|
192
|
+
|
193
|
+
# --- Option Validations ---
|
194
|
+
if options[:analyze]
|
195
|
+
# Options that only make sense with analyze
|
196
|
+
else
|
197
|
+
# Options that don't make sense without analyze (if any)
|
198
|
+
if options[:branch] || options[:jira_ticket] || options[:github_issue] || options[:requirements_file] || options[:custom_message] || !options[:update_issue].nil?
|
199
|
+
puts "#{COLOR_RED}Error: Options like --branch, --jira, --github, --requirements, --message, --update/--no-update are only for --analyze mode.#{COLOR_RESET}"
|
200
|
+
puts parser.help
|
201
|
+
exit 1
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
if options[:update_issue] == true && options[:jira_ticket].nil? && options[:github_issue].nil?
|
206
|
+
puts "#{COLOR_RED}Error: --update option requires --jira or --github to be specified for context.#{COLOR_RESET}"
|
207
|
+
puts parser.help
|
208
|
+
exit 1
|
209
|
+
end
|
210
|
+
|
211
|
+
# If --no-update is used without specifying a ticket, it's not an error, just has no effect.
|
212
|
+
# It's fine for --jira or --github to be specified without --update or --no-update (implies ask user)
|
213
|
+
|
214
|
+
# Perform sanitization and logging for custom_message after all options are parsed.
|
215
|
+
if options[:custom_message]
|
216
|
+
# Sanitize the already validated (potentially truncated) message
|
217
|
+
sanitized_message = N2B::MessageUtils.sanitize_message(options[:custom_message])
|
218
|
+
options[:custom_message] = sanitized_message # Store the sanitized version
|
219
|
+
|
220
|
+
# Log the final message that will be used
|
221
|
+
N2B::MessageUtils.log_message("Using custom analysis message: \"#{options[:custom_message]}\"", :info) unless options[:custom_message].strip.empty?
|
222
|
+
end
|
223
|
+
|
224
|
+
options
|
225
|
+
end
|
226
|
+
|
227
|
+
# --- Methods moved and adapted from original N2B::CLI for diff analysis ---
|
228
|
+
def handle_diff_analysis(config)
|
229
|
+
vcs_type = get_vcs_type
|
230
|
+
if vcs_type == :none
|
231
|
+
puts "Error: Not a git or hg repository. Diff analysis requires a VCS."
|
232
|
+
exit 1
|
233
|
+
end
|
234
|
+
|
235
|
+
requirements_filepath = @options[:requirements_file] # Adapted option name
|
236
|
+
# user_prompt_addition now directly uses the processed @options[:custom_message]
|
237
|
+
user_prompt_addition = @options[:custom_message] || ""
|
238
|
+
|
239
|
+
# Logging of custom_message is now handled in parse_options by MessageUtils.log_message
|
240
|
+
# So, the specific puts here can be removed or changed to debug level if needed.
|
241
|
+
# For now, removing:
|
242
|
+
# if @options[:custom_message] && !@options[:custom_message].strip.empty?
|
243
|
+
# puts "DEBUG: Custom message for analysis: #{@options[:custom_message]}" # Or use MessageUtils.log_message if preferred for debug
|
244
|
+
# end
|
245
|
+
|
246
|
+
# Ticket / Issue Information - Standardize to use jira_ticket and github_issue
|
247
|
+
# The original cli.rb used @options[:jira_ticket] for both. Here we differentiate.
|
248
|
+
ticket_input = @options[:jira_ticket] || @options[:github_issue]
|
249
|
+
ticket_type = @options[:jira_ticket] ? 'jira' : (@options[:github_issue] ? 'github' : nil)
|
250
|
+
# ticket_update_flag from @options[:update_issue] (true, false, or nil)
|
251
|
+
# nil means ask, true means update, false means no update.
|
252
|
+
ticket_update_flag = @options[:update_issue]
|
253
|
+
|
254
|
+
|
255
|
+
requirements_content = nil
|
256
|
+
|
257
|
+
if ticket_input && ticket_type
|
258
|
+
# Determine which issue tracker based on which option was provided
|
259
|
+
# Default to 'jira' if somehow ticket_type is nil but ticket_input exists (should not happen)
|
260
|
+
tracker_service_name = ticket_type || (config['issue_tracker'] || 'jira')
|
261
|
+
|
262
|
+
case tracker_service_name
|
263
|
+
when 'github'
|
264
|
+
puts "GitHub issue specified: #{ticket_input}"
|
265
|
+
if config['github'] && config['github']['repo'] && config['github']['access_token']
|
266
|
+
begin
|
267
|
+
github_client = N2B::GitHubClient.new(config) # Ensure N2B::GitHubClient is available
|
268
|
+
puts "Fetching GitHub issue details..."
|
269
|
+
requirements_content = github_client.fetch_issue(ticket_input) # ticket_input here is ID/URL
|
270
|
+
puts "Successfully fetched GitHub issue details."
|
271
|
+
rescue StandardError => e
|
272
|
+
puts "Error fetching GitHub issue: #{e.message}"
|
273
|
+
puts "Proceeding with diff analysis without GitHub issue details."
|
274
|
+
end
|
275
|
+
else
|
276
|
+
puts "GitHub configuration is missing or incomplete in N2B settings."
|
277
|
+
puts "Please configure GitHub using 'n2b -c' (or main n2b config) to fetch issue details."
|
278
|
+
puts "Proceeding with diff analysis without GitHub issue details."
|
279
|
+
end
|
280
|
+
when 'jira'
|
281
|
+
puts "Jira ticket specified: #{ticket_input}"
|
282
|
+
if config['jira'] && config['jira']['domain'] && config['jira']['email'] && config['jira']['api_key']
|
283
|
+
begin
|
284
|
+
jira_client = N2B::JiraClient.new(config) # Ensure N2B::JiraClient is available
|
285
|
+
puts "Fetching Jira ticket details..."
|
286
|
+
requirements_content = jira_client.fetch_ticket(ticket_input)
|
287
|
+
puts "Successfully fetched Jira ticket details."
|
288
|
+
rescue N2B::JiraClient::JiraApiError => e
|
289
|
+
puts "Error fetching Jira ticket: #{e.message}"
|
290
|
+
puts "Proceeding with diff analysis without Jira ticket details."
|
291
|
+
rescue ArgumentError => e # Catches config errors from JiraClient init
|
292
|
+
puts "Jira configuration error: #{e.message}"
|
293
|
+
puts "Please ensure Jira is configured correctly."
|
294
|
+
puts "Proceeding with diff analysis without Jira ticket details."
|
295
|
+
rescue StandardError => e
|
296
|
+
puts "An unexpected error occurred while fetching Jira ticket: #{e.message}"
|
297
|
+
puts "Proceeding with diff analysis without Jira ticket details."
|
298
|
+
end
|
299
|
+
else
|
300
|
+
puts "Jira configuration is missing or incomplete in N2B settings."
|
301
|
+
puts "Please configure Jira using 'n2b -c' (or main n2b config) to fetch ticket details."
|
302
|
+
puts "Proceeding with diff analysis without Jira ticket details."
|
303
|
+
end
|
304
|
+
end
|
305
|
+
# Common message for ticket update status based on new flag
|
306
|
+
if ticket_update_flag == true
|
307
|
+
puts "Note: Issue update is flagged (--update)."
|
308
|
+
elsif ticket_update_flag == false
|
309
|
+
puts "Note: Issue will not be updated (--no-update)."
|
310
|
+
else # nil case
|
311
|
+
puts "Note: You will be prompted whether to update the issue after analysis."
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
|
316
|
+
if requirements_content.nil? && requirements_filepath
|
317
|
+
if File.exist?(requirements_filepath)
|
318
|
+
puts "Loading requirements from file: #{requirements_filepath}"
|
319
|
+
requirements_content = File.read(requirements_filepath)
|
320
|
+
else
|
321
|
+
puts "Error: Requirements file not found: #{requirements_filepath}"
|
322
|
+
puts "Proceeding with diff analysis without file-based requirements."
|
323
|
+
end
|
324
|
+
elsif requirements_content && requirements_filepath
|
325
|
+
puts "Note: Both issue details and a requirements file were provided. Using issue details for analysis context."
|
326
|
+
end
|
327
|
+
|
328
|
+
diff_output = execute_vcs_diff(vcs_type, @options[:branch])
|
329
|
+
if diff_output.nil? || diff_output.strip.empty?
|
330
|
+
puts "No differences found to analyze."
|
331
|
+
exit 0
|
332
|
+
end
|
333
|
+
|
334
|
+
# Pass user_prompt_addition (custom_message) to analyze_diff
|
335
|
+
analysis_result = analyze_diff(diff_output, config, user_prompt_addition, requirements_content)
|
336
|
+
|
337
|
+
# --- Ticket Update Logic (adapted) ---
|
338
|
+
if ticket_input && ticket_type && analysis_result && !analysis_result.empty?
|
339
|
+
tracker_service_name = ticket_type
|
340
|
+
|
341
|
+
# Determine if we should proceed with update based on ticket_update_flag
|
342
|
+
proceed_with_update_action = false
|
343
|
+
if ticket_update_flag == true # --update
|
344
|
+
proceed_with_update_action = true
|
345
|
+
elsif ticket_update_flag.nil? # Ask user
|
346
|
+
puts "\nWould you like to update #{tracker_service_name.capitalize} issue #{ticket_input} with this analysis? (y/n)"
|
347
|
+
user_choice = $stdin.gets.chomp.downcase
|
348
|
+
proceed_with_update_action = user_choice == 'y'
|
349
|
+
end # If ticket_update_flag is false, proceed_with_update_action remains false
|
350
|
+
|
351
|
+
if proceed_with_update_action
|
352
|
+
case tracker_service_name
|
353
|
+
when 'github'
|
354
|
+
if config['github'] && config['github']['repo'] && config['github']['access_token']
|
355
|
+
# Pass custom_message to formatting function
|
356
|
+
comment_data = format_analysis_for_github(analysis_result, @options[:custom_message])
|
357
|
+
begin
|
358
|
+
update_client = N2B::GitHubClient.new(config)
|
359
|
+
puts "Updating GitHub issue #{ticket_input}..."
|
360
|
+
if update_client.update_issue(ticket_input, comment_data) # ticket_input is ID/URL
|
361
|
+
puts "GitHub issue #{ticket_input} updated successfully."
|
362
|
+
else
|
363
|
+
puts "Failed to update GitHub issue #{ticket_input}."
|
364
|
+
end
|
365
|
+
rescue StandardError => e
|
366
|
+
puts "Error updating GitHub issue: #{e.message}"
|
367
|
+
end
|
368
|
+
else
|
369
|
+
puts "GitHub configuration is missing. Cannot update GitHub issue."
|
370
|
+
end
|
371
|
+
when 'jira'
|
372
|
+
if config['jira'] && config['jira']['domain'] && config['jira']['email'] && config['jira']['api_key']
|
373
|
+
# Pass custom_message to formatting function
|
374
|
+
jira_comment_data = format_analysis_for_jira(analysis_result, @options[:custom_message])
|
375
|
+
begin
|
376
|
+
update_jira_client = N2B::JiraClient.new(config)
|
377
|
+
puts "Updating Jira ticket #{ticket_input}..."
|
378
|
+
if update_jira_client.update_ticket(ticket_input, jira_comment_data)
|
379
|
+
puts "Jira ticket #{ticket_input} updated successfully."
|
380
|
+
else
|
381
|
+
puts "Failed to update Jira ticket #{ticket_input}."
|
382
|
+
end
|
383
|
+
rescue N2B::JiraClient::JiraApiError => e
|
384
|
+
puts "Error updating Jira ticket: #{e.message}"
|
385
|
+
rescue ArgumentError => e
|
386
|
+
puts "Jira configuration error for update: #{e.message}"
|
387
|
+
rescue StandardError => e
|
388
|
+
puts "An unexpected error occurred while updating Jira ticket: #{e.message}"
|
389
|
+
end
|
390
|
+
else
|
391
|
+
puts "Jira configuration is missing. Cannot update Jira ticket."
|
392
|
+
end
|
393
|
+
end
|
394
|
+
else
|
395
|
+
puts "Issue/Ticket update skipped."
|
396
|
+
end
|
397
|
+
elsif ticket_input && (analysis_result.nil? || analysis_result.empty?)
|
398
|
+
puts "Skipping ticket update as analysis result was empty or not generated."
|
399
|
+
end
|
400
|
+
# --- End of Ticket Update Logic ---
|
401
|
+
analysis_result # Return for potential further use or testing
|
402
|
+
end
|
403
|
+
|
404
|
+
def get_vcs_type
|
405
|
+
if Dir.exist?(File.join(Dir.pwd, '.git'))
|
406
|
+
:git
|
407
|
+
elsif Dir.exist?(File.join(Dir.pwd, '.hg'))
|
408
|
+
:hg
|
409
|
+
else
|
410
|
+
:none
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
def execute_vcs_diff(vcs_type, branch_option = nil)
|
415
|
+
# Ensure shell commands are properly escaped if branch_option comes from user input
|
416
|
+
# Though OptionParser should handle this for fixed options.
|
417
|
+
# For this method, branch_option is generally safe as it's from parsed options.
|
418
|
+
|
419
|
+
case vcs_type
|
420
|
+
when :git
|
421
|
+
target_branch_name = branch_option == 'auto' ? detect_git_default_branch : branch_option
|
422
|
+
if target_branch_name
|
423
|
+
unless validate_git_branch_exists(target_branch_name)
|
424
|
+
puts "Error: Git branch '#{target_branch_name}' does not exist or is not accessible."
|
425
|
+
# Suggest available branches if possible (might be noisy, consider a debug flag)
|
426
|
+
# puts "Available local branches:\n`git branch`"
|
427
|
+
# puts "Available remote branches for 'origin':\n`git branch -r`"
|
428
|
+
exit 1
|
429
|
+
end
|
430
|
+
puts "Comparing current HEAD against git branch '#{target_branch_name}'..."
|
431
|
+
# Use three dots for "changes on your branch since target_branch_name diverged from it"
|
432
|
+
# Or two dots for "changes between target_branch_name and your current HEAD"
|
433
|
+
# Three dots is common for PR-like diffs.
|
434
|
+
`git diff #{Shellwords.escape(target_branch_name)}...HEAD`
|
435
|
+
else
|
436
|
+
puts "Could not automatically detect default git branch and no branch specified. Falling back to 'git diff HEAD' (staged changes)..."
|
437
|
+
`git diff HEAD` # This shows staged changes. `git diff` shows unstaged.
|
438
|
+
end
|
439
|
+
when :hg
|
440
|
+
target_branch_name = branch_option == 'auto' ? detect_hg_default_branch : branch_option
|
441
|
+
if target_branch_name
|
442
|
+
unless validate_hg_branch_exists(target_branch_name)
|
443
|
+
puts "Error: Mercurial branch '#{target_branch_name}' does not exist."
|
444
|
+
exit 1
|
445
|
+
end
|
446
|
+
puts "Comparing current working directory against hg branch '#{target_branch_name}'..."
|
447
|
+
# For hg, diff against the revision of the branch tip
|
448
|
+
`hg diff -r #{Shellwords.escape(target_branch_name)}`
|
449
|
+
else
|
450
|
+
puts "Could not automatically detect default hg branch and no branch specified. Falling back to 'hg diff' (uncommitted changes)..."
|
451
|
+
`hg diff` # Shows uncommitted changes.
|
452
|
+
end
|
453
|
+
else
|
454
|
+
puts "Error: Unsupported VCS type." # Should be caught by get_vcs_type check earlier
|
455
|
+
""
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
def detect_git_default_branch
|
460
|
+
# Method 1: Check origin/HEAD symbolic ref (most reliable for remotes)
|
461
|
+
result = `git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null`.strip
|
462
|
+
return result.split('/').last if $?.success? && !result.empty?
|
463
|
+
|
464
|
+
# Method 2: Check local 'main' or 'master' if they track a remote branch
|
465
|
+
['main', 'master'].each do |branch|
|
466
|
+
local_tracking = `git rev-parse --abbrev-ref #{branch}@{upstream} 2>/dev/null`.strip
|
467
|
+
return branch if $?.success? && !local_tracking.empty?
|
468
|
+
end
|
469
|
+
|
470
|
+
# Method 3: Check remote show origin for HEAD branch (less direct but good fallback)
|
471
|
+
result = `git remote show origin 2>/dev/null | grep "HEAD branch"`.strip
|
472
|
+
if $?.success? && !result.empty?
|
473
|
+
match = result.match(/HEAD branch:\s*(\S+)/)
|
474
|
+
return match[1] if match && match[1] != '(unknown)'
|
475
|
+
end
|
476
|
+
|
477
|
+
# Method 4: Last resort, check for local main/master even if not tracking
|
478
|
+
['main', 'master'].each do |branch|
|
479
|
+
return branch if system("git show-ref --verify --quiet refs/heads/#{branch}")
|
480
|
+
end
|
481
|
+
nil # No default branch found
|
482
|
+
end
|
483
|
+
|
484
|
+
def detect_hg_default_branch
|
485
|
+
# Mercurial typically uses 'default' as the main branch.
|
486
|
+
# `hg branch` shows the current branch. If it's 'default', that's a strong candidate.
|
487
|
+
current_branch = `hg branch 2>/dev/null`.strip
|
488
|
+
return current_branch if $?.success? && current_branch == 'default'
|
489
|
+
|
490
|
+
# Check if 'default' branch exists in the list of branches
|
491
|
+
branches_output = `hg branches 2>/dev/null`
|
492
|
+
if $?.success? && branches_output.lines.any? { |line| line.strip.start_with?('default ') }
|
493
|
+
return 'default'
|
494
|
+
end
|
495
|
+
|
496
|
+
# Fallback if 'default' isn't found but there's only one branch (common in simple repos)
|
497
|
+
if $?.success?
|
498
|
+
active_branches = branches_output.lines.reject { |line| line.include?('(inactive)') }
|
499
|
+
if active_branches.size == 1
|
500
|
+
return active_branches.first.split.first
|
501
|
+
end
|
502
|
+
end
|
503
|
+
|
504
|
+
# If current branch is not 'default' but exists, it might be the one being used as main.
|
505
|
+
return current_branch if $?.success? && !current_branch.empty?
|
506
|
+
|
507
|
+
'default' # Default assumption for Mercurial
|
508
|
+
end
|
509
|
+
|
510
|
+
def validate_git_branch_exists(branch)
|
511
|
+
# Check local branches
|
512
|
+
return true if system("git show-ref --verify --quiet refs/heads/#{Shellwords.escape(branch)}")
|
513
|
+
# Check remote branches (assuming 'origin' remote)
|
514
|
+
return true if system("git show-ref --verify --quiet refs/remotes/origin/#{Shellwords.escape(branch)}")
|
515
|
+
false
|
516
|
+
end
|
517
|
+
|
518
|
+
def validate_hg_branch_exists(branch)
|
519
|
+
# `hg branches` lists all branches. Check if the branch is in the list.
|
520
|
+
# `hg id -r branchname` would fail if branch doesn't exist.
|
521
|
+
system("hg id -r #{Shellwords.escape(branch)} > /dev/null 2>&1")
|
522
|
+
end
|
523
|
+
|
524
|
+
def build_diff_analysis_prompt(diff_output, user_prompt_addition = "", requirements_content = nil, config = {})
|
525
|
+
default_system_prompt_path = resolve_template_path('diff_system_prompt', config) # Ensure this template exists
|
526
|
+
default_system_prompt = File.read(default_system_prompt_path).strip rescue "Analyze this diff." # Fallback
|
527
|
+
|
528
|
+
# user_prompt_addition is @options[:custom_message], already validated and sanitized.
|
529
|
+
user_instructions_section = ""
|
530
|
+
if user_prompt_addition && !user_prompt_addition.to_s.strip.empty?
|
531
|
+
# No need to call .strip here again as sanitize_message in parse_options already does it.
|
532
|
+
user_instructions_section = "User's Custom Instructions:\n#{user_prompt_addition}\n\n---\n\n"
|
533
|
+
end
|
534
|
+
|
535
|
+
requirements_section = ""
|
536
|
+
if requirements_content && !requirements_content.to_s.strip.empty?
|
537
|
+
requirements_section = <<-REQUIREMENTS_BLOCK
|
538
|
+
CRITICAL REQUIREMENTS EVALUATION:
|
539
|
+
Based on the provided context, evaluate if the code changes meet these requirements.
|
540
|
+
For each requirement, explicitly state whether it is:
|
541
|
+
- ✅ IMPLEMENTED
|
542
|
+
- ⚠️ PARTIALLY IMPLEMENTED
|
543
|
+
- ❌ NOT IMPLEMENTED
|
544
|
+
- 🔍 UNCLEAR
|
545
|
+
|
546
|
+
--- BEGIN REQUIREMENTS ---
|
547
|
+
#{requirements_content.strip}
|
548
|
+
--- END REQUIREMENTS ---
|
549
|
+
|
550
|
+
REQUIREMENTS_BLOCK
|
551
|
+
end
|
552
|
+
|
553
|
+
analysis_intro = "Analyze the following code diff. Focus on identifying potential bugs, suggesting improvements, and assessing test coverage. If requirements are provided, evaluate against them."
|
554
|
+
|
555
|
+
# Context extraction (can be intensive, consider making optional or smarter)
|
556
|
+
# context_sections = extract_code_context_from_diff(diff_output) # This can be slow
|
557
|
+
# context_info = context_sections.empty? ? "" : "\n\nRelevant Code Context:\n#{format_context_for_prompt(context_sections)}"
|
558
|
+
# For now, let's keep context extraction simpler or rely on LLM's ability with just the diff.
|
559
|
+
# If extract_code_context_from_diff is too complex or slow, it might be omitted or simplified.
|
560
|
+
# For this integration, assuming extract_code_context_from_diff is efficient enough.
|
561
|
+
context_sections = extract_code_context_from_diff(diff_output)
|
562
|
+
context_info = ""
|
563
|
+
unless context_sections.empty?
|
564
|
+
context_info = "\n\nCurrent Code Context (for better analysis):\n"
|
565
|
+
context_sections.each do |file_path, sections|
|
566
|
+
context_info += "\n--- Context from: #{file_path} ---\n"
|
567
|
+
sections.each do |section|
|
568
|
+
context_info += "Lines approx #{section[:start_line]}-#{section[:end_line]}:\n" # Approximate lines
|
569
|
+
context_info += "```\n#{section[:content]}\n```\n\n"
|
570
|
+
end
|
571
|
+
end
|
572
|
+
end
|
573
|
+
|
574
|
+
|
575
|
+
json_instruction_path = resolve_template_path('diff_json_instruction', config) # Ensure this template exists
|
576
|
+
json_instruction = File.read(json_instruction_path).strip rescue 'Respond in JSON format with keys: "summary", "errors", "improvements", "test_coverage", "requirements_evaluation", "ticket_implementation_summary".' # Fallback
|
577
|
+
|
578
|
+
full_prompt = [
|
579
|
+
user_instructions_section, # Custom instructions first
|
580
|
+
default_system_prompt,
|
581
|
+
requirements_section,
|
582
|
+
analysis_intro,
|
583
|
+
"Diff:\n```diff\n#{diff_output}\n```", # Ensure diff is marked as diff language
|
584
|
+
context_info,
|
585
|
+
json_instruction
|
586
|
+
].select { |s| s && !s.strip.empty? }.join("\n\n")
|
587
|
+
|
588
|
+
full_prompt
|
589
|
+
end
|
590
|
+
|
591
|
+
def analyze_diff(diff_output, config, user_prompt_addition = "", requirements_content = nil)
|
592
|
+
# user_prompt_addition here IS the custom_message from options
|
593
|
+
prompt = build_diff_analysis_prompt(diff_output, user_prompt_addition, requirements_content, config)
|
594
|
+
|
595
|
+
# Use analyze_diff_with_spinner for this call
|
596
|
+
analysis_json_str = analyze_diff_with_spinner(config) do |llm| # Pass config to spinner wrapper
|
597
|
+
llm.analyze_code_diff(prompt) # This is the actual call the spinner will make
|
598
|
+
end
|
599
|
+
# Fallback if spinner itself has an issue or if LLM call within spinner fails before LlmApiError
|
600
|
+
analysis_json_str ||= '{"summary": "Error: Failed to get analysis from LLM.", "errors": [], "improvements": []}'
|
601
|
+
|
602
|
+
|
603
|
+
begin
|
604
|
+
json_content = extract_json_from_response(analysis_json_str)
|
605
|
+
analysis_result = JSON.parse(json_content)
|
606
|
+
|
607
|
+
# Output formatting (consider making this a separate method or class)
|
608
|
+
puts "\n#{COLOR_BLUE}📊 Code Diff Analysis:#{COLOR_RESET}"
|
609
|
+
puts "-------------------"
|
610
|
+
puts "#{COLOR_GREEN}Summary:#{COLOR_RESET}"
|
611
|
+
puts analysis_result['summary'] || "No summary provided."
|
612
|
+
|
613
|
+
puts "\n#{COLOR_RED}Potential Issues/Errors:#{COLOR_RESET}"
|
614
|
+
errors_list = [analysis_result['errors']].flatten.compact.reject(&:empty?)
|
615
|
+
puts errors_list.any? ? errors_list.map { |err| "- #{err}" }.join("\n") : " No specific errors identified."
|
616
|
+
|
617
|
+
puts "\n#{COLOR_YELLOW}Suggested Improvements:#{COLOR_RESET}"
|
618
|
+
improvements_list = [analysis_result['improvements']].flatten.compact.reject(&:empty?)
|
619
|
+
puts improvements_list.any? ? improvements_list.map { |imp| "- #{imp}" }.join("\n") : " No specific improvements suggested."
|
620
|
+
|
621
|
+
puts "\n#{COLOR_BLUE}Test Coverage Assessment:#{COLOR_RESET}"
|
622
|
+
test_coverage = analysis_result['test_coverage']
|
623
|
+
puts test_coverage && !test_coverage.to_s.strip.empty? ? test_coverage : " No test coverage assessment provided."
|
624
|
+
|
625
|
+
if requirements_content && !requirements_content.to_s.strip.empty?
|
626
|
+
puts "\n#{COLOR_GREEN}Requirements Evaluation:#{COLOR_RESET}"
|
627
|
+
eval_text = analysis_result['requirements_evaluation']
|
628
|
+
puts eval_text && !eval_text.to_s.strip.empty? ? eval_text : " No requirements evaluation provided."
|
629
|
+
end
|
630
|
+
|
631
|
+
# This field is often more specific for tickets
|
632
|
+
ticket_summary = analysis_result['ticket_implementation_summary']
|
633
|
+
if ticket_summary && !ticket_summary.to_s.strip.empty?
|
634
|
+
puts "\n#{COLOR_GREEN}Ticket Implementation Summary:#{COLOR_RESET}"
|
635
|
+
puts ticket_summary
|
636
|
+
end
|
637
|
+
|
638
|
+
puts "-------------------"
|
639
|
+
return analysis_result
|
640
|
+
rescue JSON::ParserError => e
|
641
|
+
puts "#{COLOR_RED}Critical Error: Failed to parse JSON response for diff analysis: #{e.message}#{COLOR_RESET}"
|
642
|
+
puts "Raw response was: #{analysis_json_str}"
|
643
|
+
# Fallback if parsing fails
|
644
|
+
return {"summary" => "Failed to parse analysis.", "errors" => ["JSON parsing error."], "improvements" => [], "test_coverage" => "Unknown", "requirements_evaluation" => "Unknown", "ticket_implementation_summary" => "Unknown"}
|
645
|
+
end
|
646
|
+
end
|
647
|
+
|
648
|
+
# Extracted from original N2B::CLI, might need slight path adjustments if templates are structured differently
|
649
|
+
# def resolve_template_path(template_key, config)
|
650
|
+
# user_path = config.dig('templates', template_key) if config.is_a?(Hash)
|
651
|
+
# return user_path if user_path && File.exist?(user_path)
|
652
|
+
# # Assuming templates are in a subdir relative to THIS file (merge_cli.rb)
|
653
|
+
# File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
|
654
|
+
# end
|
655
|
+
|
656
|
+
|
657
|
+
def format_analysis_for_jira(analysis_result, custom_message = nil)
|
658
|
+
# Basic check, ensure analysis_result is a hash
|
659
|
+
return { body: "Error: Analysis result is not in the expected format." } unless analysis_result.is_a?(Hash)
|
660
|
+
|
661
|
+
summary = analysis_result['ticket_implementation_summary']&.strip || analysis_result['summary']&.strip || "No summary."
|
662
|
+
|
663
|
+
details_parts = []
|
664
|
+
details_parts << "*Custom Analysis Focus:*\n#{custom_message}\n" if custom_message && !custom_message.empty?
|
665
|
+
details_parts << "*Technical Summary:*\n#{analysis_result['summary']&.strip}\n" if analysis_result['summary']
|
666
|
+
|
667
|
+
issues = format_issues_for_adf(analysis_result['errors'])
|
668
|
+
improvements = format_improvements_for_adf(analysis_result['improvements'])
|
669
|
+
|
670
|
+
details_parts << "*Potential Issues:*\n" + (issues.empty? ? "None identified.\n" : issues.map { |i| "- #{i}\n" }.join)
|
671
|
+
details_parts << "*Suggested Improvements:*\n" + (improvements.empty? ? "None identified.\n" : improvements.map { |i| "- #{i}\n" }.join)
|
672
|
+
details_parts << "*Test Coverage Assessment:*\n#{analysis_result['test_coverage']&.strip || "Not assessed."}\n"
|
673
|
+
|
674
|
+
req_eval = analysis_result['requirements_evaluation']&.strip
|
675
|
+
details_parts << "*Requirements Evaluation:*\n#{req_eval}\n" if req_eval && !req_eval.empty?
|
676
|
+
|
677
|
+
# ADF structure for Jira
|
678
|
+
# This is a simplified version. Real ADF is more complex.
|
679
|
+
# N2B::JiraClient would need to construct the actual ADF document.
|
680
|
+
# Here, we are returning a hash that the client can use.
|
681
|
+
{
|
682
|
+
implementation_summary: summary, # Often used as a primary comment or field
|
683
|
+
technical_summary: analysis_result['summary']&.strip,
|
684
|
+
issues: issues, # Array of strings
|
685
|
+
improvements: improvements, # Array of strings
|
686
|
+
test_coverage: analysis_result['test_coverage']&.strip,
|
687
|
+
requirements_evaluation: req_eval,
|
688
|
+
custom_analysis_focus: custom_message # Added new key
|
689
|
+
}
|
690
|
+
end
|
691
|
+
|
692
|
+
def format_issues_for_adf(errors) # Helper for Jira/GitHub formatting
|
693
|
+
return [] unless errors.is_a?(Array) && errors.any?
|
694
|
+
errors.map(&:strip).reject(&:empty?)
|
695
|
+
end
|
696
|
+
|
697
|
+
def format_improvements_for_adf(improvements) # Helper for Jira/GitHub formatting
|
698
|
+
return [] unless improvements.is_a?(Array) && improvements.any?
|
699
|
+
improvements.map(&:strip).reject(&:empty?)
|
700
|
+
end
|
701
|
+
|
702
|
+
def format_analysis_for_github(analysis_result, custom_message = nil)
|
703
|
+
# Basic check
|
704
|
+
return "Error: Analysis result is not in the expected format." unless analysis_result.is_a?(Hash)
|
705
|
+
|
706
|
+
title = "### Code Diff Analysis Summary 🤖\n"
|
707
|
+
summary_section = "**Implementation/Overall Summary:**\n#{analysis_result['ticket_implementation_summary']&.strip || analysis_result['summary']&.strip || "No summary provided."}\n\n"
|
708
|
+
|
709
|
+
details_parts = []
|
710
|
+
details_parts << "**Custom Analysis Focus:**\n#{custom_message}\n" if custom_message && !custom_message.empty?
|
711
|
+
details_parts << "**Technical Detail Summary:**\n#{analysis_result['summary']&.strip}\n" if analysis_result['summary'] && analysis_result['summary'] != analysis_result['ticket_implementation_summary']
|
712
|
+
|
713
|
+
|
714
|
+
errors_list = [analysis_result['errors']].flatten.compact.reject(&:empty?)
|
715
|
+
details_parts << "**Potential Issues/Errors:**\n" + (errors_list.empty? ? "_None identified._\n" : errors_list.map { |err| "- [ ] #{err}\n" }.join) # Added checkbox
|
716
|
+
|
717
|
+
improvements_list = [analysis_result['improvements']].flatten.compact.reject(&:empty?)
|
718
|
+
details_parts << "**Suggested Improvements:**\n" + (improvements_list.empty? ? "_None identified._\n" : improvements_list.map { |imp| "- [ ] #{imp}\n" }.join) # Added checkbox
|
719
|
+
|
720
|
+
details_parts << "**Test Coverage Assessment:**\n#{analysis_result['test_coverage']&.strip || "_Not assessed._"}\n"
|
721
|
+
|
722
|
+
req_eval = analysis_result['requirements_evaluation']&.strip
|
723
|
+
details_parts << "**Requirements Evaluation:**\n#{req_eval.empty? ? "_Not applicable or not assessed._" : req_eval}\n" if req_eval
|
724
|
+
|
725
|
+
# For GitHub, typically a Markdown string is returned for the comment body.
|
726
|
+
# The N2B::GitHubClient would take this string.
|
727
|
+
# Returning a hash that the client can use to build the comment.
|
728
|
+
{
|
729
|
+
title: "Code Diff Analysis Summary 🤖",
|
730
|
+
implementation_summary: analysis_result['ticket_implementation_summary']&.strip || analysis_result['summary']&.strip,
|
731
|
+
technical_summary: analysis_result['summary']&.strip,
|
732
|
+
issues: errors_list, # Array of strings
|
733
|
+
improvements: improvements_list, # Array of strings
|
734
|
+
test_coverage: analysis_result['test_coverage']&.strip,
|
735
|
+
requirements_evaluation: req_eval,
|
736
|
+
custom_analysis_focus: custom_message, # Added new key
|
737
|
+
# Construct a body for convenience, client can override
|
738
|
+
body: title + summary_section + details_parts.join("\n")
|
739
|
+
}
|
740
|
+
end
|
741
|
+
|
742
|
+
def extract_json_from_response(response)
|
743
|
+
# This is a robust way to extract JSON that might be embedded in other text.
|
744
|
+
# First, try to parse as-is, in case the LLM behaves perfectly.
|
745
|
+
begin
|
746
|
+
JSON.parse(response)
|
747
|
+
return response # It's already valid JSON
|
748
|
+
rescue JSON::ParserError
|
749
|
+
# If not, search for the first '{' and last '}'
|
750
|
+
end
|
751
|
+
|
752
|
+
json_start = response.index('{')
|
753
|
+
json_end = response.rindex('}') # Use rindex for the last occurrence
|
754
|
+
|
755
|
+
if json_start && json_end && json_end > json_start
|
756
|
+
potential_json = response[json_start..json_end]
|
757
|
+
begin
|
758
|
+
JSON.parse(potential_json) # Check if this substring is valid JSON
|
759
|
+
return potential_json
|
760
|
+
rescue JSON::ParserError
|
761
|
+
# Fallback: if the strict extraction fails, return the original response
|
762
|
+
# and let the caller deal with a more comprehensive repair or error.
|
763
|
+
# This can happen if there are '{' or '}' in string literals within the JSON.
|
764
|
+
# A more sophisticated parser would be needed for those cases.
|
765
|
+
# For now, this is a common heuristic.
|
766
|
+
return response # Or perhaps an error string/nil
|
767
|
+
end
|
768
|
+
else
|
769
|
+
# No clear JSON structure found, return original response for error handling
|
770
|
+
return response
|
771
|
+
end
|
772
|
+
end
|
773
|
+
|
774
|
+
def extract_code_context_from_diff(diff_output, lines_of_context: 5)
|
775
|
+
# Simplified context extraction focusing on lines around changes.
|
776
|
+
# This is a placeholder for potentially more sophisticated context extraction.
|
777
|
+
# A full AST parse or more complex diff parsing could be used for richer context.
|
778
|
+
context_sections = {}
|
779
|
+
current_file = nil
|
780
|
+
file_lines_buffer = {} # Cache for file lines
|
781
|
+
|
782
|
+
# First pass: identify files and changed line numbers from hunk headers
|
783
|
+
# @@ -old_start,old_lines +new_start,new_lines
|
784
|
+
hunks_by_file = {}
|
785
|
+
diff_output.each_line do |line|
|
786
|
+
line.chomp!
|
787
|
+
if line.start_with?('diff --git')
|
788
|
+
# Extracts 'b_file_path' from "diff --git a/a_file_path b/b_file_path"
|
789
|
+
match = line.match(/diff --git a\/(.+?) b\/(.+)/)
|
790
|
+
current_file = match[2] if match && match[2] != '/dev/null'
|
791
|
+
elsif line.start_with?('+++ b/')
|
792
|
+
# Extracts file path from "+++ b/file_path"
|
793
|
+
current_file = line[6..].strip unless line.include?('/dev/null')
|
794
|
+
elsif line.start_with?('@@') && current_file
|
795
|
+
match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
|
796
|
+
next unless match
|
797
|
+
start_line = match[1].to_i
|
798
|
+
# hunk_lines = match[2]&.to_i || 1 # Number of lines in the hunk in the new file
|
799
|
+
# For context, we care more about the range of lines around the start_line of the change.
|
800
|
+
hunks_by_file[current_file] ||= []
|
801
|
+
hunks_by_file[current_file] << { new_start: start_line }
|
802
|
+
end
|
803
|
+
end
|
804
|
+
|
805
|
+
# Second pass: extract context for each hunk
|
806
|
+
hunks_by_file.each do |file_path, hunks|
|
807
|
+
next unless File.exist?(file_path) # Ensure file exists to read context from
|
808
|
+
|
809
|
+
file_lines_buffer[file_path] ||= File.readlines(file_path, chomp: true)
|
810
|
+
all_lines = file_lines_buffer[file_path]
|
811
|
+
|
812
|
+
hunks.each do |hunk|
|
813
|
+
context_start_line = [1, hunk[:new_start] - lines_of_context].max
|
814
|
+
# Estimate end of change based on typical hunk sizes or simplify
|
815
|
+
# For simplicity, take fixed lines after start, or up to next hunk.
|
816
|
+
# This is a very rough heuristic.
|
817
|
+
context_end_line = [all_lines.length, hunk[:new_start] + lines_of_context + 5].min # +5 for some change content
|
818
|
+
|
819
|
+
actual_start_index = context_start_line - 1
|
820
|
+
actual_end_index = context_end_line - 1
|
821
|
+
|
822
|
+
if actual_start_index < all_lines.length && actual_start_index <= actual_end_index
|
823
|
+
section_content = all_lines[actual_start_index..actual_end_index].join("\n")
|
824
|
+
context_sections[file_path] ||= []
|
825
|
+
# Avoid duplicate sections if hunks are very close
|
826
|
+
unless context_sections[file_path].any? { |s| s[:content] == section_content }
|
827
|
+
context_sections[file_path] << {
|
828
|
+
# Store actual line numbers for reference, not just indices
|
829
|
+
start_line: context_start_line,
|
830
|
+
end_line: context_end_line,
|
831
|
+
content: section_content
|
832
|
+
}
|
833
|
+
end
|
834
|
+
end
|
835
|
+
end
|
836
|
+
end
|
837
|
+
context_sections
|
838
|
+
end
|
839
|
+
|
840
|
+
# Spinner method specifically for diff analysis
|
841
|
+
def analyze_diff_with_spinner(config) # Takes config to initialize LLM
|
842
|
+
llm_service_name = config['llm']
|
843
|
+
llm = case llm_service_name # Initialize LLM based on config
|
844
|
+
when 'openai' then N2B::Llm::OpenAi.new(config)
|
845
|
+
when 'claude' then N2B::Llm::Claude.new(config)
|
846
|
+
when 'gemini' then N2B::Llm::Gemini.new(config)
|
847
|
+
when 'openrouter' then N2B::Llm::OpenRouter.new(config)
|
848
|
+
when 'ollama' then N2B::Llm::Ollama.new(config)
|
849
|
+
else raise N2B::Error, "Unsupported LLM service for analysis: #{llm_service_name}"
|
850
|
+
end
|
851
|
+
|
852
|
+
spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
853
|
+
spinner_thread = Thread.new do
|
854
|
+
i = 0
|
855
|
+
loop do
|
856
|
+
print "\r#{COLOR_BLUE}🔍 #{spinner_chars[i % spinner_chars.length]} Analyzing diff...#{COLOR_RESET}"
|
857
|
+
$stdout.flush
|
858
|
+
sleep(0.1)
|
859
|
+
i += 1
|
860
|
+
end
|
861
|
+
end
|
862
|
+
|
863
|
+
begin
|
864
|
+
# The block passed to this method contains the actual LLM call
|
865
|
+
result = yield(llm) if block_given?
|
866
|
+
spinner_thread.kill
|
867
|
+
spinner_thread.join # Ensure thread is fully cleaned up
|
868
|
+
print "\r#{' ' * 35}\r" # Clear spinner line
|
869
|
+
puts "#{COLOR_GREEN}✅ Diff analysis complete!#{COLOR_RESET}"
|
870
|
+
result
|
871
|
+
rescue N2B::LlmApiError => e
|
872
|
+
spinner_thread.kill
|
873
|
+
spinner_thread.join
|
874
|
+
print "\r#{' ' * 35}\r"
|
875
|
+
puts "#{COLOR_RED}LLM API Error during diff analysis: #{e.message}#{COLOR_RESET}"
|
876
|
+
# Provide specific model error guidance
|
877
|
+
if e.message.match?(/model|invalid|not found/i)
|
878
|
+
puts "#{COLOR_YELLOW}This might be due to an invalid or unsupported model in your config. Run 'n2b -c' to reconfigure.#{COLOR_RESET}"
|
879
|
+
end
|
880
|
+
# Return a structured error JSON
|
881
|
+
'{"summary": "Error: Could not analyze diff due to LLM API error.", "errors": ["#{e.message}"], "improvements": []}'
|
882
|
+
rescue StandardError => e # Catch other unexpected errors during the yield or LLM call
|
883
|
+
spinner_thread.kill
|
884
|
+
spinner_thread.join
|
885
|
+
print "\r#{' ' * 35}\r"
|
886
|
+
puts "#{COLOR_RED}Unexpected error during diff analysis: #{e.message}#{COLOR_RESET}"
|
887
|
+
'{"summary": "Error: Unexpected failure during diff analysis.", "errors": ["#{e.message}"], "improvements": []}'
|
888
|
+
end
|
889
|
+
end
|
890
|
+
# --- End of moved methods ---
|
891
|
+
|
892
|
+
|
893
|
+
def resolve_block(block, config, full_file_content)
|
894
|
+
comment = nil
|
895
|
+
|
896
|
+
# Display file and line information
|
897
|
+
puts "\n#{COLOR_BLUE}📁 File: #{@file_path}#{COLOR_RESET}"
|
898
|
+
puts "#{COLOR_BLUE}📍 Lines: #{block.start_line}-#{block.end_line} (#{block.base_label} ↔ #{block.incoming_label})#{COLOR_RESET}"
|
899
|
+
puts "#{COLOR_GRAY}💡 You can check this conflict in your editor at the specified line numbers#{COLOR_RESET}\n"
|
900
|
+
|
901
|
+
puts "#{COLOR_YELLOW}🤖 AI is analyzing the conflict...#{COLOR_RESET}"
|
902
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
903
|
+
puts "#{COLOR_GREEN}✅ Initial suggestion ready!#{COLOR_RESET}\n"
|
904
|
+
|
905
|
+
vcs_type = get_vcs_type_for_file_operations
|
906
|
+
|
907
|
+
# Convert conflict labels to actual VCS revision identifiers
|
908
|
+
base_revision = convert_label_to_revision(block.base_label, vcs_type, :base)
|
909
|
+
incoming_revision = convert_label_to_revision(block.incoming_label, vcs_type, :incoming)
|
910
|
+
|
911
|
+
base_content_full = get_file_content_from_vcs(base_revision, @file_path, vcs_type) || difficulté_to_load_content_placeholder("base content from #{block.base_label}")
|
912
|
+
incoming_content_full = get_file_content_from_vcs(incoming_revision, @file_path, vcs_type) || difficulté_to_load_content_placeholder("incoming content from #{block.incoming_label}")
|
913
|
+
|
914
|
+
generated_html_path = nil
|
915
|
+
|
916
|
+
begin
|
917
|
+
loop do
|
918
|
+
current_resolution_content_full = apply_hunk_to_full_content(full_file_content, block, suggestion['merged_code'])
|
919
|
+
|
920
|
+
# Don't delete the HTML file immediately - keep it available for user preview
|
921
|
+
|
922
|
+
generated_html_path = generate_conflict_preview_html(
|
923
|
+
block,
|
924
|
+
base_content_full,
|
925
|
+
incoming_content_full,
|
926
|
+
current_resolution_content_full,
|
927
|
+
block.base_label,
|
928
|
+
block.incoming_label,
|
929
|
+
@file_path,
|
930
|
+
suggestion
|
931
|
+
)
|
932
|
+
|
933
|
+
preview_link_message = ""
|
934
|
+
if generated_html_path && File.exist?(generated_html_path)
|
935
|
+
preview_link_message = "🌐 #{COLOR_BLUE}Preview: file://#{generated_html_path}#{COLOR_RESET}"
|
936
|
+
else
|
937
|
+
preview_link_message = "#{COLOR_YELLOW}⚠️ Could not generate HTML preview.#{COLOR_RESET}"
|
938
|
+
end
|
939
|
+
puts preview_link_message
|
940
|
+
|
941
|
+
print_conflict(block)
|
942
|
+
print_suggestion(suggestion)
|
943
|
+
|
944
|
+
prompt_message = <<~PROMPT
|
945
|
+
#{COLOR_YELLOW}Actions: [y] Accept, [n] Skip, [c] Comment, [e] Edit, [p] Preview, [s] Refresh, [a] Abort#{COLOR_RESET}
|
946
|
+
#{COLOR_GRAY}(Preview link above can be cmd/ctrl+clicked if your terminal supports it){COLOR_RESET}
|
947
|
+
#{COLOR_YELLOW}Your choice: #{COLOR_RESET}
|
948
|
+
PROMPT
|
949
|
+
print prompt_message
|
950
|
+
choice = $stdin.gets&.strip&.downcase
|
951
|
+
|
952
|
+
case choice
|
953
|
+
when 'y'
|
954
|
+
return {accepted: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
955
|
+
when 'n'
|
956
|
+
return {accepted: false, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
957
|
+
when 'c'
|
958
|
+
puts 'Enter comment (end with blank line):'
|
959
|
+
comment = read_multiline_input
|
960
|
+
puts "#{COLOR_YELLOW}🤖 AI is analyzing your comment and generating new suggestion...#{COLOR_RESET}"
|
961
|
+
current_file_on_disk = File.exist?(@file_path) ? File.read(@file_path) : full_file_content
|
962
|
+
full_file_content = current_file_on_disk
|
963
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
964
|
+
puts "#{COLOR_GREEN}✅ New suggestion ready!#{COLOR_RESET}\n"
|
965
|
+
# Loop continues, will regenerate preview
|
966
|
+
when 'e'
|
967
|
+
edit_result = handle_editor_workflow(block, config, full_file_content)
|
968
|
+
if edit_result[:resolved]
|
969
|
+
return {accepted: true, merged_code: edit_result[:merged_code], reason: edit_result[:reason], comment: comment}
|
970
|
+
elsif edit_result[:updated_content]
|
971
|
+
full_file_content = edit_result[:updated_content]
|
972
|
+
puts "#{COLOR_YELLOW}🤖 Content changed by editor. Re-analyzing for new suggestion...#{COLOR_RESET}"
|
973
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
974
|
+
end
|
975
|
+
# Loop continues, will regenerate preview
|
976
|
+
when 'p' # Open Preview in Browser
|
977
|
+
if generated_html_path && File.exist?(generated_html_path)
|
978
|
+
puts "#{COLOR_BLUE}🌐 Opening preview in browser...#{COLOR_RESET}"
|
979
|
+
open_html_in_browser(generated_html_path)
|
980
|
+
else
|
981
|
+
puts "#{COLOR_YELLOW}⚠️ No preview available to open.#{COLOR_RESET}"
|
982
|
+
end
|
983
|
+
# Loop continues, no changes to suggestion
|
984
|
+
when 's' # Refresh Preview
|
985
|
+
puts "#{COLOR_BLUE}🔄 Refreshing suggestion and preview...#{COLOR_RESET}"
|
986
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
987
|
+
# Loop continues, preview will be regenerated
|
988
|
+
when 'a'
|
989
|
+
return {abort: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
990
|
+
when '', nil
|
991
|
+
puts "#{COLOR_RED}Please enter a valid choice.#{COLOR_RESET}"
|
992
|
+
else
|
993
|
+
puts "#{COLOR_RED}Invalid option. Please choose from the available actions.#{COLOR_RESET}"
|
994
|
+
end
|
995
|
+
end
|
996
|
+
ensure
|
997
|
+
FileUtils.rm_f(generated_html_path) if generated_html_path && File.exist?(generated_html_path)
|
998
|
+
end
|
999
|
+
end
|
1000
|
+
|
1001
|
+
def difficulté_to_load_content_placeholder(description)
|
1002
|
+
# Helper to return a placeholder if VCS content fails, aiding debug in preview
|
1003
|
+
"N2B: Could not load #{description}. Displaying this placeholder."
|
1004
|
+
end
|
1005
|
+
|
1006
|
+
def request_merge(block, config, comment, full_file_content)
|
1007
|
+
prompt = build_merge_prompt(block, comment, full_file_content)
|
1008
|
+
json_str = call_llm_for_merge(prompt, config)
|
1009
|
+
|
1010
|
+
begin
|
1011
|
+
parsed = JSON.parse(extract_json(json_str))
|
1012
|
+
|
1013
|
+
# Validate the response structure
|
1014
|
+
unless parsed.is_a?(Hash) && parsed.key?('merged_code') && parsed.key?('reason')
|
1015
|
+
raise JSON::ParserError, "Response missing required keys 'merged_code' and 'reason'"
|
1016
|
+
end
|
1017
|
+
|
1018
|
+
parsed
|
1019
|
+
rescue JSON::ParserError => e
|
1020
|
+
# First try automatic JSON repair
|
1021
|
+
puts "#{COLOR_YELLOW}⚠️ Invalid JSON detected, attempting automatic repair...#{COLOR_RESET}"
|
1022
|
+
repaired_response = attempt_json_repair(json_str, config)
|
1023
|
+
|
1024
|
+
if repaired_response
|
1025
|
+
puts "#{COLOR_GREEN}✅ JSON repair successful!#{COLOR_RESET}"
|
1026
|
+
return repaired_response
|
1027
|
+
else
|
1028
|
+
puts "#{COLOR_RED}❌ JSON repair failed#{COLOR_RESET}"
|
1029
|
+
handle_invalid_llm_response(json_str, e, block)
|
1030
|
+
end
|
1031
|
+
end
|
1032
|
+
end
|
1033
|
+
|
1034
|
+
def request_merge_with_spinner(block, config, comment, full_file_content)
|
1035
|
+
spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
1036
|
+
spinner_thread = Thread.new do
|
1037
|
+
i = 0
|
1038
|
+
while true
|
1039
|
+
print "\r#{COLOR_BLUE}#{spinner_chars[i % spinner_chars.length]} Processing...#{COLOR_RESET}"
|
1040
|
+
$stdout.flush
|
1041
|
+
sleep(0.1)
|
1042
|
+
i += 1
|
1043
|
+
end
|
1044
|
+
end
|
1045
|
+
|
1046
|
+
begin
|
1047
|
+
result = request_merge(block, config, comment, full_file_content)
|
1048
|
+
spinner_thread.kill
|
1049
|
+
print "\r#{' ' * 20}\r" # Clear the spinner line
|
1050
|
+
result
|
1051
|
+
rescue => e
|
1052
|
+
spinner_thread.kill
|
1053
|
+
print "\r#{' ' * 20}\r" # Clear the spinner line
|
1054
|
+
{ 'merged_code' => '', 'reason' => "Error: #{e.message}" }
|
1055
|
+
end
|
1056
|
+
end
|
1057
|
+
|
1058
|
+
def build_merge_prompt(block, comment, full_file_content)
|
1059
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
1060
|
+
template_path = resolve_template_path('merge_conflict_prompt', config)
|
1061
|
+
template = File.read(template_path)
|
1062
|
+
|
1063
|
+
user_comment_text = comment && !comment.empty? ? "User comment: #{comment}" : ""
|
1064
|
+
|
1065
|
+
template.gsub('{full_file_content}', full_file_content.to_s)
|
1066
|
+
.gsub('{start_line}', block.start_line.to_s)
|
1067
|
+
.gsub('{end_line}', block.end_line.to_s)
|
1068
|
+
.gsub('{context_before}', block.context_before.to_s)
|
1069
|
+
.gsub('{base_label}', block.base_label.to_s)
|
1070
|
+
.gsub('{base_content}', block.base_content.to_s)
|
1071
|
+
.gsub('{incoming_content}', block.incoming_content.to_s)
|
1072
|
+
.gsub('{incoming_label}', block.incoming_label.to_s)
|
1073
|
+
.gsub('{context_after}', block.context_after.to_s)
|
1074
|
+
.gsub('{user_comment}', user_comment_text)
|
1075
|
+
end
|
1076
|
+
|
1077
|
+
def call_llm_for_merge(prompt, config)
|
1078
|
+
llm_service_name = config['llm']
|
1079
|
+
llm = case llm_service_name
|
1080
|
+
when 'openai'
|
1081
|
+
N2B::Llm::OpenAi.new(config)
|
1082
|
+
when 'claude'
|
1083
|
+
N2B::Llm::Claude.new(config)
|
1084
|
+
when 'gemini'
|
1085
|
+
N2B::Llm::Gemini.new(config)
|
1086
|
+
when 'openrouter'
|
1087
|
+
N2B::Llm::OpenRouter.new(config)
|
1088
|
+
when 'ollama'
|
1089
|
+
N2B::Llm::Ollama.new(config)
|
1090
|
+
else
|
1091
|
+
raise N2B::Error, "Unsupported LLM service: #{llm_service_name}"
|
1092
|
+
end
|
1093
|
+
llm.analyze_code_diff(prompt)
|
1094
|
+
rescue N2B::LlmApiError => e
|
1095
|
+
puts "\n#{COLOR_RED}❌ LLM API Error#{COLOR_RESET}"
|
1096
|
+
puts "#{COLOR_YELLOW}Failed to communicate with the AI service.#{COLOR_RESET}"
|
1097
|
+
puts "#{COLOR_GRAY}Error: #{e.message}#{COLOR_RESET}"
|
1098
|
+
|
1099
|
+
# Check for common error types and provide specific guidance
|
1100
|
+
if e.message.include?('model') || e.message.include?('Model') || e.message.include?('invalid') || e.message.include?('not found')
|
1101
|
+
puts "\n#{COLOR_BLUE}💡 This looks like a model configuration issue.#{COLOR_RESET}"
|
1102
|
+
puts "#{COLOR_YELLOW}Run 'n2b -c' to reconfigure your model settings.#{COLOR_RESET}"
|
1103
|
+
elsif e.message.include?('auth') || e.message.include?('unauthorized') || e.message.include?('401')
|
1104
|
+
puts "\n#{COLOR_BLUE}💡 This looks like an authentication issue.#{COLOR_RESET}"
|
1105
|
+
puts "#{COLOR_YELLOW}Check your API key configuration with 'n2b -c'.#{COLOR_RESET}"
|
1106
|
+
elsif e.message.include?('timeout') || e.message.include?('network')
|
1107
|
+
puts "\n#{COLOR_BLUE}💡 This looks like a network issue.#{COLOR_RESET}"
|
1108
|
+
puts "#{COLOR_YELLOW}Check your internet connection and try again.#{COLOR_RESET}"
|
1109
|
+
end
|
1110
|
+
|
1111
|
+
'{"merged_code":"","reason":"LLM API error: ' + e.message.gsub('"', '\\"') + '"}'
|
1112
|
+
end
|
1113
|
+
|
1114
|
+
def extract_json(response)
|
1115
|
+
JSON.parse(response)
|
1116
|
+
response
|
1117
|
+
rescue JSON::ParserError
|
1118
|
+
start = response.index('{')
|
1119
|
+
stop = response.rindex('}')
|
1120
|
+
return response unless start && stop
|
1121
|
+
response[start..stop]
|
1122
|
+
end
|
1123
|
+
|
1124
|
+
def handle_invalid_llm_response(raw_response, error, block)
|
1125
|
+
puts "\n#{COLOR_RED}❌ Invalid LLM Response Error#{COLOR_RESET}"
|
1126
|
+
puts "#{COLOR_YELLOW}The AI returned an invalid response that couldn't be parsed.#{COLOR_RESET}"
|
1127
|
+
puts "#{COLOR_YELLOW}Automatic JSON repair was attempted but failed.#{COLOR_RESET}"
|
1128
|
+
puts "#{COLOR_GRAY}Error: #{error.message}#{COLOR_RESET}"
|
1129
|
+
|
1130
|
+
# Save problematic response for debugging
|
1131
|
+
save_debug_response(raw_response, error)
|
1132
|
+
|
1133
|
+
# Show truncated raw response for debugging
|
1134
|
+
truncated_response = raw_response.length > 200 ? "#{raw_response[0..200]}..." : raw_response
|
1135
|
+
puts "\n#{COLOR_GRAY}Raw response (truncated):#{COLOR_RESET}"
|
1136
|
+
puts "#{COLOR_GRAY}#{truncated_response}#{COLOR_RESET}"
|
1137
|
+
|
1138
|
+
puts "\n#{COLOR_BLUE}What would you like to do?#{COLOR_RESET}"
|
1139
|
+
puts "#{COLOR_GREEN}[r]#{COLOR_RESET} Retry with the same prompt"
|
1140
|
+
puts "#{COLOR_YELLOW}[c]#{COLOR_RESET} Add a comment to guide the AI better"
|
1141
|
+
puts "#{COLOR_BLUE}[m]#{COLOR_RESET} Manually choose one side of the conflict"
|
1142
|
+
puts "#{COLOR_RED}[s]#{COLOR_RESET} Skip this conflict"
|
1143
|
+
puts "#{COLOR_RED}[a]#{COLOR_RESET} Abort entirely"
|
1144
|
+
|
1145
|
+
loop do
|
1146
|
+
print "#{COLOR_YELLOW}Choose action [r/c/m/s/a]: #{COLOR_RESET}"
|
1147
|
+
choice = $stdin.gets&.strip&.downcase
|
1148
|
+
|
1149
|
+
case choice
|
1150
|
+
when 'r'
|
1151
|
+
puts "#{COLOR_YELLOW}🔄 Retrying with same prompt...#{COLOR_RESET}"
|
1152
|
+
return retry_llm_request(block)
|
1153
|
+
when 'c'
|
1154
|
+
puts "#{COLOR_YELLOW}💬 Please provide guidance for the AI:#{COLOR_RESET}"
|
1155
|
+
comment = read_multiline_input
|
1156
|
+
return retry_llm_request_with_comment(block, comment)
|
1157
|
+
when 'm'
|
1158
|
+
return handle_manual_choice(block)
|
1159
|
+
when 's'
|
1160
|
+
puts "#{COLOR_YELLOW}⏭️ Skipping this conflict#{COLOR_RESET}"
|
1161
|
+
return { 'merged_code' => '', 'reason' => 'Skipped due to invalid LLM response' }
|
1162
|
+
when 'a'
|
1163
|
+
puts "#{COLOR_RED}🛑 Aborting merge resolution#{COLOR_RESET}"
|
1164
|
+
return { 'merged_code' => '', 'reason' => 'Aborted due to invalid LLM response', 'abort' => true }
|
1165
|
+
when '', nil
|
1166
|
+
puts "#{COLOR_RED}Please enter a valid choice: r/c/m/s/a#{COLOR_RESET}"
|
1167
|
+
else
|
1168
|
+
puts "#{COLOR_RED}Invalid option. Please enter: r (retry), c (comment), m (manual), s (skip), or a (abort)#{COLOR_RESET}"
|
1169
|
+
end
|
1170
|
+
end
|
1171
|
+
end
|
1172
|
+
|
1173
|
+
def retry_llm_request(block)
|
1174
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
1175
|
+
# Always re-read file content in case it was edited
|
1176
|
+
full_file_content = File.read(@file_path)
|
1177
|
+
|
1178
|
+
puts "#{COLOR_YELLOW}🤖 Retrying AI analysis...#{COLOR_RESET}"
|
1179
|
+
suggestion = request_merge_with_spinner(block, config, nil, full_file_content)
|
1180
|
+
puts "#{COLOR_GREEN}✅ Retry successful!#{COLOR_RESET}"
|
1181
|
+
suggestion
|
1182
|
+
rescue => e
|
1183
|
+
puts "#{COLOR_RED}❌ Retry failed: #{e.message}#{COLOR_RESET}"
|
1184
|
+
{ 'merged_code' => '', 'reason' => 'Retry failed due to persistent LLM error' }
|
1185
|
+
end
|
1186
|
+
|
1187
|
+
def retry_llm_request_with_comment(block, comment)
|
1188
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
1189
|
+
# Always re-read file content in case it was edited
|
1190
|
+
full_file_content = File.read(@file_path)
|
1191
|
+
|
1192
|
+
puts "#{COLOR_YELLOW}🤖 Retrying with your guidance...#{COLOR_RESET}"
|
1193
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
1194
|
+
puts "#{COLOR_GREEN}✅ Retry with comment successful!#{COLOR_RESET}"
|
1195
|
+
suggestion
|
1196
|
+
rescue => e
|
1197
|
+
puts "#{COLOR_RED}❌ Retry with comment failed: #{e.message}#{COLOR_RESET}"
|
1198
|
+
{ 'merged_code' => '', 'reason' => 'Retry with comment failed due to persistent LLM error' }
|
1199
|
+
end
|
1200
|
+
|
1201
|
+
def handle_manual_choice(block)
|
1202
|
+
puts "\n#{COLOR_BLUE}Manual conflict resolution:#{COLOR_RESET}"
|
1203
|
+
puts "#{COLOR_RED}[1] Choose HEAD version (#{block.base_label})#{COLOR_RESET}"
|
1204
|
+
puts "#{COLOR_RED}#{block.base_content}#{COLOR_RESET}"
|
1205
|
+
puts ""
|
1206
|
+
puts "#{COLOR_GREEN}[2] Choose incoming version (#{block.incoming_label})#{COLOR_RESET}"
|
1207
|
+
puts "#{COLOR_GREEN}#{block.incoming_content}#{COLOR_RESET}"
|
1208
|
+
puts ""
|
1209
|
+
puts "#{COLOR_YELLOW}[3] Skip this conflict#{COLOR_RESET}"
|
1210
|
+
|
1211
|
+
loop do
|
1212
|
+
print "#{COLOR_YELLOW}Choose version [1/2/3]: #{COLOR_RESET}"
|
1213
|
+
choice = $stdin.gets&.strip
|
1214
|
+
|
1215
|
+
case choice
|
1216
|
+
when '1'
|
1217
|
+
puts "#{COLOR_GREEN}✅ Selected HEAD version#{COLOR_RESET}"
|
1218
|
+
return {
|
1219
|
+
'merged_code' => block.base_content,
|
1220
|
+
'reason' => "Manually selected HEAD version (#{block.base_label}) due to LLM error"
|
1221
|
+
}
|
1222
|
+
when '2'
|
1223
|
+
puts "#{COLOR_GREEN}✅ Selected incoming version#{COLOR_RESET}"
|
1224
|
+
return {
|
1225
|
+
'merged_code' => block.incoming_content,
|
1226
|
+
'reason' => "Manually selected incoming version (#{block.incoming_label}) due to LLM error"
|
1227
|
+
}
|
1228
|
+
when '3'
|
1229
|
+
puts "#{COLOR_YELLOW}⏭️ Skipping conflict#{COLOR_RESET}"
|
1230
|
+
return { 'merged_code' => '', 'reason' => 'Manually skipped due to LLM error' }
|
1231
|
+
when '', nil
|
1232
|
+
puts "#{COLOR_RED}Please enter 1, 2, or 3#{COLOR_RESET}"
|
1233
|
+
else
|
1234
|
+
puts "#{COLOR_RED}Invalid choice. Please enter 1 (HEAD), 2 (incoming), or 3 (skip)#{COLOR_RESET}"
|
1235
|
+
end
|
1236
|
+
end
|
1237
|
+
end
|
1238
|
+
|
1239
|
+
def read_multiline_input
|
1240
|
+
lines = []
|
1241
|
+
puts "#{COLOR_GRAY}(Type your comment, then press Enter on an empty line to finish)#{COLOR_RESET}"
|
1242
|
+
while (line = $stdin.gets)
|
1243
|
+
line = line.chomp
|
1244
|
+
break if line.empty?
|
1245
|
+
lines << line
|
1246
|
+
end
|
1247
|
+
comment = lines.join("\n")
|
1248
|
+
if comment.empty?
|
1249
|
+
puts "#{COLOR_YELLOW}No comment entered.#{COLOR_RESET}"
|
1250
|
+
else
|
1251
|
+
puts "#{COLOR_GREEN}Comment received: #{comment.length} characters#{COLOR_RESET}"
|
1252
|
+
end
|
1253
|
+
comment
|
1254
|
+
end
|
1255
|
+
|
1256
|
+
def print_conflict(block)
|
1257
|
+
# Show context before conflict for better understanding
|
1258
|
+
if block.context_before && !block.context_before.empty?
|
1259
|
+
puts "#{COLOR_GRAY}... context before ...#{COLOR_RESET}"
|
1260
|
+
context_lines = block.context_before.split("\n").last(3) # Show last 3 lines of context
|
1261
|
+
context_lines.each { |line| puts "#{COLOR_GRAY}#{line}#{COLOR_RESET}" }
|
1262
|
+
end
|
1263
|
+
|
1264
|
+
puts "#{COLOR_RED}<<<<<<< #{block.base_label} (lines #{block.start_line}-#{block.end_line})#{COLOR_RESET}"
|
1265
|
+
puts "#{COLOR_RED}#{block.base_content}#{COLOR_RESET}"
|
1266
|
+
puts "#{COLOR_YELLOW}=======#{COLOR_RESET}"
|
1267
|
+
puts "#{COLOR_GREEN}#{block.incoming_content}#{COLOR_RESET}"
|
1268
|
+
puts "#{COLOR_YELLOW}>>>>>>> #{block.incoming_label}#{COLOR_RESET}"
|
1269
|
+
|
1270
|
+
# Show context after conflict for better understanding
|
1271
|
+
if block.context_after && !block.context_after.empty?
|
1272
|
+
context_lines = block.context_after.split("\n").first(3) # Show first 3 lines of context
|
1273
|
+
context_lines.each { |line| puts "#{COLOR_GRAY}#{line}#{COLOR_RESET}" }
|
1274
|
+
puts "#{COLOR_GRAY}... context after ...#{COLOR_RESET}"
|
1275
|
+
end
|
1276
|
+
end
|
1277
|
+
|
1278
|
+
def print_suggestion(sug)
|
1279
|
+
puts "#{COLOR_BLUE}--- Suggestion ---#{COLOR_RESET}"
|
1280
|
+
puts "#{COLOR_BLUE}#{sug['merged_code']}#{COLOR_RESET}"
|
1281
|
+
puts "#{COLOR_GRAY}Reason: #{sug['reason']}#{COLOR_RESET}"
|
1282
|
+
end
|
1283
|
+
|
1284
|
+
def resolve_template_path(template_key, config)
|
1285
|
+
user_path = config.dig('templates', template_key) if config.is_a?(Hash)
|
1286
|
+
return user_path if user_path && File.exist?(user_path)
|
1287
|
+
|
1288
|
+
File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
|
1289
|
+
end
|
1290
|
+
|
1291
|
+
def mark_file_as_resolved(file_path)
|
1292
|
+
# Detect VCS and mark file as resolved
|
1293
|
+
if File.exist?('.hg')
|
1294
|
+
result = execute_vcs_command_with_timeout("hg resolve --mark #{Shellwords.escape(file_path)}", 10)
|
1295
|
+
if result[:success]
|
1296
|
+
puts "#{COLOR_GREEN}✅ Marked #{file_path} as resolved in Mercurial#{COLOR_RESET}"
|
1297
|
+
else
|
1298
|
+
puts "#{COLOR_YELLOW}⚠️ Could not mark #{file_path} as resolved in Mercurial#{COLOR_RESET}"
|
1299
|
+
puts "#{COLOR_GRAY}Error: #{result[:error]}#{COLOR_RESET}" if result[:error]
|
1300
|
+
puts "#{COLOR_GRAY}💡 You can manually mark it with: hg resolve --mark #{file_path}#{COLOR_RESET}"
|
1301
|
+
end
|
1302
|
+
elsif File.exist?('.git')
|
1303
|
+
result = execute_vcs_command_with_timeout("git add #{Shellwords.escape(file_path)}", 10)
|
1304
|
+
if result[:success]
|
1305
|
+
puts "#{COLOR_GREEN}✅ Added #{file_path} to Git staging area#{COLOR_RESET}"
|
1306
|
+
else
|
1307
|
+
puts "#{COLOR_YELLOW}⚠️ Could not add #{file_path} to Git staging area#{COLOR_RESET}"
|
1308
|
+
puts "#{COLOR_GRAY}Error: #{result[:error]}#{COLOR_RESET}" if result[:error]
|
1309
|
+
puts "#{COLOR_GRAY}💡 You can manually add it with: git add #{file_path}#{COLOR_RESET}"
|
1310
|
+
end
|
1311
|
+
else
|
1312
|
+
puts "#{COLOR_BLUE}ℹ️ No VCS detected - file saved but not marked as resolved#{COLOR_RESET}"
|
1313
|
+
end
|
1314
|
+
end
|
1315
|
+
|
1316
|
+
def execute_vcs_command_with_timeout(command, timeout_seconds)
|
1317
|
+
require 'open3'
|
1318
|
+
|
1319
|
+
begin
|
1320
|
+
# Use Open3.popen3 with manual timeout handling to avoid thread issues
|
1321
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3(command)
|
1322
|
+
stdin.close
|
1323
|
+
|
1324
|
+
# Wait for the process with timeout
|
1325
|
+
if wait_thr.join(timeout_seconds)
|
1326
|
+
# Process completed within timeout
|
1327
|
+
stdout_content = stdout.read
|
1328
|
+
stderr_content = stderr.read
|
1329
|
+
status = wait_thr.value
|
1330
|
+
|
1331
|
+
stdout.close
|
1332
|
+
stderr.close
|
1333
|
+
|
1334
|
+
if status.success?
|
1335
|
+
{ success: true, stdout: stdout_content, stderr: stderr_content }
|
1336
|
+
else
|
1337
|
+
{ success: false, error: "Command failed: #{stderr_content.strip}", stdout: stdout_content, stderr: stderr_content }
|
1338
|
+
end
|
1339
|
+
else
|
1340
|
+
# Process timed out, kill it
|
1341
|
+
Process.kill('TERM', wait_thr.pid) rescue nil
|
1342
|
+
sleep(0.1)
|
1343
|
+
Process.kill('KILL', wait_thr.pid) rescue nil
|
1344
|
+
|
1345
|
+
stdout.close rescue nil
|
1346
|
+
stderr.close rescue nil
|
1347
|
+
wait_thr.join rescue nil
|
1348
|
+
|
1349
|
+
{ success: false, error: "Command timed out after #{timeout_seconds} seconds" }
|
1350
|
+
end
|
1351
|
+
rescue => e
|
1352
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
1353
|
+
end
|
1354
|
+
end
|
1355
|
+
|
1356
|
+
def show_help_and_status
|
1357
|
+
# This method will be invoked by OptionParser for -h/--help,
|
1358
|
+
# or when n2b-diff is run without arguments in non-analyze mode.
|
1359
|
+
# The OptionParser instance itself will print most of the help text.
|
1360
|
+
# We just add any extra status info here.
|
1361
|
+
|
1362
|
+
puts "" # Extra newline for spacing after OptionParser's output if it called this.
|
1363
|
+
# If running not due to -h, but due to missing file_path in merge mode:
|
1364
|
+
if !@options[:analyze] && @file_path.nil? && !@args.include?('-h') && !@args.include?('--help')
|
1365
|
+
puts "#{COLOR_RED}Error: No file path provided for merge conflict resolution."
|
1366
|
+
puts "Run with -h or --help for detailed usage."
|
1367
|
+
puts ""
|
1368
|
+
end
|
1369
|
+
|
1370
|
+
|
1371
|
+
# Show unresolved conflicts if in a VCS repository and not in analyze mode
|
1372
|
+
# (or if specifically requested, but for now, tied to non-analyze mode)
|
1373
|
+
if !@options[:analyze] && (File.exist?('.hg') || File.exist?('.git'))
|
1374
|
+
puts "#{COLOR_BLUE}📋 Unresolved Conflicts Status:#{COLOR_RESET}"
|
1375
|
+
if File.exist?('.hg')
|
1376
|
+
puts "#{COLOR_GRAY}Checking Mercurial...#{COLOR_RESET}"
|
1377
|
+
result = execute_vcs_command_with_timeout("hg resolve --list", 5)
|
1378
|
+
if result[:success]
|
1379
|
+
unresolved_files = result[:stdout].lines.select { |line| line.start_with?('U ') }
|
1380
|
+
if unresolved_files.any?
|
1381
|
+
unresolved_files.each { |line| puts " #{COLOR_RED}❌ #{line.strip.sub(/^U /, '')} (Mercurial)#{COLOR_RESET}" }
|
1382
|
+
puts "\n#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve listed conflicts.#{COLOR_RESET}"
|
1383
|
+
else
|
1384
|
+
puts " #{COLOR_GREEN}✅ No unresolved Mercurial conflicts.#{COLOR_RESET}"
|
1385
|
+
end
|
1386
|
+
else
|
1387
|
+
puts " #{COLOR_YELLOW}⚠️ Could not check Mercurial status: #{result[:error]}#{COLOR_RESET}"
|
1388
|
+
end
|
1389
|
+
end
|
1390
|
+
|
1391
|
+
if File.exist?('.git')
|
1392
|
+
puts "#{COLOR_GRAY}Checking Git...#{COLOR_RESET}"
|
1393
|
+
# For Git, `git status --porcelain` is better as `git diff --name-only --diff-filter=U` only shows unmerged paths.
|
1394
|
+
# We want to show files with conflict markers.
|
1395
|
+
# `git status --porcelain=v1` shows "UU" for unmerged files.
|
1396
|
+
result = execute_vcs_command_with_timeout("git status --porcelain=v1", 5)
|
1397
|
+
if result[:success]
|
1398
|
+
unresolved_files = result[:stdout].lines.select{|line| line.start_with?('UU ')}.map{|line| line.sub(/^UU /, '').strip}
|
1399
|
+
if unresolved_files.any?
|
1400
|
+
unresolved_files.each { |file| puts " #{COLOR_RED}❌ #{file} (Git)#{COLOR_RESET}" }
|
1401
|
+
puts "\n#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve listed conflicts.#{COLOR_RESET}"
|
1402
|
+
else
|
1403
|
+
puts " #{COLOR_GREEN}✅ No unresolved Git conflicts.#{COLOR_RESET}"
|
1404
|
+
end
|
1405
|
+
else
|
1406
|
+
puts " #{COLOR_YELLOW}⚠️ Could not check Git status: #{result[:error]}#{COLOR_RESET}"
|
1407
|
+
end
|
1408
|
+
end
|
1409
|
+
elsif @options[:analyze]
|
1410
|
+
# This part might not be reached if -h is used, as OptionParser exits.
|
1411
|
+
# But if called for other reasons in analyze mode:
|
1412
|
+
puts "#{COLOR_BLUE}ℹ️ Running in analysis mode. VCS conflict status check is skipped.#{COLOR_RESET}"
|
1413
|
+
end
|
1414
|
+
# Ensure exit if we got here due to an operational error (like no file in merge mode)
|
1415
|
+
# but not if it was just -h (OptionParser handles exit for -h)
|
1416
|
+
if !@options[:analyze] && @file_path.nil? && !@args.include?('-h') && !@args.include?('--help')
|
1417
|
+
exit 1
|
1418
|
+
end
|
1419
|
+
end
|
1420
|
+
|
1421
|
+
def attempt_json_repair(malformed_response, config)
|
1422
|
+
repair_prompt = build_json_repair_prompt(malformed_response)
|
1423
|
+
|
1424
|
+
begin
|
1425
|
+
puts "#{COLOR_BLUE}🔧 Asking AI to fix the JSON...#{COLOR_RESET}"
|
1426
|
+
repaired_json_str = call_llm_for_merge(repair_prompt, config)
|
1427
|
+
|
1428
|
+
# Try to parse the repaired response
|
1429
|
+
parsed = JSON.parse(extract_json(repaired_json_str))
|
1430
|
+
|
1431
|
+
# Validate the repaired response structure
|
1432
|
+
if parsed.is_a?(Hash) && parsed.key?('merged_code') && parsed.key?('reason')
|
1433
|
+
return parsed
|
1434
|
+
else
|
1435
|
+
puts "#{COLOR_YELLOW}⚠️ Repaired JSON missing required keys#{COLOR_RESET}"
|
1436
|
+
return nil
|
1437
|
+
end
|
1438
|
+
rescue JSON::ParserError => e
|
1439
|
+
puts "#{COLOR_YELLOW}⚠️ JSON repair attempt also returned invalid JSON#{COLOR_RESET}"
|
1440
|
+
return nil
|
1441
|
+
rescue => e
|
1442
|
+
puts "#{COLOR_YELLOW}⚠️ JSON repair failed: #{e.message}#{COLOR_RESET}"
|
1443
|
+
return nil
|
1444
|
+
end
|
1445
|
+
end
|
1446
|
+
|
1447
|
+
def build_json_repair_prompt(malformed_response)
|
1448
|
+
<<~PROMPT
|
1449
|
+
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:
|
1450
|
+
|
1451
|
+
Original response:
|
1452
|
+
#{malformed_response}
|
1453
|
+
|
1454
|
+
Requirements:
|
1455
|
+
- Must be valid JSON
|
1456
|
+
- Must have "merged_code" key with the code content
|
1457
|
+
- Must have "reason" key with explanation
|
1458
|
+
- Return ONLY the JSON, no other text
|
1459
|
+
|
1460
|
+
Fixed JSON:
|
1461
|
+
PROMPT
|
1462
|
+
end
|
1463
|
+
|
1464
|
+
def handle_editor_workflow(block, config, full_file_content)
|
1465
|
+
editor_command = config.dig('editor', 'command')
|
1466
|
+
editor_type = config.dig('editor', 'type')
|
1467
|
+
editor_configured = config.dig('editor', 'configured')
|
1468
|
+
|
1469
|
+
lines = File.readlines(@file_path, chomp: true)
|
1470
|
+
current_block_content_with_markers = lines[(block.start_line - 1)...block.end_line].join("\n")
|
1471
|
+
|
1472
|
+
if editor_configured && editor_command && editor_type == 'diff_tool'
|
1473
|
+
require 'tmpdir'
|
1474
|
+
Dir.mktmpdir("n2b_diff_") do |tmpdir|
|
1475
|
+
base_file_path = File.join(tmpdir, "base_#{File.basename(@file_path)}")
|
1476
|
+
remote_file_path = File.join(tmpdir, "remote_#{File.basename(@file_path)}")
|
1477
|
+
merged_file_path = File.join(tmpdir, "merged_#{File.basename(@file_path)}")
|
1478
|
+
|
1479
|
+
File.write(base_file_path, block.base_content)
|
1480
|
+
File.write(remote_file_path, block.incoming_content)
|
1481
|
+
|
1482
|
+
# Initial content for the merged file: LLM suggestion or current block with markers
|
1483
|
+
initial_merged_content = block.suggestion&.dig('merged_code')
|
1484
|
+
if initial_merged_content.nil? || initial_merged_content.strip.empty?
|
1485
|
+
initial_merged_content = current_block_content_with_markers
|
1486
|
+
end
|
1487
|
+
File.write(merged_file_path, initial_merged_content)
|
1488
|
+
|
1489
|
+
# Common pattern: tool base merged remote. Some tools might vary.
|
1490
|
+
# Example: meld uses local remote base --output output_file
|
1491
|
+
# For now, using a common sequence. Needs documentation for custom tools.
|
1492
|
+
# We assume the tool edits the `merged_file_path` (second argument) in place or uses it as output.
|
1493
|
+
full_diff_command = "#{editor_command} #{Shellwords.escape(base_file_path)} #{Shellwords.escape(merged_file_path)} #{Shellwords.escape(remote_file_path)}"
|
1494
|
+
puts "#{COLOR_BLUE}🔧 Launching diff tool: #{editor_command}...#{COLOR_RESET}"
|
1495
|
+
puts "#{COLOR_GRAY} Base: #{base_file_path}#{COLOR_RESET}"
|
1496
|
+
puts "#{COLOR_GRAY} Remote: #{remote_file_path}#{COLOR_RESET}"
|
1497
|
+
puts "#{COLOR_GRAY} Merged: #{merged_file_path} (edit this one)#{COLOR_RESET}"
|
1498
|
+
|
1499
|
+
system(full_diff_command)
|
1500
|
+
|
1501
|
+
puts "#{COLOR_BLUE}📁 Diff tool closed.#{COLOR_RESET}"
|
1502
|
+
merged_code_from_editor = File.read(merged_file_path)
|
1503
|
+
|
1504
|
+
# Check if the merged content is different from the initial content with markers
|
1505
|
+
# to avoid considering unchanged initial conflict markers as a resolution.
|
1506
|
+
if merged_code_from_editor.strip == current_block_content_with_markers.strip && merged_code_from_editor.include?('<<<<<<<')
|
1507
|
+
puts "#{COLOR_YELLOW}⚠️ It seems the conflict markers are still present. Did you resolve the conflict?#{COLOR_RESET}"
|
1508
|
+
end
|
1509
|
+
|
1510
|
+
print "#{COLOR_YELLOW}Did you resolve this conflict using the diff tool? [y/n]: #{COLOR_RESET}"
|
1511
|
+
response = $stdin.gets&.strip&.downcase
|
1512
|
+
if response == 'y'
|
1513
|
+
puts "#{COLOR_GREEN}✅ Conflict marked as resolved by user via diff tool#{COLOR_RESET}"
|
1514
|
+
return {
|
1515
|
+
resolved: true,
|
1516
|
+
merged_code: merged_code_from_editor,
|
1517
|
+
reason: "User resolved conflict with diff tool: #{editor_command}"
|
1518
|
+
}
|
1519
|
+
else
|
1520
|
+
puts "#{COLOR_BLUE}🔄 Conflict not marked as resolved. Continuing with AI assistance...#{COLOR_RESET}"
|
1521
|
+
# If user says 'n', we don't use the content from the diff tool as a resolution.
|
1522
|
+
# We might need to re-fetch LLM suggestion or just go back to menu.
|
1523
|
+
# For now, return resolved: false. The updated_content is not from the main file.
|
1524
|
+
return { resolved: false, updated_content: full_file_content } # original full_file_content
|
1525
|
+
end
|
1526
|
+
end # Tempdir is automatically removed
|
1527
|
+
else
|
1528
|
+
# Fallback to text editor or if editor is 'text_editor'
|
1529
|
+
editor_to_use = editor_command || detect_system_editor # Use configured or system editor
|
1530
|
+
|
1531
|
+
original_file_content_for_block_check = File.read(@file_path) # Before text editor opens it
|
1532
|
+
|
1533
|
+
puts "#{COLOR_BLUE}🔧 Opening #{@file_path} in editor (#{editor_to_use})...#{COLOR_RESET}"
|
1534
|
+
open_file_in_editor(@file_path, editor_to_use) # Pass specific editor
|
1535
|
+
puts "#{COLOR_BLUE}📁 Editor closed. Checking for changes...#{COLOR_RESET}"
|
1536
|
+
|
1537
|
+
current_file_content_after_edit = File.read(@file_path)
|
1538
|
+
|
1539
|
+
if file_changed?(original_file_content_for_block_check, current_file_content_after_edit)
|
1540
|
+
puts "#{COLOR_YELLOW}📝 File has been modified.#{COLOR_RESET}"
|
1541
|
+
print "#{COLOR_YELLOW}Did you resolve this conflict yourself in the editor? [y/n]: #{COLOR_RESET}"
|
1542
|
+
response = $stdin.gets&.strip&.downcase
|
1543
|
+
|
1544
|
+
if response == 'y'
|
1545
|
+
puts "#{COLOR_GREEN}✅ Conflict marked as resolved by user in text editor#{COLOR_RESET}"
|
1546
|
+
# Extract the changed block content
|
1547
|
+
# Re-read lines as they might have changed in number
|
1548
|
+
edited_lines = File.readlines(@file_path, chomp: true)
|
1549
|
+
# Heuristic: if lines were added/removed, block boundaries might shift.
|
1550
|
+
# For simplicity, we'll use original block's line numbers to extract,
|
1551
|
+
# but this might be inaccurate if user adds/removes many lines *outside* the conflict block.
|
1552
|
+
# A more robust way would be to re-parse or use markers if they exist.
|
1553
|
+
# For now, assume user edits primarily *within* the original start/end lines.
|
1554
|
+
# The number of lines in the resolved code could be different.
|
1555
|
+
# We need to ask the user to ensure the markers are gone.
|
1556
|
+
|
1557
|
+
# Let's get the content of the lines that corresponded to the original block.
|
1558
|
+
# This isn't perfect if the user adds/deletes lines *within* the block,
|
1559
|
+
# changing its length. The LLM's suggestion is for a block of a certain size.
|
1560
|
+
# For user resolution, they define the new block.
|
1561
|
+
# We need to get the content from start_line to (potentially new) end_line.
|
1562
|
+
# This is tricky. The simplest is to take the whole file, but that's not what merge tools do.
|
1563
|
+
# The contract is that the user removed the conflict markers.
|
1564
|
+
|
1565
|
+
# We will return the content of the file from the original start line
|
1566
|
+
# to an end line that reflects the number of lines in the manually merged code.
|
1567
|
+
# This is still tricky. Let's assume the user edited the block and the surrounding lines are stable.
|
1568
|
+
# The `resolve_block` method replaces `lines[(block.start_line-1)...block.end_line]`
|
1569
|
+
# So, the returned `merged_code` should be what replaces that segment.
|
1570
|
+
|
1571
|
+
# Simplest approach: user confirms resolution, we assume the relevant part of the file is the resolution.
|
1572
|
+
# We need to extract the content of the resolved block from current_file_content_after_edit
|
1573
|
+
# based on block.start_line and the *new* end_line of the resolved conflict.
|
1574
|
+
# This is hard without re-parsing.
|
1575
|
+
# A practical approach: The user resolved it. The file is now correct *at those lines*.
|
1576
|
+
# The `resolve_block` method will write the *entire* `lines` array back to the file.
|
1577
|
+
# If the user resolved it, the `lines` array (after their edit) IS the resolution for that part.
|
1578
|
+
# So, we need to give `resolve_block` the lines from the file that correspond to the original block markers.
|
1579
|
+
# This means the `merged_code` should be the content of the file from `block.start_line`
|
1580
|
+
# up to where the `block.end_line` *would* be after their edits.
|
1581
|
+
|
1582
|
+
# Let's refine: the user has edited the file. The section of the file
|
1583
|
+
# that previously contained the conflict markers (block.start_line to block.end_line)
|
1584
|
+
# now contains their resolution. We need to extract this segment.
|
1585
|
+
# The number of lines might have changed.
|
1586
|
+
# The `resolve_block` function will replace `lines[original_start_idx..original_end_idx]` with the new content.
|
1587
|
+
# So we must provide the exact lines that should go into that slice.
|
1588
|
+
|
1589
|
+
# We need to ask the user to confirm the new end line if it changed, or trust they know.
|
1590
|
+
# The simplest is to return the segment from the file from original start_line to original end_line,
|
1591
|
+
# assuming the user's changes fit there. This is too naive.
|
1592
|
+
|
1593
|
+
# If the user says 'y', the file is considered resolved in that region.
|
1594
|
+
# The `resolve_block` will then write the `lines` array (which is `current_file_content_after_edit.split("\n")`)
|
1595
|
+
# back to the file. The key is that `resolve_block` *already has* the full `lines` from the modified file
|
1596
|
+
# when it reconstructs the file if result[:accepted] is true.
|
1597
|
+
# So, the `merged_code` we return here is more for logging/consistency.
|
1598
|
+
# The critical part is that `lines` in `resolve_block` needs to be updated if the file was changed by the editor.
|
1599
|
+
|
1600
|
+
# The `resolve_block` method reads `lines = File.readlines(@file_path, chomp: true)` at the beginning.
|
1601
|
+
# If we edit the file here, `lines` in `resolve_block` becomes stale.
|
1602
|
+
# This means `handle_editor_workflow` must return the *new* full file content if it changed.
|
1603
|
+
# And the `merged_code` for the log should be the segment of the new file.
|
1604
|
+
|
1605
|
+
# Let's re-read the file and extract the relevant segment for the log.
|
1606
|
+
# The actual application of changes happens because `resolve_block` will use the modified `lines` array.
|
1607
|
+
# We need to estimate the new end_line. This is complex.
|
1608
|
+
# For now, let's just say "user resolved". The actual diff applied will be based on the whole file change.
|
1609
|
+
# The `merged_code` for logging can be a placeholder or the new content of the block.
|
1610
|
+
# Let's assume the user ensures the markers are gone.
|
1611
|
+
# The content of lines from block.start_line to block.end_line in the *new file* is their resolution.
|
1612
|
+
# The number of lines in this resolution can be different from the original block.
|
1613
|
+
# This is fine, as the `replacement` in `resolve_block` handles this.
|
1614
|
+
|
1615
|
+
# Simplification: if user says 'y', the code that will be used is the content
|
1616
|
+
# of the file from block.start_line to some new end_line.
|
1617
|
+
# The crucial part is that `resolve_block` needs to operate on the *modified* file content.
|
1618
|
+
# So, we should pass `current_file_content_after_edit` back up.
|
1619
|
+
# And for logging, extract the lines from `block.start_line` to `block.end_line` from this new content.
|
1620
|
+
# This assumes the user's resolution fits within the original line numbers, which is not always true.
|
1621
|
+
|
1622
|
+
# The most robust is to re-parse the file for conflict markers. If none are found in this region, it's resolved.
|
1623
|
+
# The "merged_code" would be the lines from the edited file that replaced the original conflict.
|
1624
|
+
|
1625
|
+
# Let's assume the user has resolved the conflict markers from `block.start_line` to `block.end_line`.
|
1626
|
+
# The content of these lines in `current_file_content_after_edit` is the resolution.
|
1627
|
+
# The number of lines of this resolution might be different.
|
1628
|
+
# The `resolve_block` needs to replace `lines[(block.start_line-1)...block.end_line]`
|
1629
|
+
# The `merged_code` should be this new segment.
|
1630
|
+
|
1631
|
+
# For now, let `merged_code` be a conceptual value. The `resolve_block` loop needs to use the new file content.
|
1632
|
+
# The key is `updated_content` for the main loop, and `merged_code` for logging.
|
1633
|
+
|
1634
|
+
# The `resolve_block` method needs to use the content of the file *after* the edit.
|
1635
|
+
# The current structure of `resolve_block` re-reads `lines` only if `request_merge_with_spinner` is called again.
|
1636
|
+
# This needs adjustment.
|
1637
|
+
|
1638
|
+
# For now, if user says 'y':
|
1639
|
+
# 1. The `merged_code` will be what's in the file from `block.start_line` to `block.end_line` (original numbering).
|
1640
|
+
# This is imperfect for logging if lines were added/removed.
|
1641
|
+
# 2. The `resolve_block` loop must use `current_file_content_after_edit` for its `lines` variable.
|
1642
|
+
# This is the most important part for correctness.
|
1643
|
+
|
1644
|
+
# Let's return the segment from the modified file for `merged_code`.
|
1645
|
+
# This is still tricky because `block.end_line` is from the original parse.
|
1646
|
+
# If user deleted lines, `block.end_line` might be out of bounds for `edited_lines`.
|
1647
|
+
# If user added lines, we wouldn't capture all of it.
|
1648
|
+
|
1649
|
+
# Simplest for now: the user resolved it. The merged_code for logging can be a placeholder.
|
1650
|
+
# The main thing is that `resolve_block` now operates on `current_file_content_after_edit`.
|
1651
|
+
# The subtask asks for `merged_code` to be `<content_from_editor_or_file>`.
|
1652
|
+
# This means the content of the resolved block.
|
1653
|
+
|
1654
|
+
# Let's try to extract the content from the edited file using original line numbers as a guide.
|
1655
|
+
# This is a known limitation. A better way would be for user to indicate new block end.
|
1656
|
+
resolved_segment = edited_lines[(block.start_line - 1)..[block.end_line - 1]].join("\n") rescue "User resolved - content not easily extracted due to line changes"
|
1657
|
+
if edited_lines.slice((block.start_line-1)...(block.end_line)).join("\n").include?("<<<<<<<")
|
1658
|
+
puts "#{COLOR_YELLOW}⚠️ Conflict markers seem to still be present in the edited file. Please ensure they are removed for proper resolution.#{COLOR_RESET}"
|
1659
|
+
end
|
1660
|
+
|
1661
|
+
|
1662
|
+
return {
|
1663
|
+
resolved: true,
|
1664
|
+
merged_code: resolved_segment, # Content from the file for the resolved block
|
1665
|
+
reason: "User resolved conflict in text editor: #{editor_to_use}",
|
1666
|
+
updated_content: current_file_content_after_edit # Pass back the full content
|
1667
|
+
}
|
1668
|
+
else
|
1669
|
+
puts "#{COLOR_BLUE}🔄 Conflict not marked as resolved. Continuing with AI assistance...#{COLOR_RESET}"
|
1670
|
+
return {
|
1671
|
+
resolved: false,
|
1672
|
+
updated_content: current_file_content_after_edit # Pass back the full content
|
1673
|
+
}
|
1674
|
+
end
|
1675
|
+
else
|
1676
|
+
puts "#{COLOR_GRAY}📋 No changes detected in the file. Continuing...#{COLOR_RESET}"
|
1677
|
+
return { resolved: false, updated_content: nil } # No changes, so original full_file_content is still valid
|
1678
|
+
end
|
1679
|
+
end
|
1680
|
+
end
|
1681
|
+
|
1682
|
+
def detect_system_editor
|
1683
|
+
# This is the ultimate fallback if no configuration is set.
|
1684
|
+
# The ENV['EDITOR'] || ENV['VISUAL'] check should be done by the caller if preferred before this.
|
1685
|
+
case RbConfig::CONFIG['host_os']
|
1686
|
+
when /darwin|mac os/
|
1687
|
+
'open' # Typically non-blocking, might need different handling or user awareness.
|
1688
|
+
when /linux/
|
1689
|
+
# Prefer common user-friendly editors if available, then vi as fallback.
|
1690
|
+
# This simple version just picks one. `command_exists?` could be used here.
|
1691
|
+
ENV['EDITOR'] || ENV['VISUAL'] || 'nano' # or 'vi'
|
1692
|
+
when /mswin|mingw/
|
1693
|
+
ENV['EDITOR'] || ENV['VISUAL'] || 'notepad'
|
1694
|
+
else
|
1695
|
+
ENV['EDITOR'] || ENV['VISUAL'] || 'vi' # vi is a common default on Unix-like systems
|
1696
|
+
end
|
1697
|
+
end
|
1698
|
+
|
1699
|
+
def open_file_in_editor(file_path, editor_command = nil)
|
1700
|
+
# If no specific editor_command is passed, try configured editor, then system fallbacks.
|
1701
|
+
# This method is now simplified as the decision of *which* editor (configured vs fallback)
|
1702
|
+
# is made in handle_editor_workflow. This method just executes it.
|
1703
|
+
# However, the original call from resolve_block (before this change) did not pass editor_command.
|
1704
|
+
# So, if editor_command is nil, we should still try to get it from config or fallback.
|
1705
|
+
|
1706
|
+
effective_editor = editor_command # Use passed command if available
|
1707
|
+
|
1708
|
+
if effective_editor.nil?
|
1709
|
+
config = get_config(reconfigure: false, advanced_flow: false) # Ensure config is loaded
|
1710
|
+
effective_editor = config.dig('editor', 'command') if config.dig('editor', 'configured')
|
1711
|
+
effective_editor ||= detect_system_editor # Ultimate fallback
|
1712
|
+
end
|
1713
|
+
|
1714
|
+
puts "#{COLOR_GRAY}Attempting to open with: #{effective_editor} #{Shellwords.escape(file_path)}#{COLOR_RESET}"
|
1715
|
+
begin
|
1716
|
+
if RbConfig::CONFIG['host_os'] =~ /darwin|mac os/ && effective_editor == 'open'
|
1717
|
+
# 'open' is non-blocking. This is fine.
|
1718
|
+
result = system("open #{Shellwords.escape(file_path)}")
|
1719
|
+
unless result
|
1720
|
+
# `system` returns true if command found and exited with 0, false otherwise for `open`.
|
1721
|
+
# It returns nil if command execution fails.
|
1722
|
+
puts "#{COLOR_YELLOW}⚠️ 'open' command might have failed or file opened in background. Please check manually.#{COLOR_RESET}"
|
1723
|
+
end
|
1724
|
+
# For 'open', we don't wait. User needs to manually come back.
|
1725
|
+
# Consider adding a prompt "Press Enter after closing the editor..." for non-blocking editors.
|
1726
|
+
# For now, keeping it simple.
|
1727
|
+
else
|
1728
|
+
# For most terminal editors, system() will block until the editor is closed.
|
1729
|
+
system("#{effective_editor} #{Shellwords.escape(file_path)}")
|
1730
|
+
end
|
1731
|
+
rescue StandardError => e
|
1732
|
+
puts "#{COLOR_RED}❌ Failed to open editor '#{effective_editor}': #{e.message}#{COLOR_RESET}"
|
1733
|
+
puts "#{COLOR_YELLOW}💡 Please ensure your configured editor is correct or set your EDITOR environment variable.#{COLOR_RESET}"
|
1734
|
+
puts "#{COLOR_BLUE}You may need to open #{file_path} manually in your preferred editor to make changes.#{COLOR_RESET}"
|
1735
|
+
print "#{COLOR_YELLOW}Press Enter to continue after manually editing (if you choose to do so)...#{COLOR_RESET}"
|
1736
|
+
$stdin.gets # Pause to allow manual editing
|
1737
|
+
end
|
1738
|
+
end
|
1739
|
+
|
1740
|
+
def file_changed?(original_content, current_content)
|
1741
|
+
original_content != current_content
|
1742
|
+
end
|
1743
|
+
|
1744
|
+
def save_debug_response(raw_response, error)
|
1745
|
+
begin
|
1746
|
+
debug_dir = '.n2b_debug'
|
1747
|
+
FileUtils.mkdir_p(debug_dir)
|
1748
|
+
timestamp = Time.now.strftime('%Y-%m-%d-%H%M%S')
|
1749
|
+
debug_file = File.join(debug_dir, "invalid_response_#{timestamp}.txt")
|
1750
|
+
|
1751
|
+
debug_content = <<~DEBUG
|
1752
|
+
N2B Debug: Invalid LLM Response
|
1753
|
+
===============================
|
1754
|
+
Timestamp: #{Time.now}
|
1755
|
+
File: #{@file_path}
|
1756
|
+
Error: #{error.message}
|
1757
|
+
Error Class: #{error.class}
|
1758
|
+
|
1759
|
+
Raw LLM Response:
|
1760
|
+
-----------------
|
1761
|
+
#{raw_response}
|
1762
|
+
|
1763
|
+
End of Response
|
1764
|
+
===============
|
1765
|
+
DEBUG
|
1766
|
+
|
1767
|
+
File.write(debug_file, debug_content)
|
1768
|
+
puts "#{COLOR_GRAY}🐛 Debug info saved to #{debug_file}#{COLOR_RESET}"
|
1769
|
+
rescue => e
|
1770
|
+
puts "#{COLOR_GRAY}⚠️ Could not save debug info: #{e.message}#{COLOR_RESET}"
|
1771
|
+
end
|
1772
|
+
end
|
1773
|
+
|
1774
|
+
def generate_merge_log_html(log_entries, timestamp)
|
1775
|
+
git_info = extract_git_info
|
1776
|
+
|
1777
|
+
html = <<~HTML
|
1778
|
+
<!DOCTYPE html>
|
1779
|
+
<html lang="en">
|
1780
|
+
<head>
|
1781
|
+
<meta charset="UTF-8">
|
1782
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
1783
|
+
<title>N2B Merge Log - #{@file_path} - #{timestamp}</title>
|
1784
|
+
<style>
|
1785
|
+
body {
|
1786
|
+
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
1787
|
+
margin: 0;
|
1788
|
+
padding: 20px;
|
1789
|
+
background-color: #f8f9fa;
|
1790
|
+
color: #333;
|
1791
|
+
line-height: 1.6;
|
1792
|
+
}
|
1793
|
+
.header {
|
1794
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
1795
|
+
color: white;
|
1796
|
+
padding: 20px;
|
1797
|
+
border-radius: 8px;
|
1798
|
+
margin-bottom: 20px;
|
1799
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
1800
|
+
}
|
1801
|
+
.header h1 {
|
1802
|
+
margin: 0 0 10px 0;
|
1803
|
+
font-size: 24px;
|
1804
|
+
}
|
1805
|
+
.header .meta {
|
1806
|
+
opacity: 0.9;
|
1807
|
+
font-size: 14px;
|
1808
|
+
}
|
1809
|
+
.conflict-container {
|
1810
|
+
background: white;
|
1811
|
+
border-radius: 8px;
|
1812
|
+
margin-bottom: 20px;
|
1813
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
1814
|
+
overflow: hidden;
|
1815
|
+
}
|
1816
|
+
.conflict-header {
|
1817
|
+
background-color: #e9ecef;
|
1818
|
+
padding: 15px;
|
1819
|
+
border-bottom: 1px solid #dee2e6;
|
1820
|
+
font-weight: bold;
|
1821
|
+
color: #495057;
|
1822
|
+
}
|
1823
|
+
.conflict-table {
|
1824
|
+
width: 100%;
|
1825
|
+
border-collapse: collapse;
|
1826
|
+
}
|
1827
|
+
.conflict-table th {
|
1828
|
+
background-color: #f8f9fa;
|
1829
|
+
padding: 12px;
|
1830
|
+
text-align: left;
|
1831
|
+
font-weight: 600;
|
1832
|
+
border-bottom: 2px solid #dee2e6;
|
1833
|
+
color: #495057;
|
1834
|
+
}
|
1835
|
+
.conflict-table td {
|
1836
|
+
padding: 12px;
|
1837
|
+
border-bottom: 1px solid #e9ecef;
|
1838
|
+
vertical-align: top;
|
1839
|
+
}
|
1840
|
+
.code-block {
|
1841
|
+
background-color: #f8f9fa;
|
1842
|
+
border: 1px solid #e9ecef;
|
1843
|
+
border-radius: 4px;
|
1844
|
+
padding: 10px;
|
1845
|
+
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
1846
|
+
font-size: 13px;
|
1847
|
+
white-space: pre-wrap;
|
1848
|
+
overflow-x: auto;
|
1849
|
+
max-height: 300px;
|
1850
|
+
overflow-y: auto;
|
1851
|
+
}
|
1852
|
+
.base-code { border-left: 4px solid #dc3545; }
|
1853
|
+
.incoming-code { border-left: 4px solid #007bff; }
|
1854
|
+
.resolution-code { border-left: 4px solid #28a745; }
|
1855
|
+
.method-badge {
|
1856
|
+
display: inline-block;
|
1857
|
+
padding: 4px 8px;
|
1858
|
+
border-radius: 12px;
|
1859
|
+
font-size: 12px;
|
1860
|
+
font-weight: 500;
|
1861
|
+
text-transform: uppercase;
|
1862
|
+
}
|
1863
|
+
.method-llm { background-color: #e3f2fd; color: #1976d2; }
|
1864
|
+
.method-manual { background-color: #fff3e0; color: #f57c00; }
|
1865
|
+
.method-skip { background-color: #fce4ec; color: #c2185b; }
|
1866
|
+
.method-abort { background-color: #ffebee; color: #d32f2f; }
|
1867
|
+
.footer {
|
1868
|
+
text-align: center;
|
1869
|
+
margin-top: 30px;
|
1870
|
+
padding: 20px;
|
1871
|
+
color: #6c757d;
|
1872
|
+
font-size: 14px;
|
1873
|
+
}
|
1874
|
+
.stats {
|
1875
|
+
display: flex;
|
1876
|
+
gap: 20px;
|
1877
|
+
margin-top: 10px;
|
1878
|
+
}
|
1879
|
+
.stat-item {
|
1880
|
+
background: rgba(255, 255, 255, 0.2);
|
1881
|
+
padding: 8px 12px;
|
1882
|
+
border-radius: 4px;
|
1883
|
+
}
|
1884
|
+
</style>
|
1885
|
+
</head>
|
1886
|
+
<body>
|
1887
|
+
<div class="header">
|
1888
|
+
<h1>🔀 N2B Merge Resolution Log</h1>
|
1889
|
+
<div class="meta">
|
1890
|
+
<strong>File:</strong> #{@file_path}<br>
|
1891
|
+
<strong>Timestamp:</strong> #{Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')}<br>
|
1892
|
+
<strong>Branch:</strong> #{git_info[:branch]}<br>
|
1893
|
+
<strong>Total Conflicts:</strong> #{log_entries.length}
|
1894
|
+
</div>
|
1895
|
+
<div class="stats">
|
1896
|
+
<div class="stat-item">
|
1897
|
+
<strong>Resolved:</strong> #{log_entries.count { |e| e[:action] == 'accepted' }}
|
1898
|
+
</div>
|
1899
|
+
<div class="stat-item">
|
1900
|
+
<strong>Skipped:</strong> #{log_entries.count { |e| e[:action] == 'skipped' }}
|
1901
|
+
</div>
|
1902
|
+
<div class="stat-item">
|
1903
|
+
<strong>Aborted:</strong> #{log_entries.count { |e| e[:action] == 'aborted' }}
|
1904
|
+
</div>
|
1905
|
+
</div>
|
1906
|
+
</div>
|
1907
|
+
HTML
|
1908
|
+
|
1909
|
+
log_entries.each_with_index do |entry, index|
|
1910
|
+
conflict_number = index + 1
|
1911
|
+
method_class = case entry[:resolution_method]
|
1912
|
+
when /llm|ai|suggested/i then 'method-llm'
|
1913
|
+
when /manual|user|edit/i then 'method-manual'
|
1914
|
+
when /skip/i then 'method-skip'
|
1915
|
+
when /abort/i then 'method-abort'
|
1916
|
+
else 'method-llm'
|
1917
|
+
end
|
1918
|
+
|
1919
|
+
html += <<~CONFLICT_HTML
|
1920
|
+
<div class="conflict-container">
|
1921
|
+
<div class="conflict-header">
|
1922
|
+
Conflict ##{conflict_number} - Lines #{entry[:start_line]}-#{entry[:end_line]}
|
1923
|
+
<span class="method-badge #{method_class}">#{entry[:resolution_method]}</span>
|
1924
|
+
</div>
|
1925
|
+
<table class="conflict-table">
|
1926
|
+
<thead>
|
1927
|
+
<tr>
|
1928
|
+
<th style="width: 25%">Base Branch Code</th>
|
1929
|
+
<th style="width: 25%">Incoming Branch Code</th>
|
1930
|
+
<th style="width: 25%">Final Resolution</th>
|
1931
|
+
<th style="width: 25%">Resolution Details</th>
|
1932
|
+
</tr>
|
1933
|
+
</thead>
|
1934
|
+
<tbody>
|
1935
|
+
<tr>
|
1936
|
+
<td>
|
1937
|
+
<div class="code-block base-code">#{escape_html(entry[:base_content] || 'N/A')}</div>
|
1938
|
+
</td>
|
1939
|
+
<td>
|
1940
|
+
<div class="code-block incoming-code">#{escape_html(entry[:incoming_content] || 'N/A')}</div>
|
1941
|
+
</td>
|
1942
|
+
<td>
|
1943
|
+
<div class="code-block resolution-code">#{escape_html(entry[:resolved_content] || 'N/A')}</div>
|
1944
|
+
</td>
|
1945
|
+
<td>
|
1946
|
+
<div class="code-block">
|
1947
|
+
<strong>Method:</strong> #{escape_html(entry[:resolution_method])}<br>
|
1948
|
+
<strong>Action:</strong> #{escape_html(entry[:action])}<br>
|
1949
|
+
<strong>Time:</strong> #{entry[:timestamp]}<br><br>
|
1950
|
+
#{entry[:llm_suggestion] ? "<strong>LLM Analysis:</strong><br>#{escape_html(entry[:llm_suggestion])}" : ''}
|
1951
|
+
</div>
|
1952
|
+
</td>
|
1953
|
+
</tr>
|
1954
|
+
</tbody>
|
1955
|
+
</table>
|
1956
|
+
</div>
|
1957
|
+
CONFLICT_HTML
|
1958
|
+
end
|
1959
|
+
|
1960
|
+
html += <<~FOOTER_HTML
|
1961
|
+
<div class="footer">
|
1962
|
+
Generated by N2B v#{N2B::VERSION} - AI-Powered Merge Conflict Resolution<br>
|
1963
|
+
<small>This log contains the complete history of merge conflict resolutions for audit and review purposes.</small>
|
1964
|
+
</div>
|
1965
|
+
</body>
|
1966
|
+
</html>
|
1967
|
+
FOOTER_HTML
|
1968
|
+
|
1969
|
+
html
|
1970
|
+
end
|
1971
|
+
|
1972
|
+
def escape_html(text)
|
1973
|
+
return '' if text.nil?
|
1974
|
+
text.to_s
|
1975
|
+
.gsub('&', '&')
|
1976
|
+
.gsub('<', '<')
|
1977
|
+
.gsub('>', '>')
|
1978
|
+
.gsub('"', '"')
|
1979
|
+
.gsub("'", ''')
|
1980
|
+
end
|
1981
|
+
|
1982
|
+
def extract_git_info
|
1983
|
+
branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip rescue 'unknown'
|
1984
|
+
{
|
1985
|
+
branch: branch.empty? ? 'unknown' : branch
|
1986
|
+
}
|
1987
|
+
end
|
1988
|
+
|
1989
|
+
def determine_resolution_method(result)
|
1990
|
+
return 'Aborted' if result[:abort]
|
1991
|
+
return 'Manual Edit' if result[:reason]&.include?('manually resolved')
|
1992
|
+
return 'Manual Choice' if result[:reason]&.include?('Manually selected')
|
1993
|
+
return 'Skipped' if !result[:accepted] && !result[:abort]
|
1994
|
+
return 'LLM Suggestion' if result[:accepted] && result[:reason]
|
1995
|
+
'Unknown'
|
1996
|
+
end
|
1997
|
+
|
1998
|
+
def determine_action(result)
|
1999
|
+
return 'aborted' if result[:abort]
|
2000
|
+
return 'accepted' if result[:accepted]
|
2001
|
+
return 'skipped'
|
2002
|
+
end
|
2003
|
+
|
2004
|
+
private # Ensure all subsequent methods are private unless specified
|
2005
|
+
|
2006
|
+
def get_vcs_type_for_file_operations
|
2007
|
+
# Leverages existing get_vcs_type but more for file content retrieval context
|
2008
|
+
get_vcs_type
|
2009
|
+
end
|
2010
|
+
|
2011
|
+
def convert_label_to_revision(label, vcs_type, side)
|
2012
|
+
# Convert conflict marker labels to actual VCS revision identifiers
|
2013
|
+
case vcs_type
|
2014
|
+
when :git
|
2015
|
+
case label.downcase
|
2016
|
+
when /head|working.*copy|current/
|
2017
|
+
'HEAD'
|
2018
|
+
when /merge.*rev|incoming|branch/
|
2019
|
+
'MERGE_HEAD'
|
2020
|
+
else
|
2021
|
+
# If it's already a valid Git revision, use it as-is
|
2022
|
+
label
|
2023
|
+
end
|
2024
|
+
when :hg
|
2025
|
+
case label.downcase
|
2026
|
+
when /working.*copy|current/
|
2027
|
+
'.' # Current working directory parent
|
2028
|
+
when /merge.*rev|incoming|branch/
|
2029
|
+
'p2()' # Second parent (incoming branch)
|
2030
|
+
else
|
2031
|
+
# If it's already a valid Mercurial revision, use it as-is
|
2032
|
+
label
|
2033
|
+
end
|
2034
|
+
else
|
2035
|
+
label
|
2036
|
+
end
|
2037
|
+
end
|
2038
|
+
|
2039
|
+
def get_file_content_from_vcs(label, file_path, vcs_type)
|
2040
|
+
# Note: file_path is the path in the working directory.
|
2041
|
+
# VCS commands often need path relative to repo root if not run from root.
|
2042
|
+
# Assuming execution from repo root or that file_path is appropriately relative.
|
2043
|
+
# Pathname can help make it relative if needed:
|
2044
|
+
# relative_file_path = Pathname.new(File.absolute_path(file_path)).relative_path_from(Pathname.new(Dir.pwd)).to_s
|
2045
|
+
|
2046
|
+
# A simpler approach for `git show` is that it usually works with paths from repo root.
|
2047
|
+
# If @file_path is already relative to repo root or absolute, it might just work.
|
2048
|
+
# For robustness, ensuring it's relative to repo root is better.
|
2049
|
+
# However, current `execute_vcs_diff` uses `Dir.pwd`, implying commands are run from current dir.
|
2050
|
+
# Let's assume file_path as given is suitable for now.
|
2051
|
+
|
2052
|
+
command = case vcs_type
|
2053
|
+
when :git
|
2054
|
+
# For git show <commit-ish>:<path>, path is usually from repo root.
|
2055
|
+
# If @file_path is not from repo root, this might need adjustment.
|
2056
|
+
# Let's assume @file_path is correctly specified for this context.
|
2057
|
+
"git show #{Shellwords.escape(label)}:#{Shellwords.escape(file_path)}"
|
2058
|
+
when :hg
|
2059
|
+
"hg cat -r #{Shellwords.escape(label)} #{Shellwords.escape(file_path)}"
|
2060
|
+
else
|
2061
|
+
nil
|
2062
|
+
end
|
2063
|
+
|
2064
|
+
return nil unless command
|
2065
|
+
|
2066
|
+
begin
|
2067
|
+
# Timeout might be needed for very large files or slow VCS.
|
2068
|
+
content = `#{command}` # Using backticks captures stdout
|
2069
|
+
# Check $? for command success.
|
2070
|
+
# `git show` returns 0 on success, non-zero otherwise (e.g. 128 if path not found).
|
2071
|
+
# `hg cat` also returns 0 on success.
|
2072
|
+
return content if $?.success?
|
2073
|
+
|
2074
|
+
# If command failed, log a warning but don't necessarily halt everything.
|
2075
|
+
# The preview will just show empty for that panel.
|
2076
|
+
puts "#{COLOR_YELLOW}Warning: VCS command '#{command}' failed or returned no content. Exit status: #{$?.exitstatus}.#{COLOR_RESET}"
|
2077
|
+
nil
|
2078
|
+
rescue StandardError => e
|
2079
|
+
puts "#{COLOR_YELLOW}Warning: Could not fetch content for '#{file_path}' from VCS label '#{label}': #{e.message}#{COLOR_RESET}"
|
2080
|
+
nil
|
2081
|
+
end
|
2082
|
+
end
|
2083
|
+
|
2084
|
+
def apply_hunk_to_full_content(original_full_content_with_markers, conflict_block_details, resolved_hunk_text)
|
2085
|
+
return original_full_content_with_markers if resolved_hunk_text.nil?
|
2086
|
+
|
2087
|
+
lines = original_full_content_with_markers.lines.map(&:chomp)
|
2088
|
+
# Convert 1-based line numbers from block_details to 0-based array indices
|
2089
|
+
start_idx = conflict_block_details.start_line - 1
|
2090
|
+
end_idx = conflict_block_details.end_line - 1
|
2091
|
+
|
2092
|
+
# Basic validation of indices
|
2093
|
+
if start_idx < 0 || start_idx >= lines.length || end_idx < start_idx || end_idx >= lines.length
|
2094
|
+
# This case should ideally not happen if block_details are correct
|
2095
|
+
# Return original content or handle error appropriately
|
2096
|
+
# For preview, it's safer to show the original content with markers if hunk application is problematic
|
2097
|
+
return original_full_content_with_markers
|
2098
|
+
end
|
2099
|
+
|
2100
|
+
hunk_lines = resolved_hunk_text.lines.map(&:chomp)
|
2101
|
+
|
2102
|
+
new_content_lines = []
|
2103
|
+
new_content_lines.concat(lines[0...start_idx]) if start_idx > 0 # Lines before the conflict block
|
2104
|
+
new_content_lines.concat(hunk_lines) # The resolved hunk
|
2105
|
+
new_content_lines.concat(lines[(end_idx + 1)..-1]) if (end_idx + 1) < lines.length # Lines after the conflict block
|
2106
|
+
|
2107
|
+
new_content_lines.join("\n")
|
2108
|
+
end
|
2109
|
+
|
2110
|
+
# --- HTML Preview Generation ---
|
2111
|
+
|
2112
|
+
# private (already established)
|
2113
|
+
|
2114
|
+
def get_language_class(file_path)
|
2115
|
+
ext = File.extname(file_path).downcase
|
2116
|
+
case ext
|
2117
|
+
when '.rb' then 'ruby'
|
2118
|
+
when '.js' then 'javascript'
|
2119
|
+
when '.py' then 'python'
|
2120
|
+
when '.java' then 'java'
|
2121
|
+
when '.c', '.h', '.cpp', '.hpp' then 'cpp'
|
2122
|
+
when '.cs' then 'csharp'
|
2123
|
+
when '.go' then 'go'
|
2124
|
+
when '.php' then 'php'
|
2125
|
+
when '.ts' then 'typescript'
|
2126
|
+
when '.swift' then 'swift'
|
2127
|
+
when '.kt', '.kts' then 'kotlin'
|
2128
|
+
when '.rs' then 'rust'
|
2129
|
+
when '.scala' then 'scala'
|
2130
|
+
when '.pl' then 'perl'
|
2131
|
+
when '.pm' then 'perl'
|
2132
|
+
when '.sh' then 'bash'
|
2133
|
+
when '.html', '.htm', '.xhtml', '.xml' then 'xml' # Highlight.js uses 'xml' for HTML
|
2134
|
+
when '.css' then 'css'
|
2135
|
+
when '.json' then 'json'
|
2136
|
+
when '.yml', '.yaml' then 'yaml'
|
2137
|
+
when '.md', '.markdown' then 'markdown'
|
2138
|
+
else
|
2139
|
+
'' # Let Highlight.js auto-detect or default to plain text
|
2140
|
+
end
|
2141
|
+
end
|
2142
|
+
|
2143
|
+
def find_sub_content_lines(full_content, sub_content)
|
2144
|
+
return nil if sub_content.nil? || sub_content.empty?
|
2145
|
+
full_lines = full_content.lines.map(&:chomp)
|
2146
|
+
sub_lines = sub_content.lines.map(&:chomp)
|
2147
|
+
|
2148
|
+
return nil if sub_lines.empty?
|
2149
|
+
|
2150
|
+
full_lines.each_with_index do |_, index|
|
2151
|
+
match = true
|
2152
|
+
sub_lines.each_with_index do |sub_line_content, sub_index|
|
2153
|
+
unless full_lines[index + sub_index] == sub_line_content
|
2154
|
+
match = false
|
2155
|
+
break
|
2156
|
+
end
|
2157
|
+
end
|
2158
|
+
return { start: index + 1, end: index + sub_lines.length } if match # 1-based line numbers
|
2159
|
+
end
|
2160
|
+
nil
|
2161
|
+
end
|
2162
|
+
|
2163
|
+
def generate_conflict_preview_html(block_details, base_content_full, incoming_content_full, current_resolution_content_full, base_branch_name, incoming_branch_name, file_path, llm_suggestion = nil)
|
2164
|
+
require 'cgi' # For CGI.escapeHTML
|
2165
|
+
require 'fileutils' # For FileUtils.mkdir_p
|
2166
|
+
require 'shellwords' # For Shellwords.escape, already used elsewhere but good to have contextually
|
2167
|
+
require 'rbconfig' # For RbConfig::CONFIG
|
2168
|
+
|
2169
|
+
lang_class = get_language_class(file_path)
|
2170
|
+
|
2171
|
+
# Find line numbers for highlighting
|
2172
|
+
# These are line numbers within their respective full_content strings (1-based)
|
2173
|
+
base_highlight_lines = find_sub_content_lines(base_content_full, block_details.base_content)
|
2174
|
+
incoming_highlight_lines = find_sub_content_lines(incoming_content_full, block_details.incoming_content)
|
2175
|
+
|
2176
|
+
# For resolution, highlight the conflict area within the full resolution content
|
2177
|
+
# The current_resolution_content_full already contains the resolved content
|
2178
|
+
# We'll highlight the same line range as the original conflict
|
2179
|
+
resolution_highlight_lines = { start: block_details.start_line, end: block_details.end_line }
|
2180
|
+
|
2181
|
+
html_content = StringIO.new
|
2182
|
+
html_content << "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
|
2183
|
+
html_content << " <meta charset=\"UTF-8\">\n"
|
2184
|
+
html_content << " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
|
2185
|
+
html_content << " <title>Conflict Preview: #{CGI.escapeHTML(File.basename(file_path))}</title>\n"
|
2186
|
+
html_content << " <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css\">\n"
|
2187
|
+
html_content << " <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n"
|
2188
|
+
# Optional: specific languages if needed, e.g.,
|
2189
|
+
# html_content << " <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/#{lang_class}.min.js\"></script>\n" if lang_class && !lang_class.empty?
|
2190
|
+
|
2191
|
+
html_content << " <style>\n"
|
2192
|
+
html_content << " body { font-family: sans-serif; margin: 0; display: flex; flex-direction: column; height: 100vh; }\n"
|
2193
|
+
html_content << " .header { padding: 10px; background-color: #f0f0f0; border-bottom: 1px solid #ccc; text-align: center; }\n"
|
2194
|
+
html_content << " .header h2 { margin: 0; }\n"
|
2195
|
+
html_content << " .llm-message { padding: 10px; background-color: #e8f4fd; border-bottom: 1px solid #ccc; margin: 0; }\n"
|
2196
|
+
html_content << " .llm-message h3 { margin: 0 0 5px 0; color: #1976d2; font-size: 0.9em; }\n"
|
2197
|
+
html_content << " .llm-message p { margin: 0; color: #424242; font-size: 0.85em; line-height: 1.3; }\n"
|
2198
|
+
html_content << " .columns-container { display: flex; flex: 1; overflow: hidden; }\n"
|
2199
|
+
html_content << " .column { flex: 1; padding: 0; border-left: 1px solid #ccc; overflow-y: auto; display: flex; flex-direction: column; }\n"
|
2200
|
+
html_content << " .column:first-child { border-left: none; }\n"
|
2201
|
+
html_content << " .column h3 { background-color: #e0e0e0; padding: 8px 10px; margin: 0; border-bottom: 1px solid #ccc; text-align: center; font-size: 1em; }\n"
|
2202
|
+
html_content << " .code-container { flex: 1; overflow-y: auto; position: relative; }\n"
|
2203
|
+
html_content << " pre { margin: 0; padding: 0; height: 100%; }\n"
|
2204
|
+
html_content << " code { display: block; padding: 10px 10px 10px 60px; font-family: 'SF Mono', Monaco, Inconsolata, 'Fira Code', monospace; font-size: 0.85em; line-height: 1.4em; }\n"
|
2205
|
+
html_content << " .line { display: block; position: relative; }\n"
|
2206
|
+
html_content << " .line-number { position: absolute; left: 0; width: 50px; padding-right: 10px; text-align: right; color: #999; user-select: none; font-size: 0.8em; background-color: #f8f8f8; border-right: 1px solid #e0e0e0; }\n"
|
2207
|
+
html_content << " .conflict-lines-base { background-color: #ffebee; border-left: 3px solid #f44336; }\n"
|
2208
|
+
html_content << " .conflict-lines-incoming { background-color: #e3f2fd; border-left: 3px solid #2196f3; }\n"
|
2209
|
+
html_content << " .conflict-lines-resolution { background-color: #e8f5e9; border-left: 3px solid #4caf50; }\n"
|
2210
|
+
html_content << " @media (max-width: 768px) { .columns-container { flex-direction: column; } .column { border-left: none; border-top: 1px solid #ccc;} }\n"
|
2211
|
+
html_content << " </style>\n</head>\n<body>\n"
|
2212
|
+
|
2213
|
+
html_content << " <div class=\"header\"><h2>Conflict Preview: #{CGI.escapeHTML(file_path)}</h2></div>\n"
|
2214
|
+
|
2215
|
+
# Add LLM message section if available
|
2216
|
+
if llm_suggestion && llm_suggestion['reason']
|
2217
|
+
html_content << " <div class=\"llm-message\">\n"
|
2218
|
+
html_content << " <h3>🤖 AI Analysis & Suggestion</h3>\n"
|
2219
|
+
html_content << " <p>#{CGI.escapeHTML(llm_suggestion['reason'])}</p>\n"
|
2220
|
+
html_content << " </div>\n"
|
2221
|
+
end
|
2222
|
+
|
2223
|
+
html_content << " <div class=\"columns-container\">\n"
|
2224
|
+
|
2225
|
+
# Helper to generate HTML for one column
|
2226
|
+
generate_column_html = lambda do |title, full_code, highlight_info, highlight_class_suffix|
|
2227
|
+
html_content << " <div class=\"column\">\n"
|
2228
|
+
html_content << " <h3>#{CGI.escapeHTML(title)}</h3>\n"
|
2229
|
+
html_content << " <div class=\"code-container\">\n" # Wrapper for scrolling
|
2230
|
+
html_content << " <pre><code class=\"#{lang_class}\">\n"
|
2231
|
+
|
2232
|
+
full_code.lines.each_with_index do |line_text, index|
|
2233
|
+
line_number = index + 1
|
2234
|
+
line_class = "line"
|
2235
|
+
if highlight_info && line_number >= highlight_info[:start] && line_number <= highlight_info[:end]
|
2236
|
+
line_class += " conflict-lines conflict-lines-#{highlight_class_suffix}"
|
2237
|
+
end
|
2238
|
+
html_content << "<span class=\"#{line_class}\"><span class=\"line-number\">#{line_number}</span>#{CGI.escapeHTML(line_text.chomp)}</span>\n"
|
2239
|
+
end
|
2240
|
+
|
2241
|
+
html_content << " </code></pre>\n"
|
2242
|
+
html_content << " </div>\n" # end .code-container
|
2243
|
+
html_content << " </div>\n" # end .column
|
2244
|
+
end
|
2245
|
+
|
2246
|
+
generate_column_html.call("Base (#{base_branch_name})", base_content_full, base_highlight_lines, "base")
|
2247
|
+
generate_column_html.call("Incoming (#{incoming_branch_name})", incoming_content_full, incoming_highlight_lines, "incoming")
|
2248
|
+
generate_column_html.call("Current Resolution", current_resolution_content_full, resolution_highlight_lines, "resolution")
|
2249
|
+
|
2250
|
+
html_content << " </div>\n" # end .columns-container
|
2251
|
+
html_content << " <script>hljs.highlightAll();</script>\n"
|
2252
|
+
html_content << "</body>\n</html>"
|
2253
|
+
|
2254
|
+
# Save to file with unique but persistent naming
|
2255
|
+
log_dir = '.n2b_merge_log'
|
2256
|
+
FileUtils.mkdir_p(log_dir)
|
2257
|
+
|
2258
|
+
# Create a more stable filename based on file path and conflict location
|
2259
|
+
file_basename = File.basename(file_path, '.*')
|
2260
|
+
conflict_id = "#{block_details.start_line}_#{block_details.end_line}"
|
2261
|
+
preview_filename = "conflict_#{file_basename}_lines_#{conflict_id}.html"
|
2262
|
+
full_preview_path = File.join(log_dir, preview_filename)
|
2263
|
+
|
2264
|
+
File.write(full_preview_path, html_content.string)
|
2265
|
+
|
2266
|
+
return File.absolute_path(full_preview_path)
|
2267
|
+
end
|
2268
|
+
|
2269
|
+
def open_html_in_browser(html_file_path)
|
2270
|
+
absolute_path = File.absolute_path(html_file_path)
|
2271
|
+
# Ensure correct file URL format for different OSes, especially Windows
|
2272
|
+
file_url = if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
|
2273
|
+
"file:///#{absolute_path.gsub("\\", "/")}" # Windows needs forward slashes
|
2274
|
+
else
|
2275
|
+
"file://#{absolute_path}"
|
2276
|
+
end
|
2277
|
+
|
2278
|
+
command = nil
|
2279
|
+
os = RbConfig::CONFIG['host_os']
|
2280
|
+
|
2281
|
+
case os
|
2282
|
+
when /darwin|mac os/
|
2283
|
+
command = "open #{Shellwords.escape(file_url)}"
|
2284
|
+
when /linux/
|
2285
|
+
# Check for WSL environment, as xdg-open might not work as expected directly
|
2286
|
+
# or might open browser inside WSL, not on Windows host.
|
2287
|
+
# Powershell.exe can be used to open it on Windows host from WSL.
|
2288
|
+
if ENV['WSL_DISTRO_NAME'] || (ENV['IS_WSL'] == 'true') || File.exist?('/proc/sys/fs/binfmt_misc/WSLInterop')
|
2289
|
+
# Using powershell.exe to open the URL on the Windows host
|
2290
|
+
# Ensure the file_url is accessible from Windows (e.g. via /mnt/c/...)
|
2291
|
+
# This assumes the path is already a Windows-accessible path if running in WSL context
|
2292
|
+
# or that the user has set up their environment for this.
|
2293
|
+
# For file URLs, it's often better to translate to Windows path format.
|
2294
|
+
windows_path = absolute_path.gsub(%r{^/mnt/([a-z])}, '\1:') # Basic /mnt/c -> C:
|
2295
|
+
command = "powershell.exe -c \"Start-Process '#{windows_path}'\""
|
2296
|
+
puts "#{COLOR_YELLOW}Detected WSL, attempting to open in Windows browser: #{command}#{COLOR_RESET}"
|
2297
|
+
else
|
2298
|
+
command = "xdg-open #{Shellwords.escape(file_url)}"
|
2299
|
+
end
|
2300
|
+
when /mswin|mingw|cygwin/ # Windows
|
2301
|
+
# `start` command with an empty title "" for paths with spaces
|
2302
|
+
command = "start \"\" \"#{file_url.gsub("file:///", "")}\"" # `start` takes path directly
|
2303
|
+
else
|
2304
|
+
puts "#{COLOR_YELLOW}Unsupported OS: #{os}. Cannot open browser automatically.#{COLOR_RESET}"
|
2305
|
+
return false
|
2306
|
+
end
|
2307
|
+
|
2308
|
+
if command
|
2309
|
+
puts "#{COLOR_BLUE}Attempting to open preview in browser: #{command}#{COLOR_RESET}"
|
2310
|
+
begin
|
2311
|
+
success = system(command)
|
2312
|
+
unless success
|
2313
|
+
# system() returns false if command executes with non-zero status, nil if command fails to execute
|
2314
|
+
puts "#{COLOR_RED}Failed to execute command to open browser. Exit status: #{$?.exitstatus if $?}.#{COLOR_RESET}"
|
2315
|
+
raise "Command execution failed" # Will be caught by rescue
|
2316
|
+
end
|
2317
|
+
puts "#{COLOR_GREEN}Preview should now be open in your browser.#{COLOR_RESET}"
|
2318
|
+
return true
|
2319
|
+
rescue => e
|
2320
|
+
puts "#{COLOR_RED}Failed to automatically open the HTML preview: #{e.message}#{COLOR_RESET}"
|
2321
|
+
end
|
2322
|
+
end
|
2323
|
+
|
2324
|
+
puts "#{COLOR_YELLOW}Please open it manually: #{file_url}#{COLOR_RESET}"
|
2325
|
+
false
|
2326
|
+
end
|
2327
|
+
|
2328
|
+
end
|
2329
|
+
end
|