n2b 0.7.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 +291 -118
- data/bin/branch-audit.sh +397 -0
- data/bin/n2b-test-github +22 -0
- data/lib/n2b/base.rb +207 -37
- data/lib/n2b/cli.rb +53 -400
- data/lib/n2b/github_client.rb +391 -0
- data/lib/n2b/jira_client.rb +236 -37
- data/lib/n2b/llm/claude.rb +1 -1
- data/lib/n2b/llm/gemini.rb +1 -1
- data/lib/n2b/llm/open_ai.rb +1 -1
- data/lib/n2b/merge_cli.rb +1771 -136
- data/lib/n2b/message_utils.rb +59 -0
- data/lib/n2b/templates/diff_system_prompt.txt +40 -20
- data/lib/n2b/templates/github_comment.txt +67 -0
- data/lib/n2b/templates/jira_comment.txt +7 -0
- data/lib/n2b/templates/merge_conflict_prompt.txt +2 -2
- data/lib/n2b/version.rb +1 -1
- metadata +8 -3
data/lib/n2b/base.rb
CHANGED
@@ -3,12 +3,36 @@ require_relative 'model_config'
|
|
3
3
|
module N2B
|
4
4
|
class Base
|
5
5
|
|
6
|
-
|
7
|
-
|
6
|
+
def self.config_file
|
7
|
+
# Bulletproof test environment detection
|
8
|
+
if test_environment?
|
9
|
+
File.expand_path('~/.n2b_test/config.yml')
|
10
|
+
else
|
11
|
+
ENV['N2B_CONFIG_FILE'] || File.expand_path('~/.n2b/config.yml')
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.history_file
|
16
|
+
if test_environment?
|
17
|
+
File.expand_path('~/.n2b_test/history')
|
18
|
+
else
|
19
|
+
ENV['N2B_HISTORY_FILE'] || File.expand_path('~/.n2b/history')
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.test_environment?
|
24
|
+
# Multiple ways to detect test environment for maximum safety
|
25
|
+
ENV['RAILS_ENV'] == 'test' ||
|
26
|
+
ENV['RACK_ENV'] == 'test' ||
|
27
|
+
ENV['N2B_TEST_MODE'] == 'true' ||
|
28
|
+
$PROGRAM_NAME.include?('rake') ||
|
29
|
+
$PROGRAM_NAME.include?('test') ||
|
30
|
+
caller.any? { |line| line.include?('test/') || line.include?('minitest') || line.include?('rake') }
|
31
|
+
end
|
8
32
|
|
9
33
|
def load_config
|
10
|
-
if File.exist?(
|
11
|
-
YAML.load_file(
|
34
|
+
if File.exist?(self.class.config_file)
|
35
|
+
YAML.load_file(self.class.config_file)
|
12
36
|
else
|
13
37
|
{ }
|
14
38
|
end
|
@@ -94,26 +118,46 @@ module N2B
|
|
94
118
|
|
95
119
|
if prompt_for_advanced
|
96
120
|
puts "\n--- Advanced Settings ---"
|
97
|
-
print "Would you like to configure advanced settings (e.g., Jira integration, privacy)? (y/n) [default: n]: "
|
121
|
+
print "Would you like to configure advanced settings (e.g., Jira or GitHub integration, privacy)? (y/n) [default: n]: "
|
98
122
|
choice = $stdin.gets.chomp.downcase
|
99
123
|
|
100
124
|
if choice == 'y'
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
125
|
+
current_tracker = config['issue_tracker'] || 'none'
|
126
|
+
print "\nSelect issue tracker to integrate (none, jira, github) [current: #{current_tracker}]: "
|
127
|
+
tracker_choice = $stdin.gets.chomp.downcase
|
128
|
+
tracker_choice = current_tracker if tracker_choice.empty?
|
129
|
+
config['issue_tracker'] = tracker_choice
|
130
|
+
|
131
|
+
case tracker_choice
|
132
|
+
when 'jira'
|
133
|
+
puts "\n--- Jira Integration ---"
|
134
|
+
puts "You can generate a Jira API token here: https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/"
|
135
|
+
config['jira'] ||= {}
|
136
|
+
print "Jira Domain (e.g., your-company.atlassian.net) [current: #{config['jira']['domain']}]: "
|
137
|
+
config['jira']['domain'] = $stdin.gets.chomp.then { |val| val.empty? ? config['jira']['domain'] : val }
|
138
|
+
|
139
|
+
print "Jira Email Address [current: #{config['jira']['email']}]: "
|
140
|
+
config['jira']['email'] = $stdin.gets.chomp.then { |val| val.empty? ? config['jira']['email'] : val }
|
141
|
+
|
142
|
+
print "Jira API Token #{config['jira']['api_key'] ? '[leave blank to keep current]' : ''}: "
|
143
|
+
api_token_input = $stdin.gets.chomp
|
144
|
+
config['jira']['api_key'] = api_token_input if !api_token_input.empty?
|
145
|
+
|
146
|
+
print "Default Jira Project Key (optional, e.g., MYPROJ) [current: #{config['jira']['default_project']}]: "
|
147
|
+
config['jira']['default_project'] = $stdin.gets.chomp.then { |val| val.empty? ? config['jira']['default_project'] : val }
|
148
|
+
when 'github'
|
149
|
+
puts "\n--- GitHub Integration ---"
|
150
|
+
config['github'] ||= {}
|
151
|
+
print "GitHub Repository (owner/repo) [current: #{config['github']['repo']}]: "
|
152
|
+
config['github']['repo'] = $stdin.gets.chomp.then { |val| val.empty? ? config['github']['repo'] : val }
|
153
|
+
|
154
|
+
print "GitHub Access Token #{config['github']['access_token'] ? '[leave blank to keep current]' : ''}: "
|
155
|
+
token_input = $stdin.gets.chomp
|
156
|
+
config['github']['access_token'] = token_input if !token_input.empty?
|
157
|
+
else
|
158
|
+
config['jira'] ||= {}
|
159
|
+
config['github'] ||= {}
|
160
|
+
end
|
117
161
|
|
118
162
|
# Privacy Settings
|
119
163
|
puts "\n--- Privacy Settings ---"
|
@@ -132,9 +176,13 @@ module N2B
|
|
132
176
|
config['append_to_shell_history'] = $stdin.gets.chomp.then { |val| val.empty? ? config['append_to_shell_history'] : (val.downcase == 'true') }
|
133
177
|
config['privacy']['append_to_shell_history'] = config['append_to_shell_history'] # Also place under privacy for consistency
|
134
178
|
|
179
|
+
# Editor Configuration
|
180
|
+
prompt_for_editor_config(config)
|
181
|
+
|
135
182
|
else # User chose 'n' for advanced settings
|
136
|
-
|
137
|
-
config['
|
183
|
+
config['jira'] ||= {}
|
184
|
+
config['github'] ||= {}
|
185
|
+
config['issue_tracker'] ||= 'none'
|
138
186
|
# If they opt out, we don't clear existing, just don't prompt.
|
139
187
|
# If it's a fresh setup and they opt out, these will remain empty/nil.
|
140
188
|
|
@@ -148,7 +196,9 @@ module N2B
|
|
148
196
|
end
|
149
197
|
else # Not prompting for advanced (neither advanced_flow nor first_time_core_setup)
|
150
198
|
# Ensure defaults for privacy if they don't exist from a previous config
|
151
|
-
config['jira'] ||= {}
|
199
|
+
config['jira'] ||= {}
|
200
|
+
config['github'] ||= {}
|
201
|
+
config['issue_tracker'] ||= 'none'
|
152
202
|
config['privacy'] ||= {}
|
153
203
|
config['privacy']['send_shell_history'] = config['privacy']['send_shell_history'] || false
|
154
204
|
config['privacy']['send_llm_history'] = config['privacy']['send_llm_history'] || true
|
@@ -157,6 +207,12 @@ module N2B
|
|
157
207
|
config['privacy']['append_to_shell_history'] = config['append_to_shell_history']
|
158
208
|
end
|
159
209
|
|
210
|
+
# Editor Configuration
|
211
|
+
config['editor'] ||= {}
|
212
|
+
config['editor']['command'] ||= nil # or 'nano', 'vi'
|
213
|
+
config['editor']['type'] ||= nil # 'text_editor' or 'diff_tool'
|
214
|
+
config['editor']['configured'] ||= false
|
215
|
+
|
160
216
|
# Validate configuration before saving
|
161
217
|
validation_errors = validate_config(config)
|
162
218
|
if validation_errors.any?
|
@@ -165,13 +221,15 @@ module N2B
|
|
165
221
|
puts "Configuration saved with warnings."
|
166
222
|
end
|
167
223
|
|
168
|
-
puts "\nConfiguration saved to #{
|
169
|
-
FileUtils.mkdir_p(File.dirname(
|
170
|
-
File.write(
|
224
|
+
puts "\nConfiguration saved to #{self.class.config_file}"
|
225
|
+
FileUtils.mkdir_p(File.dirname(self.class.config_file)) unless File.exist?(File.dirname(self.class.config_file))
|
226
|
+
File.write(self.class.config_file, config.to_yaml)
|
171
227
|
else
|
172
228
|
# If not reconfiguring, still ensure privacy and jira keys exist with defaults if missing
|
173
229
|
# This handles configs from before these settings were introduced
|
174
230
|
config['jira'] ||= {}
|
231
|
+
config['github'] ||= {}
|
232
|
+
config['issue_tracker'] ||= 'none'
|
175
233
|
config['privacy'] ||= {}
|
176
234
|
config['privacy']['send_shell_history'] = config['privacy']['send_shell_history'] || false
|
177
235
|
config['privacy']['send_llm_history'] = config['privacy']['send_llm_history'] || true
|
@@ -181,12 +239,100 @@ module N2B
|
|
181
239
|
current_append_setting = config.key?('append_to_shell_history') ? config['append_to_shell_history'] : false
|
182
240
|
config['append_to_shell_history'] = current_append_setting
|
183
241
|
config['privacy']['append_to_shell_history'] = config['privacy']['append_to_shell_history'] || current_append_setting
|
242
|
+
|
243
|
+
# Ensure editor config is initialized if missing (for older configs)
|
244
|
+
config['editor'] ||= {}
|
245
|
+
config['editor']['command'] ||= nil
|
246
|
+
config['editor']['type'] ||= nil
|
247
|
+
config['editor']['configured'] ||= false
|
184
248
|
end
|
185
249
|
config
|
186
250
|
end
|
187
251
|
|
188
252
|
private
|
189
253
|
|
254
|
+
def command_exists?(command)
|
255
|
+
# Check for Windows or Unix-like systems for the correct command
|
256
|
+
null_device = RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/ ? 'NUL' : '/dev/null'
|
257
|
+
system("which #{command} > #{null_device} 2>&1") || system("where #{command} > #{null_device} 2>&1")
|
258
|
+
end
|
259
|
+
|
260
|
+
def prompt_for_editor_config(config)
|
261
|
+
puts "\n--- Editor Configuration ---"
|
262
|
+
current_editor_command = config.dig('editor', 'command')
|
263
|
+
current_editor_type = config.dig('editor', 'type')
|
264
|
+
current_status = if current_editor_command
|
265
|
+
"Current: #{current_editor_command} (#{current_editor_type || 'not set'})"
|
266
|
+
else
|
267
|
+
"Current: Not configured"
|
268
|
+
end
|
269
|
+
puts current_status
|
270
|
+
|
271
|
+
known_editors = [
|
272
|
+
{ name: 'nano', command: 'nano', description: 'Simple terminal editor', type: 'text_editor' },
|
273
|
+
{ name: 'vim', command: 'vim', description: 'Powerful terminal editor', type: 'text_editor' },
|
274
|
+
{ name: 'vi', command: 'vi', description: 'Standard Unix terminal editor', type: 'text_editor' },
|
275
|
+
{ name: 'code', command: 'code', description: 'Visual Studio Code (requires "code" in PATH)', type: 'text_editor' },
|
276
|
+
{ name: 'subl', command: 'subl', description: 'Sublime Text (requires "subl" in PATH)', type: 'text_editor' },
|
277
|
+
{ name: 'meld', command: 'meld', description: 'Visual diff and merge tool', type: 'diff_tool' },
|
278
|
+
{ name: 'kdiff3', command: 'kdiff3', description: 'Visual diff and merge tool (KDE)', type: 'diff_tool' },
|
279
|
+
{ name: 'opendiff', command: 'opendiff', description: 'File comparison tool (macOS)', type: 'diff_tool' },
|
280
|
+
{ name: 'vimdiff', command: 'vimdiff', description: 'Diff tool using Vim', type: 'diff_tool' }
|
281
|
+
]
|
282
|
+
|
283
|
+
available_editors = known_editors.select { |editor| command_exists?(editor[:command]) }
|
284
|
+
|
285
|
+
if available_editors.empty?
|
286
|
+
puts "No standard editors detected automatically."
|
287
|
+
else
|
288
|
+
puts "Choose your preferred editor/diff tool:"
|
289
|
+
available_editors.each_with_index do |editor, index|
|
290
|
+
puts "#{index + 1}. #{editor[:name]} (#{editor[:description]})"
|
291
|
+
end
|
292
|
+
puts "#{available_editors.length + 1}. Custom (enter your own command)"
|
293
|
+
print "Enter choice (1-#{available_editors.length + 1}) or leave blank to skip: "
|
294
|
+
end
|
295
|
+
|
296
|
+
choice_input = $stdin.gets.chomp
|
297
|
+
return if choice_input.empty? && current_editor_command # Skip if already configured and user wants to skip
|
298
|
+
|
299
|
+
choice = choice_input.to_i
|
300
|
+
|
301
|
+
selected_editor = nil
|
302
|
+
custom_command = nil
|
303
|
+
|
304
|
+
if choice > 0 && choice <= available_editors.length
|
305
|
+
selected_editor = available_editors[choice - 1]
|
306
|
+
config['editor']['command'] = selected_editor[:command]
|
307
|
+
config['editor']['type'] = selected_editor[:type]
|
308
|
+
config['editor']['configured'] = true
|
309
|
+
puts "✓ Using #{selected_editor[:name]} as your editor/diff tool."
|
310
|
+
elsif choice == available_editors.length + 1 || (available_editors.empty? && !choice_input.empty?)
|
311
|
+
print "Enter custom editor command: "
|
312
|
+
custom_command = $stdin.gets.chomp
|
313
|
+
if custom_command.empty?
|
314
|
+
puts "No command entered. Editor configuration skipped."
|
315
|
+
return
|
316
|
+
end
|
317
|
+
|
318
|
+
print "Is this a 'text_editor' or a 'diff_tool'? (text_editor/diff_tool): "
|
319
|
+
custom_type = $stdin.gets.chomp.downcase
|
320
|
+
unless ['text_editor', 'diff_tool'].include?(custom_type)
|
321
|
+
puts "Invalid type. Defaulting to 'text_editor'."
|
322
|
+
custom_type = 'text_editor'
|
323
|
+
end
|
324
|
+
config['editor']['command'] = custom_command
|
325
|
+
config['editor']['type'] = custom_type
|
326
|
+
config['editor']['configured'] = true
|
327
|
+
puts "✓ Using custom command '#{custom_command}' (#{custom_type}) as your editor/diff tool."
|
328
|
+
else
|
329
|
+
puts "Invalid choice. Editor configuration skipped."
|
330
|
+
# Keep existing config if invalid choice, or clear if they wanted to change but failed?
|
331
|
+
# For now, just skipping and keeping whatever was there.
|
332
|
+
return
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
190
336
|
def validate_config(config)
|
191
337
|
errors = []
|
192
338
|
|
@@ -209,18 +355,42 @@ module N2B
|
|
209
355
|
errors << "API key missing for #{config['llm']} provider"
|
210
356
|
end
|
211
357
|
|
212
|
-
# Validate
|
213
|
-
if
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
358
|
+
# Validate editor configuration (optional, so more like warnings or info)
|
359
|
+
# Example: Check if command is set if configured is true
|
360
|
+
# if config['editor'] && config['editor']['configured'] && (config['editor']['command'].nil? || config['editor']['command'].empty?)
|
361
|
+
# errors << "Editor is marked as configured, but no command is set."
|
362
|
+
# end
|
363
|
+
|
364
|
+
tracker = config['issue_tracker'] || 'none'
|
365
|
+
case tracker
|
366
|
+
when 'jira'
|
367
|
+
if config['jira'] && !config['jira'].empty?
|
368
|
+
jira_config = config['jira']
|
369
|
+
if jira_config['domain'] && !jira_config['domain'].empty?
|
370
|
+
if jira_config['email'].nil? || jira_config['email'].empty?
|
371
|
+
errors << "Jira email missing when domain is configured"
|
372
|
+
end
|
373
|
+
if jira_config['api_key'].nil? || jira_config['api_key'].empty?
|
374
|
+
errors << "Jira API key missing when domain is configured"
|
375
|
+
end
|
376
|
+
else
|
377
|
+
errors << "Jira domain missing when issue_tracker is set to 'jira'"
|
222
378
|
end
|
379
|
+
else
|
380
|
+
errors << "Jira configuration missing when issue_tracker is set to 'jira'"
|
381
|
+
end
|
382
|
+
when 'github'
|
383
|
+
if config['github'] && !config['github'].empty?
|
384
|
+
gh = config['github']
|
385
|
+
errors << "GitHub repository missing when issue_tracker is set to 'github'" if gh['repo'].nil? || gh['repo'].empty?
|
386
|
+
errors << "GitHub access token missing when issue_tracker is set to 'github'" if gh['access_token'].nil? || gh['access_token'].empty?
|
387
|
+
else
|
388
|
+
errors << "GitHub configuration missing when issue_tracker is set to 'github'"
|
223
389
|
end
|
390
|
+
when 'none'
|
391
|
+
# No validation needed for 'none' tracker
|
392
|
+
else
|
393
|
+
errors << "Invalid issue_tracker '#{tracker}' - must be 'jira', 'github', or 'none'"
|
224
394
|
end
|
225
395
|
|
226
396
|
errors
|