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.
@@ -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