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
@@ -0,0 +1,391 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'uri'
|
3
|
+
require 'json'
|
4
|
+
require_relative 'template_engine'
|
5
|
+
|
6
|
+
module N2B
|
7
|
+
class GitHubClient
|
8
|
+
class GitHubApiError < StandardError; end
|
9
|
+
|
10
|
+
def initialize(config)
|
11
|
+
@config = config
|
12
|
+
@github_config = @config['github'] || {}
|
13
|
+
unless @github_config['repo'] && @github_config['access_token']
|
14
|
+
raise ArgumentError, "GitHub repo and access token must be configured in N2B settings."
|
15
|
+
end
|
16
|
+
@api_base = @github_config['api_base'] || 'https://api.github.com'
|
17
|
+
end
|
18
|
+
|
19
|
+
def fetch_issue(issue_input)
|
20
|
+
repo, number = parse_issue_input(issue_input)
|
21
|
+
begin
|
22
|
+
issue_data = make_api_request('GET', "/repos/#{repo}/issues/#{number}")
|
23
|
+
comments = make_api_request('GET', "/repos/#{repo}/issues/#{number}/comments")
|
24
|
+
format_issue_for_requirements(repo, issue_data, comments)
|
25
|
+
rescue GitHubApiError => e
|
26
|
+
puts "⚠️ Failed to fetch from GitHub API: #{e.message}"
|
27
|
+
fetch_dummy_issue_data(repo, number)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def update_issue(issue_input, comment_data)
|
32
|
+
repo, number = parse_issue_input(issue_input)
|
33
|
+
body_text = comment_data.is_a?(String) ? comment_data : generate_templated_comment(comment_data)
|
34
|
+
body = { 'body' => body_text }
|
35
|
+
make_api_request('POST', "/repos/#{repo}/issues/#{number}/comments", body)
|
36
|
+
puts "✅ Successfully added comment to GitHub issue #{repo}##{number}"
|
37
|
+
true
|
38
|
+
rescue GitHubApiError => e
|
39
|
+
puts "❌ Failed to update GitHub issue #{repo}##{number}: #{e.message}"
|
40
|
+
false
|
41
|
+
end
|
42
|
+
|
43
|
+
def generate_templated_comment(comment_data)
|
44
|
+
template_data = prepare_template_data(comment_data)
|
45
|
+
template_path = resolve_template_path('github_comment', @config)
|
46
|
+
template_content = File.read(template_path)
|
47
|
+
engine = N2B::TemplateEngine.new(template_content, template_data)
|
48
|
+
engine.render
|
49
|
+
end
|
50
|
+
|
51
|
+
def prepare_template_data(comment_data)
|
52
|
+
errors = comment_data[:issues] || comment_data['issues'] || []
|
53
|
+
critical_errors = []
|
54
|
+
important_errors = []
|
55
|
+
|
56
|
+
errors.each do |error|
|
57
|
+
severity = classify_error_severity(error)
|
58
|
+
file_ref = extract_file_reference(error)
|
59
|
+
item = {
|
60
|
+
'file_reference' => file_ref,
|
61
|
+
'description' => clean_error_description(error)
|
62
|
+
}
|
63
|
+
case severity
|
64
|
+
when 'CRITICAL'
|
65
|
+
critical_errors << item
|
66
|
+
else
|
67
|
+
important_errors << item
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
improvements = (comment_data[:improvements] || comment_data['improvements'] || []).map do |imp|
|
72
|
+
{
|
73
|
+
'file_reference' => extract_file_reference(imp),
|
74
|
+
'description' => clean_error_description(imp)
|
75
|
+
}
|
76
|
+
end
|
77
|
+
|
78
|
+
missing_tests = extract_missing_tests(comment_data[:test_coverage] || comment_data['test_coverage'] || '')
|
79
|
+
requirements = extract_requirements_status(comment_data[:requirements_evaluation] || comment_data['requirements_evaluation'] || '')
|
80
|
+
|
81
|
+
git_info = extract_git_info
|
82
|
+
|
83
|
+
{
|
84
|
+
'implementation_summary' => comment_data[:implementation_summary] || comment_data['implementation_summary'] || 'Code analysis completed',
|
85
|
+
'critical_errors' => critical_errors,
|
86
|
+
'important_errors' => important_errors,
|
87
|
+
'improvements' => improvements,
|
88
|
+
'missing_tests' => missing_tests,
|
89
|
+
'requirements' => requirements,
|
90
|
+
'timestamp' => Time.now.strftime('%Y-%m-%d %H:%M UTC'),
|
91
|
+
'branch_name' => git_info[:branch],
|
92
|
+
'files_changed' => git_info[:files_changed],
|
93
|
+
'lines_added' => git_info[:lines_added],
|
94
|
+
'lines_removed' => git_info[:lines_removed],
|
95
|
+
'critical_errors_empty' => critical_errors.empty?,
|
96
|
+
'important_errors_empty' => important_errors.empty?,
|
97
|
+
'improvements_empty' => improvements.empty?,
|
98
|
+
'missing_tests_empty' => missing_tests.empty?
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
def classify_error_severity(error_text)
|
103
|
+
text = error_text.downcase
|
104
|
+
case text
|
105
|
+
when /security|sql injection|xss|csrf|vulnerability|exploit|attack/
|
106
|
+
'CRITICAL'
|
107
|
+
when /performance|n\+1|timeout|memory leak|slow query|bottleneck/
|
108
|
+
'IMPORTANT'
|
109
|
+
when /error|exception|bug|fail|crash|break/
|
110
|
+
'IMPORTANT'
|
111
|
+
when /style|convention|naming|format|indent|space/
|
112
|
+
'LOW'
|
113
|
+
else
|
114
|
+
'IMPORTANT'
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def extract_file_reference(text)
|
119
|
+
if match = text.match(/(\S+\.(?:rb|js|py|java|cpp|c|h|ts|jsx|tsx|php|go|rs|swift|kt))(?:\s+(?:line|lines?)\s+(\d+(?:-\d+)?)|:(\d+(?:-\d+)?)|\s*\(line\s+(\d+)\))?/i)
|
120
|
+
file = match[1]
|
121
|
+
line = match[2] || match[3] || match[4]
|
122
|
+
line ? "#{file}:#{line}" : file
|
123
|
+
else
|
124
|
+
'General'
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def clean_error_description(text)
|
129
|
+
text.gsub(/\S+\.(?:rb|js|py|java|cpp|c|h|ts|jsx|tsx|php|go|rs|swift|kt)(?:\s+(?:line|lines?)\s+\d+(?:-\d+)?|:\d+(?:-\d+)?|\s*\(line\s+\d+\))?:?\s*/i, '').strip
|
130
|
+
end
|
131
|
+
|
132
|
+
def extract_missing_tests(test_coverage_text)
|
133
|
+
missing_tests = []
|
134
|
+
test_coverage_text.scan(/(?:missing|need|add|require).*?test.*?(?:\.|$)/i) do |match|
|
135
|
+
missing_tests << { 'description' => match.strip }
|
136
|
+
end
|
137
|
+
if missing_tests.empty? && test_coverage_text.include?('%')
|
138
|
+
if coverage_match = test_coverage_text.match(/(\d+)%/)
|
139
|
+
coverage = coverage_match[1].to_i
|
140
|
+
if coverage < 80
|
141
|
+
missing_tests << { 'description' => "Increase test coverage from #{coverage}% to target 80%+" }
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
missing_tests
|
146
|
+
end
|
147
|
+
|
148
|
+
def extract_requirements_status(requirements_text)
|
149
|
+
requirements = []
|
150
|
+
requirements_text.split("\n").each do |line|
|
151
|
+
line = line.strip
|
152
|
+
next if line.empty?
|
153
|
+
if match = line.match(/(✅|⚠️|❌|🔍)?\s*(PARTIALLY\s+IMPLEMENTED|NOT\s+IMPLEMENTED|IMPLEMENTED|UNCLEAR)?:?\s*(.+)/i)
|
154
|
+
status_emoji, status_text, description = match.captures
|
155
|
+
status = case
|
156
|
+
when status_text&.include?('PARTIALLY')
|
157
|
+
'PARTIALLY_IMPLEMENTED'
|
158
|
+
when status_text&.include?('NOT')
|
159
|
+
'NOT_IMPLEMENTED'
|
160
|
+
when status_emoji == '✅' || (status_text&.include?('IMPLEMENTED') && !status_text&.include?('NOT') && !status_text&.include?('PARTIALLY'))
|
161
|
+
'IMPLEMENTED'
|
162
|
+
when status_emoji == '⚠️'
|
163
|
+
'PARTIALLY_IMPLEMENTED'
|
164
|
+
when status_emoji == '❌'
|
165
|
+
'NOT_IMPLEMENTED'
|
166
|
+
else
|
167
|
+
'UNCLEAR'
|
168
|
+
end
|
169
|
+
requirements << {
|
170
|
+
'status' => status,
|
171
|
+
'description' => description.strip
|
172
|
+
}
|
173
|
+
end
|
174
|
+
end
|
175
|
+
requirements
|
176
|
+
end
|
177
|
+
|
178
|
+
def extract_git_info
|
179
|
+
begin
|
180
|
+
if File.exist?('.git')
|
181
|
+
branch = execute_vcs_command_with_timeout('git branch --show-current', 5)
|
182
|
+
branch = branch[:success] ? branch[:stdout].strip : 'unknown'
|
183
|
+
branch = 'unknown' if branch.empty?
|
184
|
+
|
185
|
+
diff_result = execute_vcs_command_with_timeout('git diff --stat HEAD~1', 5)
|
186
|
+
if diff_result[:success]
|
187
|
+
diff_stats = diff_result[:stdout].strip
|
188
|
+
files_changed = diff_stats.scan(/(\d+) files? changed/).flatten.first || '0'
|
189
|
+
lines_added = diff_stats.scan(/(\d+) insertions?/).flatten.first || '0'
|
190
|
+
lines_removed = diff_stats.scan(/(\d+) deletions?/).flatten.first || '0'
|
191
|
+
else
|
192
|
+
files_changed = '0'
|
193
|
+
lines_added = '0'
|
194
|
+
lines_removed = '0'
|
195
|
+
end
|
196
|
+
elsif File.exist?('.hg')
|
197
|
+
branch_result = execute_vcs_command_with_timeout('hg branch', 5)
|
198
|
+
branch = branch_result[:success] ? branch_result[:stdout].strip : 'default'
|
199
|
+
branch = 'default' if branch.empty?
|
200
|
+
|
201
|
+
diff_result = execute_vcs_command_with_timeout('hg diff --stat', 5)
|
202
|
+
if diff_result[:success]
|
203
|
+
files_changed = diff_result[:stdout].lines.count.to_s
|
204
|
+
else
|
205
|
+
files_changed = '0'
|
206
|
+
end
|
207
|
+
lines_added = '0'
|
208
|
+
lines_removed = '0'
|
209
|
+
else
|
210
|
+
branch = 'unknown'
|
211
|
+
files_changed = '0'
|
212
|
+
lines_added = '0'
|
213
|
+
lines_removed = '0'
|
214
|
+
end
|
215
|
+
rescue
|
216
|
+
branch = 'unknown'
|
217
|
+
files_changed = '0'
|
218
|
+
lines_added = '0'
|
219
|
+
lines_removed = '0'
|
220
|
+
end
|
221
|
+
{ branch: branch, files_changed: files_changed, lines_added: lines_added, lines_removed: lines_removed }
|
222
|
+
end
|
223
|
+
|
224
|
+
def execute_vcs_command_with_timeout(command, timeout_seconds)
|
225
|
+
require 'open3'
|
226
|
+
|
227
|
+
begin
|
228
|
+
# Use Open3.popen3 with manual timeout handling to avoid thread issues
|
229
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3(command)
|
230
|
+
stdin.close
|
231
|
+
|
232
|
+
# Manual timeout implementation
|
233
|
+
start_time = Time.now
|
234
|
+
while wait_thr.alive?
|
235
|
+
if Time.now - start_time > timeout_seconds
|
236
|
+
# Kill the process
|
237
|
+
begin
|
238
|
+
Process.kill('TERM', wait_thr.pid)
|
239
|
+
sleep(0.5)
|
240
|
+
Process.kill('KILL', wait_thr.pid) if wait_thr.alive?
|
241
|
+
rescue Errno::ESRCH
|
242
|
+
# Process already dead
|
243
|
+
end
|
244
|
+
stdout.close
|
245
|
+
stderr.close
|
246
|
+
return { success: false, error: "Command timed out after #{timeout_seconds} seconds" }
|
247
|
+
end
|
248
|
+
sleep(0.1)
|
249
|
+
end
|
250
|
+
|
251
|
+
# Process completed within timeout
|
252
|
+
stdout_content = stdout.read
|
253
|
+
stderr_content = stderr.read
|
254
|
+
stdout.close
|
255
|
+
stderr.close
|
256
|
+
|
257
|
+
exit_status = wait_thr.value
|
258
|
+
if exit_status.success?
|
259
|
+
{ success: true, stdout: stdout_content, stderr: stderr_content }
|
260
|
+
else
|
261
|
+
{ success: false, error: stderr_content.empty? ? "Command failed with exit code #{exit_status.exitstatus}" : stderr_content }
|
262
|
+
end
|
263
|
+
rescue => e
|
264
|
+
{ success: false, error: "Unexpected error: #{e.message}" }
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
def resolve_template_path(template_key, config)
|
269
|
+
user_path = config.dig('templates', template_key) if config.is_a?(Hash)
|
270
|
+
return user_path if user_path && File.exist?(user_path)
|
271
|
+
File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
|
272
|
+
end
|
273
|
+
|
274
|
+
def get_config(reconfigure: false, advanced_flow: false)
|
275
|
+
# Return the config that was passed during initialization
|
276
|
+
# This is used for template resolution and other configuration needs
|
277
|
+
@config
|
278
|
+
end
|
279
|
+
|
280
|
+
def test_connection
|
281
|
+
puts "🧪 Testing GitHub API connection..."
|
282
|
+
begin
|
283
|
+
user = make_api_request('GET', '/user')
|
284
|
+
puts "✅ Authentication successful as #{user['login']}"
|
285
|
+
repo = @github_config['repo']
|
286
|
+
repo_data = make_api_request('GET', "/repos/#{repo}")
|
287
|
+
puts "✅ Access to repository #{repo_data['full_name']}"
|
288
|
+
true
|
289
|
+
rescue => e
|
290
|
+
puts "❌ GitHub connection test failed: #{e.message}"
|
291
|
+
false
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
private
|
296
|
+
|
297
|
+
def parse_issue_input(input)
|
298
|
+
if input =~ %r{^https?://}
|
299
|
+
uri = URI.parse(input)
|
300
|
+
parts = uri.path.split('/').reject(&:empty?)
|
301
|
+
if parts.length >= 4 && ['issues', 'pull'].include?(parts[2])
|
302
|
+
repo = "#{parts[0]}/#{parts[1]}"
|
303
|
+
number = parts[3]
|
304
|
+
return [repo, number]
|
305
|
+
else
|
306
|
+
raise GitHubApiError, "Could not parse issue from URL: #{input}"
|
307
|
+
end
|
308
|
+
elsif input.to_s =~ /^#?(\d+)$/
|
309
|
+
repo = @github_config['repo']
|
310
|
+
raise GitHubApiError, 'Repository not configured' unless repo
|
311
|
+
return [repo, $1]
|
312
|
+
else
|
313
|
+
raise GitHubApiError, "Invalid issue format: #{input}"
|
314
|
+
end
|
315
|
+
rescue URI::InvalidURIError
|
316
|
+
raise GitHubApiError, "Invalid URL format: #{input}"
|
317
|
+
end
|
318
|
+
|
319
|
+
def make_api_request(method, path, body = nil)
|
320
|
+
uri = URI.parse("#{@api_base}#{path}")
|
321
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
322
|
+
http.use_ssl = (uri.scheme == 'https')
|
323
|
+
request = case method.upcase
|
324
|
+
when 'GET'
|
325
|
+
Net::HTTP::Get.new(uri.request_uri)
|
326
|
+
when 'POST'
|
327
|
+
r = Net::HTTP::Post.new(uri.request_uri)
|
328
|
+
r.body = body.to_json if body
|
329
|
+
r
|
330
|
+
else
|
331
|
+
raise GitHubApiError, "Unsupported HTTP method: #{method}"
|
332
|
+
end
|
333
|
+
request['Authorization'] = "token #{@github_config['access_token']}"
|
334
|
+
request['User-Agent'] = 'n2b'
|
335
|
+
request['Accept'] = 'application/vnd.github+json'
|
336
|
+
response = http.request(request)
|
337
|
+
unless response.is_a?(Net::HTTPSuccess)
|
338
|
+
raise GitHubApiError, "GitHub API Error: #{response.code} #{response.message} - #{response.body}"
|
339
|
+
end
|
340
|
+
response.body.empty? ? {} : JSON.parse(response.body)
|
341
|
+
rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError,
|
342
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError => e
|
343
|
+
raise GitHubApiError, "GitHub API request failed: #{e.class} - #{e.message}"
|
344
|
+
end
|
345
|
+
|
346
|
+
def format_issue_for_requirements(repo, issue_data, comments)
|
347
|
+
comments_section = format_comments_for_requirements(comments)
|
348
|
+
<<~OUT
|
349
|
+
Repository: #{repo}
|
350
|
+
Issue Number: #{issue_data['number']}
|
351
|
+
Title: #{issue_data['title']}
|
352
|
+
State: #{issue_data['state']}
|
353
|
+
Author: #{issue_data.dig('user', 'login')}
|
354
|
+
|
355
|
+
--- Full Description ---
|
356
|
+
#{issue_data['body']}
|
357
|
+
|
358
|
+
#{comments_section}
|
359
|
+
OUT
|
360
|
+
end
|
361
|
+
|
362
|
+
def format_comments_for_requirements(comments)
|
363
|
+
return "" unless comments.is_a?(Array) && comments.any?
|
364
|
+
formatted = ["--- Comments with Additional Context ---"]
|
365
|
+
comments.each_with_index do |comment, idx|
|
366
|
+
created = comment['created_at'] || 'Unknown'
|
367
|
+
formatted_date = begin
|
368
|
+
Time.parse(created).strftime('%Y-%m-%d %H:%M')
|
369
|
+
rescue
|
370
|
+
created
|
371
|
+
end
|
372
|
+
formatted << "\nComment #{idx + 1} (#{comment.dig('user', 'login')}, #{formatted_date}):"
|
373
|
+
formatted << comment['body'].to_s.strip
|
374
|
+
end
|
375
|
+
formatted.join("\n")
|
376
|
+
end
|
377
|
+
|
378
|
+
def fetch_dummy_issue_data(repo, number)
|
379
|
+
<<~DUMMY.strip
|
380
|
+
Repository: #{repo}
|
381
|
+
Issue Number: #{number}
|
382
|
+
Title: Dummy issue #{number}
|
383
|
+
State: open
|
384
|
+
Author: Dummy
|
385
|
+
|
386
|
+
--- Full Description ---
|
387
|
+
This is dummy issue content used when GitHub API access fails.
|
388
|
+
DUMMY
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|