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
data/lib/n2b/cli.rb
CHANGED
@@ -1,5 +1,3 @@
|
|
1
|
-
require_relative 'jira_client' # For N2B::JiraClient
|
2
|
-
|
3
1
|
module N2B
|
4
2
|
class CLI < Base
|
5
3
|
def self.run(args)
|
@@ -16,281 +14,27 @@ module N2B
|
|
16
14
|
config = get_config(reconfigure: @options[:config], advanced_flow: @options[:advanced_config])
|
17
15
|
user_input = @args.join(' ') # All remaining args form user input/prompt addition
|
18
16
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
17
|
+
# Diff functionality has been removed from N2B::CLI
|
18
|
+
# if @options[:diff]
|
19
|
+
# handle_diff_analysis(config)
|
20
|
+
# els
|
21
|
+
if user_input.empty? # No input text after options
|
22
|
+
# If config mode was chosen, it's handled by get_config.
|
23
|
+
# If not, and no input, prompt for it.
|
24
|
+
unless @options[:config] # Don't prompt if only -c or --advanced-config was used
|
25
|
+
puts "Enter your natural language command (or type 'exit' or 'quit'):"
|
26
|
+
input_text = $stdin.gets.chomp
|
27
|
+
exit if ['exit', 'quit'].include?(input_text.downcase)
|
28
|
+
process_natural_language_command(input_text, config)
|
29
|
+
end
|
30
|
+
else # Natural language command provided as argument
|
26
31
|
process_natural_language_command(user_input, config)
|
27
32
|
end
|
28
33
|
end
|
29
34
|
|
30
35
|
protected
|
31
36
|
|
32
|
-
|
33
|
-
vcs_type = get_vcs_type
|
34
|
-
if vcs_type == :none
|
35
|
-
puts "Error: Not a git or hg repository."
|
36
|
-
exit 1
|
37
|
-
end
|
38
|
-
|
39
|
-
# Get requirements file from parsed options
|
40
|
-
requirements_filepath = @options[:requirements]
|
41
|
-
user_prompt_addition = @args.join(' ') # All remaining args are user prompt addition
|
42
|
-
|
43
|
-
# Jira Ticket Information
|
44
|
-
jira_ticket = @options[:jira_ticket]
|
45
|
-
jira_update_flag = @options[:jira_update] # true, false, or nil
|
46
|
-
|
47
|
-
requirements_content = nil # Initialize requirements_content
|
48
|
-
|
49
|
-
if jira_ticket
|
50
|
-
puts "Jira ticket specified: #{jira_ticket}"
|
51
|
-
if config['jira'] && config['jira']['domain'] && config['jira']['email'] && config['jira']['api_key']
|
52
|
-
begin
|
53
|
-
jira_client = N2B::JiraClient.new(config) # Pass the whole config
|
54
|
-
puts "Fetching Jira ticket details..."
|
55
|
-
# If a requirements file is also provided, the Jira ticket will take precedence.
|
56
|
-
# Or, we could append/prepend. For now, Jira overwrites.
|
57
|
-
requirements_content = jira_client.fetch_ticket(jira_ticket)
|
58
|
-
puts "Successfully fetched Jira ticket details."
|
59
|
-
# The fetched content is now in requirements_content and will be passed to analyze_diff
|
60
|
-
rescue N2B::JiraClient::JiraApiError => e
|
61
|
-
puts "Error fetching Jira ticket: #{e.message}"
|
62
|
-
puts "Proceeding with diff analysis without Jira ticket details."
|
63
|
-
rescue ArgumentError => e # Catches missing Jira config in JiraClient.new
|
64
|
-
puts "Jira configuration error: #{e.message}"
|
65
|
-
puts "Please ensure Jira is configured correctly using 'n2b -c'."
|
66
|
-
puts "Proceeding with diff analysis without Jira ticket details."
|
67
|
-
rescue StandardError => e
|
68
|
-
puts "An unexpected error occurred while fetching Jira ticket: #{e.message}"
|
69
|
-
puts "Proceeding with diff analysis without Jira ticket details."
|
70
|
-
end
|
71
|
-
else
|
72
|
-
puts "Jira configuration is missing or incomplete in N2B settings."
|
73
|
-
puts "Please configure Jira using 'n2b -c' to fetch ticket details."
|
74
|
-
puts "Proceeding with diff analysis without Jira ticket details."
|
75
|
-
end
|
76
|
-
# Handling of jira_update_flag can be done elsewhere, e.g., after analysis
|
77
|
-
if jira_update_flag == true
|
78
|
-
puts "Note: Jira ticket update (--jira-update) is flagged."
|
79
|
-
# Actual update logic will be separate
|
80
|
-
elsif jira_update_flag == false
|
81
|
-
puts "Note: Jira ticket will not be updated (--jira-no-update)."
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
# Load requirements from file if no Jira ticket was fetched or if specifically desired even with Jira.
|
86
|
-
# Current logic: Jira fetch, if successful, populates requirements_content.
|
87
|
-
# If Jira not specified, or fetch failed, try to load from file.
|
88
|
-
if requirements_content.nil? && requirements_filepath
|
89
|
-
if File.exist?(requirements_filepath)
|
90
|
-
puts "Loading requirements from file: #{requirements_filepath}"
|
91
|
-
requirements_content = File.read(requirements_filepath)
|
92
|
-
else
|
93
|
-
puts "Error: Requirements file not found: #{requirements_filepath}"
|
94
|
-
# Decide if to exit or proceed. For now, proceed.
|
95
|
-
puts "Proceeding with diff analysis without file-based requirements."
|
96
|
-
end
|
97
|
-
elsif requirements_content && requirements_filepath
|
98
|
-
puts "Note: Both Jira ticket and requirements file were provided. Using Jira ticket content for analysis."
|
99
|
-
end
|
100
|
-
|
101
|
-
diff_output = execute_vcs_diff(vcs_type, @options[:branch])
|
102
|
-
analysis_result = analyze_diff(diff_output, config, user_prompt_addition, requirements_content) # Store the result
|
103
|
-
|
104
|
-
# --- Jira Update Logic ---
|
105
|
-
if jira_ticket && analysis_result && !analysis_result.empty?
|
106
|
-
# Check if Jira config is valid for updating
|
107
|
-
if config['jira'] && config['jira']['domain'] && config['jira']['email'] && config['jira']['api_key']
|
108
|
-
jira_comment_data = format_analysis_for_jira(analysis_result)
|
109
|
-
proceed_with_update = false
|
110
|
-
|
111
|
-
if jira_update_flag == true # --jira-update used
|
112
|
-
proceed_with_update = true
|
113
|
-
elsif jira_update_flag.nil? # Neither --jira-update nor --jira-no-update used
|
114
|
-
puts "\nWould you like to update Jira ticket #{jira_ticket} with this analysis? (y/n)"
|
115
|
-
user_choice = $stdin.gets.chomp.downcase
|
116
|
-
proceed_with_update = user_choice == 'y'
|
117
|
-
end # If jira_update_flag is false, proceed_with_update remains false
|
118
|
-
|
119
|
-
if proceed_with_update
|
120
|
-
begin
|
121
|
-
# Re-instantiate JiraClient or use an existing one if available and in scope
|
122
|
-
# For safety and simplicity here, re-instantiate with current config.
|
123
|
-
update_jira_client = N2B::JiraClient.new(config)
|
124
|
-
puts "Updating Jira ticket #{jira_ticket}..."
|
125
|
-
if update_jira_client.update_ticket(jira_ticket, jira_comment_data)
|
126
|
-
puts "Jira ticket #{jira_ticket} updated successfully."
|
127
|
-
else
|
128
|
-
# update_ticket currently returns true/false, but might raise error for http issues
|
129
|
-
puts "Failed to update Jira ticket #{jira_ticket}. The client did not report an error, but the update may not have completed."
|
130
|
-
end
|
131
|
-
rescue N2B::JiraClient::JiraApiError => e
|
132
|
-
puts "Error updating Jira ticket: #{e.message}"
|
133
|
-
rescue ArgumentError => e # From JiraClient.new if config is suddenly invalid
|
134
|
-
puts "Jira configuration error before update: #{e.message}"
|
135
|
-
rescue StandardError => e
|
136
|
-
puts "An unexpected error occurred while updating Jira ticket: #{e.message}"
|
137
|
-
end
|
138
|
-
else
|
139
|
-
puts "Jira ticket update skipped."
|
140
|
-
end
|
141
|
-
else
|
142
|
-
puts "Jira configuration is missing or incomplete. Cannot proceed with Jira update."
|
143
|
-
end
|
144
|
-
elsif jira_ticket && (analysis_result.nil? || analysis_result.empty?)
|
145
|
-
puts "Skipping Jira update as analysis result was empty or not generated."
|
146
|
-
end
|
147
|
-
# --- End of Jira Update Logic ---
|
148
|
-
|
149
|
-
analysis_result # Return analysis_result from handle_diff_analysis
|
150
|
-
end
|
151
|
-
|
152
|
-
def get_vcs_type
|
153
|
-
if Dir.exist?(File.join(Dir.pwd, '.git'))
|
154
|
-
:git
|
155
|
-
elsif Dir.exist?(File.join(Dir.pwd, '.hg'))
|
156
|
-
:hg
|
157
|
-
else
|
158
|
-
:none
|
159
|
-
end
|
160
|
-
end
|
161
|
-
|
162
|
-
def execute_vcs_diff(vcs_type, branch_option = nil)
|
163
|
-
case vcs_type
|
164
|
-
when :git
|
165
|
-
if branch_option
|
166
|
-
target_branch = branch_option == 'auto' ? detect_git_default_branch : branch_option
|
167
|
-
if target_branch
|
168
|
-
# Validate that the target branch exists
|
169
|
-
unless validate_git_branch_exists(target_branch)
|
170
|
-
puts "Error: Branch '#{target_branch}' does not exist."
|
171
|
-
puts "Available branches:"
|
172
|
-
puts `git branch -a`.lines.map(&:strip).reject(&:empty?)
|
173
|
-
exit 1
|
174
|
-
end
|
175
|
-
|
176
|
-
puts "Comparing current branch against '#{target_branch}'..."
|
177
|
-
`git diff #{target_branch}...HEAD`
|
178
|
-
else
|
179
|
-
puts "Could not detect default branch, falling back to HEAD diff..."
|
180
|
-
`git diff HEAD`
|
181
|
-
end
|
182
|
-
else
|
183
|
-
`git diff HEAD`
|
184
|
-
end
|
185
|
-
when :hg
|
186
|
-
if branch_option
|
187
|
-
target_branch = branch_option == 'auto' ? detect_hg_default_branch : branch_option
|
188
|
-
if target_branch
|
189
|
-
# Validate that the target branch exists
|
190
|
-
unless validate_hg_branch_exists(target_branch)
|
191
|
-
puts "Error: Branch '#{target_branch}' does not exist."
|
192
|
-
puts "Available branches:"
|
193
|
-
puts `hg branches`.lines.map(&:strip).reject(&:empty?)
|
194
|
-
exit 1
|
195
|
-
end
|
196
|
-
|
197
|
-
puts "Comparing current branch against '#{target_branch}'..."
|
198
|
-
`hg diff -r #{target_branch}`
|
199
|
-
else
|
200
|
-
puts "Could not detect default branch, falling back to standard diff..."
|
201
|
-
`hg diff`
|
202
|
-
end
|
203
|
-
else
|
204
|
-
`hg diff`
|
205
|
-
end
|
206
|
-
else
|
207
|
-
"" # Should not happen if get_vcs_type logic is correct and checked before calling
|
208
|
-
end
|
209
|
-
end
|
210
|
-
|
211
|
-
def detect_git_default_branch
|
212
|
-
# Try multiple methods to detect the default branch
|
213
|
-
|
214
|
-
# Method 1: Check origin/HEAD symbolic ref
|
215
|
-
result = `git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null`.strip
|
216
|
-
if $?.success? && !result.empty?
|
217
|
-
return result.split('/').last
|
218
|
-
end
|
219
|
-
|
220
|
-
# Method 2: Check remote show origin
|
221
|
-
result = `git remote show origin 2>/dev/null | grep "HEAD branch"`.strip
|
222
|
-
if $?.success? && !result.empty?
|
223
|
-
match = result.match(/HEAD branch:\s*(\w+)/)
|
224
|
-
return match[1] if match
|
225
|
-
end
|
226
|
-
|
227
|
-
# Method 3: Check if common default branches exist
|
228
|
-
['main', 'master'].each do |branch|
|
229
|
-
result = `git rev-parse --verify origin/#{branch} 2>/dev/null`
|
230
|
-
if $?.success?
|
231
|
-
return branch
|
232
|
-
end
|
233
|
-
end
|
234
|
-
|
235
|
-
# Method 4: Fallback - check local branches
|
236
|
-
['main', 'master'].each do |branch|
|
237
|
-
result = `git rev-parse --verify #{branch} 2>/dev/null`
|
238
|
-
if $?.success?
|
239
|
-
return branch
|
240
|
-
end
|
241
|
-
end
|
242
|
-
|
243
|
-
# If all else fails, return nil
|
244
|
-
nil
|
245
|
-
end
|
246
|
-
|
247
|
-
def detect_hg_default_branch
|
248
|
-
# Method 1: Check current branch (if it's 'default', that's the main branch)
|
249
|
-
result = `hg branch 2>/dev/null`.strip
|
250
|
-
if $?.success? && result == 'default'
|
251
|
-
return 'default'
|
252
|
-
end
|
253
|
-
|
254
|
-
# Method 2: Look for 'default' branch in branch list
|
255
|
-
result = `hg branches 2>/dev/null`
|
256
|
-
if $?.success? && result.include?('default')
|
257
|
-
return 'default'
|
258
|
-
end
|
259
|
-
|
260
|
-
# Method 3: Check if there are any branches at all
|
261
|
-
result = `hg branches 2>/dev/null`.strip
|
262
|
-
if $?.success? && !result.empty?
|
263
|
-
# Get the first branch (usually the main one)
|
264
|
-
first_branch = result.lines.first&.split&.first
|
265
|
-
return first_branch if first_branch
|
266
|
-
end
|
267
|
-
|
268
|
-
# Fallback to 'default' (standard hg main branch name)
|
269
|
-
'default'
|
270
|
-
end
|
271
|
-
|
272
|
-
def validate_git_branch_exists(branch)
|
273
|
-
# Check if branch exists locally
|
274
|
-
_result = `git rev-parse --verify #{branch} 2>/dev/null`
|
275
|
-
return true if $?.success?
|
276
|
-
|
277
|
-
# Check if branch exists on remote
|
278
|
-
_result = `git rev-parse --verify origin/#{branch} 2>/dev/null`
|
279
|
-
return true if $?.success?
|
280
|
-
|
281
|
-
false
|
282
|
-
end
|
283
|
-
|
284
|
-
def validate_hg_branch_exists(branch)
|
285
|
-
# Check if branch exists in hg branches
|
286
|
-
result = `hg branches 2>/dev/null`
|
287
|
-
if $?.success?
|
288
|
-
return result.lines.any? { |line| line.strip.start_with?(branch) }
|
289
|
-
end
|
290
|
-
|
291
|
-
# If we can't list branches, assume it exists (hg is more permissive)
|
292
|
-
true
|
293
|
-
end
|
37
|
+
# All diff-related methods like handle_diff_analysis, get_vcs_type, etc., are removed.
|
294
38
|
|
295
39
|
private
|
296
40
|
|
@@ -316,32 +60,9 @@ module N2B
|
|
316
60
|
end
|
317
61
|
end
|
318
62
|
|
319
|
-
def build_diff_analysis_prompt(diff_output, user_prompt_addition = "", requirements_content = nil)
|
320
|
-
|
321
|
-
|
322
|
-
Your task is to provide a constructive and detailed analysis of the changes.
|
323
|
-
Focus on identifying potential bugs, suggesting improvements in code quality, style, performance, and security.
|
324
|
-
Also, provide a concise summary of the changes.
|
325
|
-
|
326
|
-
IMPORTANT: When referring to specific issues or improvements, always include:
|
327
|
-
- The exact file path (e.g., "lib/n2b/cli.rb")
|
328
|
-
- The specific line numbers or line ranges (e.g., "line 42" or "lines 15-20")
|
329
|
-
- The exact code snippet you're referring to when possible
|
330
|
-
|
331
|
-
This helps users quickly locate and understand the issues you identify.
|
332
|
-
|
333
|
-
SPECIAL FOCUS ON TEST COVERAGE:
|
334
|
-
Pay special attention to whether the developer has provided adequate test coverage for the changes:
|
335
|
-
- Look for new test files or modifications to existing test files
|
336
|
-
- Check if new functionality has corresponding tests
|
337
|
-
- Evaluate if edge cases and error conditions are tested
|
338
|
-
- Assess if the tests are meaningful and comprehensive
|
339
|
-
- Note any missing test coverage that should be added
|
340
|
-
|
341
|
-
NOTE: In addition to the diff, you will also receive the current code context around the changed areas.
|
342
|
-
This provides better understanding of the surrounding code and helps with more accurate analysis.
|
343
|
-
The user may provide additional instructions or specific requirements below.
|
344
|
-
SYSTEM_PROMPT
|
63
|
+
def build_diff_analysis_prompt(diff_output, user_prompt_addition = "", requirements_content = nil, config = {})
|
64
|
+
default_system_prompt_path = resolve_template_path('diff_system_prompt', config)
|
65
|
+
default_system_prompt = File.read(default_system_prompt_path).strip
|
345
66
|
|
346
67
|
user_instructions_section = ""
|
347
68
|
unless user_prompt_addition.to_s.strip.empty?
|
@@ -382,35 +103,8 @@ REQUIREMENTS_BLOCK
|
|
382
103
|
end
|
383
104
|
end
|
384
105
|
|
385
|
-
|
386
|
-
|
387
|
-
Do not include any explanatory text before or after the JSON.
|
388
|
-
Each error and improvement should include specific file paths and line numbers.
|
389
|
-
|
390
|
-
The JSON object must contain the following keys:
|
391
|
-
- "summary": (string) Brief overall description of the changes.
|
392
|
-
- "ticket_implementation_summary": (string) A concise summary of what was implemented or achieved in relation to the ticket's goals, based *only* on the provided diff. This is for developer status updates and Jira comments.
|
393
|
-
- "errors": (list of strings) Potential bugs or issues found.
|
394
|
-
- "improvements": (list of strings) Suggestions for code quality, style, performance, or security.
|
395
|
-
- "test_coverage": (string) Assessment of test coverage for the changes.
|
396
|
-
- "requirements_evaluation": (string, include only if requirements were provided in the prompt) Evaluation of how the changes meet the provided requirements.
|
397
|
-
|
398
|
-
Example format:
|
399
|
-
{
|
400
|
-
"summary": "Refactored the user authentication module and added password complexity checks.",
|
401
|
-
"ticket_implementation_summary": "Implemented the core logic for user password updates and strengthened security by adding complexity validation as per the ticket's primary goal. Some UI elements are pending.",
|
402
|
-
"errors": [
|
403
|
-
"lib/example.rb line 42: Potential null pointer exception when accessing user.name without checking if user is nil.",
|
404
|
-
"src/main.js lines 15-20: Missing error handling for async operation."
|
405
|
-
],
|
406
|
-
"improvements": [
|
407
|
-
"lib/example.rb line 30: Consider using a constant for the magic number 42.",
|
408
|
-
"src/utils.py lines 5-10: This method could be simplified using list comprehension."
|
409
|
-
],
|
410
|
-
"test_coverage": "Good: New functionality in lib/example.rb has corresponding tests in test/example_test.rb. Missing: No tests for error handling edge cases in the new validation method.",
|
411
|
-
"requirements_evaluation": "✅ IMPLEMENTED: User authentication feature is fully implemented in auth.rb. ⚠️ PARTIALLY IMPLEMENTED: Error handling is present but lacks specific error codes. ❌ NOT IMPLEMENTED: Email notifications are not addressed in this diff."
|
412
|
-
}
|
413
|
-
JSON_INSTRUCTION
|
106
|
+
json_instruction_path = resolve_template_path('diff_json_instruction', config)
|
107
|
+
json_instruction = File.read(json_instruction_path).strip
|
414
108
|
|
415
109
|
full_prompt = [
|
416
110
|
default_system_prompt,
|
@@ -426,7 +120,7 @@ JSON_INSTRUCTION
|
|
426
120
|
end
|
427
121
|
|
428
122
|
def analyze_diff(diff_output, config, user_prompt_addition = "", requirements_content = nil)
|
429
|
-
prompt = build_diff_analysis_prompt(diff_output, user_prompt_addition, requirements_content)
|
123
|
+
prompt = build_diff_analysis_prompt(diff_output, user_prompt_addition, requirements_content, config)
|
430
124
|
analysis_json_str = call_llm_for_diff_analysis(prompt, config)
|
431
125
|
|
432
126
|
begin
|
@@ -500,6 +194,19 @@ JSON_INSTRUCTION
|
|
500
194
|
improvements.map(&:strip).reject(&:empty?)
|
501
195
|
end
|
502
196
|
|
197
|
+
def format_analysis_for_github(analysis_result)
|
198
|
+
return "No analysis result available." if analysis_result.nil? || analysis_result.empty?
|
199
|
+
|
200
|
+
{
|
201
|
+
implementation_summary: analysis_result['ticket_implementation_summary']&.strip,
|
202
|
+
technical_summary: analysis_result['summary']&.strip,
|
203
|
+
issues: format_issues_for_adf(analysis_result['errors']),
|
204
|
+
improvements: format_improvements_for_adf(analysis_result['improvements']),
|
205
|
+
test_coverage: analysis_result['test_coverage']&.strip,
|
206
|
+
requirements_evaluation: analysis_result['requirements_evaluation']&.strip
|
207
|
+
}
|
208
|
+
end
|
209
|
+
|
503
210
|
def extract_json_from_response(response)
|
504
211
|
# First try to parse the response as-is
|
505
212
|
begin
|
@@ -588,40 +295,6 @@ JSON_INSTRUCTION
|
|
588
295
|
context_sections
|
589
296
|
end
|
590
297
|
|
591
|
-
def call_llm_for_diff_analysis(prompt, config)
|
592
|
-
begin
|
593
|
-
llm_service_name = config['llm']
|
594
|
-
llm = case llm_service_name
|
595
|
-
when 'openai'
|
596
|
-
N2M::Llm::OpenAi.new(config)
|
597
|
-
when 'claude'
|
598
|
-
N2M::Llm::Claude.new(config)
|
599
|
-
when 'gemini'
|
600
|
-
N2M::Llm::Gemini.new(config)
|
601
|
-
when 'openrouter'
|
602
|
-
N2M::Llm::OpenRouter.new(config)
|
603
|
-
when 'ollama'
|
604
|
-
N2M::Llm::Ollama.new(config)
|
605
|
-
else
|
606
|
-
# Should not happen if config is validated, but as a safeguard:
|
607
|
-
raise N2B::Error, "Unsupported LLM service: #{llm_service_name}"
|
608
|
-
end
|
609
|
-
|
610
|
-
response_json_str = llm.analyze_code_diff(prompt) # Call the new dedicated method
|
611
|
-
response_json_str
|
612
|
-
rescue N2B::LlmApiError => e # This catches errors from analyze_code_diff
|
613
|
-
puts "Error communicating with the LLM: #{e.message}"
|
614
|
-
|
615
|
-
# Check if it might be a model-related error
|
616
|
-
if e.message.include?('model') || e.message.include?('Model') || e.message.include?('invalid') || e.message.include?('not found')
|
617
|
-
puts "\nThis might be due to an invalid or unsupported model configuration."
|
618
|
-
puts "Run 'n2b -c' to reconfigure your model settings."
|
619
|
-
end
|
620
|
-
|
621
|
-
return '{"summary": "Error: Could not analyze diff due to LLM API error.", "errors": [], "improvements": []}'
|
622
|
-
end
|
623
|
-
end
|
624
|
-
|
625
298
|
def append_to_llm_history_file(commands)
|
626
299
|
File.open(HISTORY_FILE, 'a') do |file|
|
627
300
|
file.puts(commands)
|
@@ -640,19 +313,19 @@ JSON_INSTRUCTION
|
|
640
313
|
llm_service_name = config['llm']
|
641
314
|
llm = case llm_service_name
|
642
315
|
when 'openai'
|
643
|
-
|
316
|
+
N2B::Llm::OpenAi.new(config)
|
644
317
|
when 'claude'
|
645
|
-
|
318
|
+
N2B::Llm::Claude.new(config)
|
646
319
|
when 'gemini'
|
647
|
-
|
320
|
+
N2B::Llm::Gemini.new(config)
|
648
321
|
when 'openrouter'
|
649
|
-
|
322
|
+
N2B::Llm::OpenRouter.new(config)
|
650
323
|
when 'ollama'
|
651
|
-
|
324
|
+
N2B::Llm::Ollama.new(config)
|
652
325
|
else
|
653
326
|
# Fallback or error, though config validation should prevent this
|
654
327
|
puts "Warning: Unsupported LLM service '#{llm_service_name}' configured. Falling back to Claude."
|
655
|
-
|
328
|
+
N2B::Llm::Claude.new(config)
|
656
329
|
end
|
657
330
|
|
658
331
|
# This content is specific to bash command generation
|
@@ -667,23 +340,37 @@ JSON_INSTRUCTION
|
|
667
340
|
EOF
|
668
341
|
|
669
342
|
|
670
|
-
|
671
|
-
|
672
|
-
|
673
|
-
#
|
674
|
-
|
675
|
-
|
676
|
-
|
677
|
-
|
678
|
-
|
343
|
+
puts "🤖 AI is generating commands..."
|
344
|
+
response = make_request_with_spinner(llm, content)
|
345
|
+
|
346
|
+
# Handle both Hash (from JSON mode providers) and String responses
|
347
|
+
if response.is_a?(Hash)
|
348
|
+
# Already parsed by the LLM provider
|
349
|
+
parsed_response = response
|
350
|
+
response_str = response.to_json # For history logging
|
351
|
+
else
|
352
|
+
# String response that needs parsing
|
353
|
+
response_str = response
|
679
354
|
begin
|
680
|
-
parsed_response = JSON.parse(
|
681
|
-
parsed_response
|
355
|
+
parsed_response = JSON.parse(response_str)
|
682
356
|
rescue JSON::ParserError => e
|
683
|
-
puts "
|
684
|
-
|
685
|
-
|
357
|
+
puts "⚠️ Invalid JSON detected, attempting automatic repair..."
|
358
|
+
repaired_response = attempt_json_repair_for_commands(response_str, llm)
|
359
|
+
|
360
|
+
if repaired_response
|
361
|
+
puts "✅ JSON repair successful!"
|
362
|
+
parsed_response = repaired_response
|
363
|
+
else
|
364
|
+
puts "❌ JSON repair failed"
|
365
|
+
puts "Error parsing LLM response JSON for command generation: #{e.message}"
|
366
|
+
# This is a fallback for when the LLM response *content* is not valid JSON.
|
367
|
+
parsed_response = { "commands" => ["echo 'Error: LLM returned invalid JSON content.'"], "explanation" => "The response from the language model was not valid JSON." }
|
368
|
+
end
|
686
369
|
end
|
370
|
+
end
|
371
|
+
|
372
|
+
append_to_llm_history_file("#{prompt}\n#{response_str}") # Storing the response for history
|
373
|
+
parsed_response
|
687
374
|
rescue N2B::LlmApiError => e
|
688
375
|
puts "Error communicating with the LLM: #{e.message}"
|
689
376
|
|
@@ -784,63 +471,116 @@ JSON_INSTRUCTION
|
|
784
471
|
end
|
785
472
|
system("history -r") # Attempt to reload history in current session
|
786
473
|
end
|
787
|
-
|
474
|
+
|
475
|
+
def resolve_template_path(template_key, config)
|
476
|
+
user_path = config.dig('templates', template_key) if config.is_a?(Hash)
|
477
|
+
return user_path if user_path && File.exist?(user_path)
|
478
|
+
|
479
|
+
File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
|
480
|
+
end
|
481
|
+
|
482
|
+
def make_request_with_spinner(llm, content)
|
483
|
+
spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
484
|
+
spinner_thread = Thread.new do
|
485
|
+
i = 0
|
486
|
+
while true
|
487
|
+
print "\r⠿ #{spinner_chars[i % spinner_chars.length]} Processing..."
|
488
|
+
$stdout.flush
|
489
|
+
sleep(0.1)
|
490
|
+
i += 1
|
491
|
+
end
|
492
|
+
end
|
493
|
+
|
494
|
+
begin
|
495
|
+
result = llm.make_request(content)
|
496
|
+
spinner_thread.kill
|
497
|
+
print "\r#{' ' * 25}\r" # Clear the spinner line
|
498
|
+
puts "✅ Commands generated!"
|
499
|
+
result
|
500
|
+
rescue => e
|
501
|
+
spinner_thread.kill
|
502
|
+
print "\r#{' ' * 25}\r" # Clear the spinner line
|
503
|
+
raise e
|
504
|
+
end
|
505
|
+
end
|
506
|
+
|
507
|
+
def attempt_json_repair_for_commands(malformed_response, llm)
|
508
|
+
repair_prompt = <<~PROMPT
|
509
|
+
The following response was supposed to be valid JSON with keys "commands" (array) and "explanation" (string), but it has formatting issues. Please fix it and return ONLY the corrected JSON:
|
510
|
+
|
511
|
+
Original response:
|
512
|
+
#{malformed_response}
|
513
|
+
|
514
|
+
Requirements:
|
515
|
+
- Must be valid JSON
|
516
|
+
- Must have "commands" key with array of command strings
|
517
|
+
- Must have "explanation" key with explanation text
|
518
|
+
- Return ONLY the JSON, no other text
|
519
|
+
|
520
|
+
Fixed JSON:
|
521
|
+
PROMPT
|
522
|
+
|
523
|
+
begin
|
524
|
+
puts "🔧 Asking AI to fix the JSON..."
|
525
|
+
repaired_json_str = llm.make_request(repair_prompt)
|
526
|
+
|
527
|
+
# Handle both Hash and String responses
|
528
|
+
if repaired_json_str.is_a?(Hash)
|
529
|
+
repaired_response = repaired_json_str
|
530
|
+
else
|
531
|
+
repaired_response = JSON.parse(repaired_json_str)
|
532
|
+
end
|
533
|
+
|
534
|
+
# Validate the repaired response structure
|
535
|
+
if repaired_response.is_a?(Hash) && repaired_response.key?('commands') && repaired_response.key?('explanation')
|
536
|
+
return repaired_response
|
537
|
+
else
|
538
|
+
return nil
|
539
|
+
end
|
540
|
+
rescue JSON::ParserError, StandardError
|
541
|
+
return nil
|
542
|
+
end
|
543
|
+
end
|
544
|
+
|
788
545
|
|
789
546
|
def parse_options
|
790
547
|
options = {
|
791
548
|
execute: false,
|
792
549
|
config: nil,
|
793
|
-
diff: false,
|
794
|
-
requirements: nil,
|
795
|
-
branch: nil,
|
796
|
-
jira_ticket: nil,
|
797
|
-
jira_update: nil, #
|
798
|
-
advanced_config: false
|
550
|
+
# diff: false, # Removed
|
551
|
+
# requirements: nil, # Removed
|
552
|
+
# branch: nil, # Removed
|
553
|
+
# jira_ticket: nil, # Removed
|
554
|
+
# jira_update: nil, # Removed
|
555
|
+
advanced_config: false
|
799
556
|
}
|
800
557
|
|
801
558
|
parser = OptionParser.new do |opts|
|
802
559
|
opts.banner = "Usage: n2b [options] [natural language command]"
|
803
560
|
|
804
|
-
opts.on('-x', '--execute', 'Execute the commands after confirmation') do
|
561
|
+
opts.on('-x', '--execute', 'Execute the translated commands after confirmation.') do
|
805
562
|
options[:execute] = true
|
806
563
|
end
|
807
564
|
|
808
|
-
|
809
|
-
options[:diff] = true
|
810
|
-
end
|
811
|
-
|
812
|
-
opts.on('-b', '--branch [BRANCH]', 'Compare against branch (default: auto-detect main/master)') do |branch|
|
813
|
-
options[:branch] = branch || 'auto'
|
814
|
-
end
|
815
|
-
|
816
|
-
opts.on('-r', '--requirements FILE', 'Requirements file for diff analysis') do |file|
|
817
|
-
options[:requirements] = file
|
818
|
-
end
|
565
|
+
# Removed options: -d, --diff, -b, --branch, -r, --requirements, -j, --jira, --jira-update, --jira-no-update
|
819
566
|
|
820
|
-
opts.on('-
|
821
|
-
options[:
|
822
|
-
end
|
823
|
-
|
824
|
-
opts.on('--jira-update', 'Update the linked Jira ticket (requires -j)') do
|
825
|
-
options[:jira_update] = true
|
567
|
+
opts.on('-c', '--config', 'Configure N2B (API key, model, privacy settings, etc.).') do
|
568
|
+
options[:config] = true
|
826
569
|
end
|
827
570
|
|
828
|
-
opts.on('--
|
829
|
-
options[:
|
571
|
+
opts.on('--advanced-config', 'Access advanced configuration options.') do
|
572
|
+
options[:advanced_config] = true
|
573
|
+
options[:config] = true # --advanced-config implies -c
|
830
574
|
end
|
831
575
|
|
832
|
-
opts.
|
576
|
+
opts.on_tail('-h', '--help', 'Show this help message.') do
|
833
577
|
puts opts
|
834
578
|
exit
|
835
579
|
end
|
836
580
|
|
837
|
-
opts.
|
838
|
-
|
839
|
-
|
840
|
-
|
841
|
-
opts.on('--advanced-config', 'Access advanced configuration options including Jira and privacy settings') do
|
842
|
-
options[:advanced_config] = true
|
843
|
-
options[:config] = true # Forcing config mode if advanced is chosen
|
581
|
+
opts.on_tail('-v', '--version', 'Show version.') do
|
582
|
+
puts "n2b version #{N2B::VERSION}" # Assuming N2B::VERSION is defined
|
583
|
+
exit
|
844
584
|
end
|
845
585
|
end
|
846
586
|
|
@@ -851,36 +591,14 @@ JSON_INSTRUCTION
|
|
851
591
|
puts ""
|
852
592
|
puts parser.help
|
853
593
|
exit 1
|
854
|
-
|
855
|
-
|
856
|
-
# Validate option combinations
|
857
|
-
if options[:branch] && !options[:diff]
|
858
|
-
puts "Error: --branch option can only be used with --diff"
|
859
|
-
puts ""
|
860
|
-
puts parser.help
|
861
|
-
exit 1
|
862
|
-
end
|
863
|
-
|
864
|
-
if options[:jira_update] == true && options[:jira_ticket].nil?
|
865
|
-
puts "Error: --jira-update option requires a Jira ticket to be specified with -j or --jira."
|
866
|
-
puts ""
|
867
|
-
puts parser.help
|
868
|
-
exit 1
|
869
|
-
end
|
870
|
-
|
871
|
-
if options[:jira_update] == false && options[:jira_ticket].nil?
|
872
|
-
puts "Error: --jira-no-update option requires a Jira ticket to be specified with -j or --jira."
|
594
|
+
rescue OptionParser::MissingArgument => e
|
595
|
+
puts "Error: #{e.message}"
|
873
596
|
puts ""
|
874
597
|
puts parser.help
|
875
598
|
exit 1
|
876
599
|
end
|
877
600
|
|
878
|
-
|
879
|
-
puts "Error: --jira-update and --jira-no-update are mutually exclusive."
|
880
|
-
puts ""
|
881
|
-
puts parser.help
|
882
|
-
exit 1
|
883
|
-
end
|
601
|
+
# Removed validation logic for diff/jira related options
|
884
602
|
|
885
603
|
options
|
886
604
|
end
|