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/jira_client.rb
CHANGED
@@ -2,6 +2,7 @@ require 'net/http'
|
|
2
2
|
require 'uri'
|
3
3
|
require 'json'
|
4
4
|
require 'base64'
|
5
|
+
require_relative 'template_engine'
|
5
6
|
|
6
7
|
module N2B
|
7
8
|
class JiraClient
|
@@ -67,19 +68,44 @@ module N2B
|
|
67
68
|
|
68
69
|
puts "Updating Jira ticket #{ticket_key} with analysis comment..."
|
69
70
|
|
71
|
+
# Generate comment using template system
|
72
|
+
template_comment = generate_templated_comment(comment)
|
73
|
+
|
74
|
+
if debug_mode?
|
75
|
+
puts "🔍 DEBUG: Generated template comment (#{template_comment.length} chars):"
|
76
|
+
puts "--- TEMPLATE COMMENT START ---"
|
77
|
+
puts template_comment
|
78
|
+
puts "--- TEMPLATE COMMENT END ---"
|
79
|
+
end
|
80
|
+
|
70
81
|
# Prepare the comment body in Jira's Atlassian Document Format (ADF)
|
71
82
|
comment_body = {
|
72
|
-
"body" => format_comment_as_adf(
|
83
|
+
"body" => format_comment_as_adf(template_comment)
|
73
84
|
}
|
74
85
|
|
86
|
+
if debug_mode?
|
87
|
+
puts "🔍 DEBUG: Formatted ADF comment body:"
|
88
|
+
puts "--- ADF BODY START ---"
|
89
|
+
puts JSON.pretty_generate(comment_body)
|
90
|
+
puts "--- ADF BODY END ---"
|
91
|
+
end
|
92
|
+
|
75
93
|
# Make the API call to add a comment
|
76
94
|
path = "/rest/api/3/issue/#{ticket_key}/comment"
|
95
|
+
puts "🔍 DEBUG: Making API request to: #{path}" if debug_mode?
|
96
|
+
|
77
97
|
_response = make_api_request('POST', path, comment_body)
|
78
98
|
|
79
99
|
puts "✅ Successfully added comment to Jira ticket #{ticket_key}"
|
80
100
|
true
|
81
101
|
rescue JiraApiError => e
|
82
102
|
puts "❌ Failed to update Jira ticket #{ticket_key}: #{e.message}"
|
103
|
+
if debug_mode?
|
104
|
+
puts "🔍 DEBUG: Full error details:"
|
105
|
+
puts " - Ticket key: #{ticket_key}"
|
106
|
+
puts " - Template comment length: #{template_comment&.length || 'nil'}"
|
107
|
+
puts " - Comment body keys: #{comment_body&.keys || 'nil'}"
|
108
|
+
end
|
83
109
|
false
|
84
110
|
end
|
85
111
|
|
@@ -396,28 +422,543 @@ module N2B
|
|
396
422
|
end
|
397
423
|
end
|
398
424
|
|
399
|
-
|
425
|
+
def generate_templated_comment(comment_data)
|
426
|
+
# Handle structured hash data from format_analysis_for_jira
|
427
|
+
if comment_data.is_a?(Hash) && comment_data.key?(:implementation_summary)
|
428
|
+
return generate_structured_comment(comment_data)
|
429
|
+
end
|
400
430
|
|
401
|
-
|
402
|
-
|
431
|
+
# Prepare template data from the analysis results
|
432
|
+
template_data = prepare_template_data(comment_data)
|
433
|
+
|
434
|
+
# Load and render template
|
435
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
436
|
+
template_path = resolve_template_path('jira_comment', config)
|
437
|
+
template_content = File.read(template_path)
|
438
|
+
|
439
|
+
engine = N2B::TemplateEngine.new(template_content, template_data)
|
440
|
+
engine.render
|
441
|
+
end
|
442
|
+
|
443
|
+
def generate_structured_comment(data)
|
444
|
+
# Generate a properly formatted comment from structured analysis data
|
445
|
+
git_info = extract_git_info
|
446
|
+
timestamp = Time.now.strftime("%Y-%m-%d %H:%M UTC")
|
447
|
+
|
448
|
+
comment_parts = []
|
449
|
+
|
450
|
+
# Header
|
451
|
+
comment_parts << "*N2B Code Analysis Report*"
|
452
|
+
comment_parts << ""
|
453
|
+
|
454
|
+
# Implementation Summary (always expanded)
|
455
|
+
comment_parts << "*Implementation Summary:*"
|
456
|
+
comment_parts << (data[:implementation_summary] || "Unknown")
|
457
|
+
comment_parts << ""
|
458
|
+
|
459
|
+
# Custom message if provided (also expanded)
|
460
|
+
if data[:custom_analysis_focus] && !data[:custom_analysis_focus].empty?
|
461
|
+
comment_parts << "*Custom Analysis Focus:*"
|
462
|
+
comment_parts << data[:custom_analysis_focus]
|
463
|
+
comment_parts << ""
|
464
|
+
end
|
465
|
+
|
466
|
+
comment_parts << "---"
|
467
|
+
comment_parts << ""
|
468
|
+
|
469
|
+
# Automated Analysis Findings
|
470
|
+
comment_parts << "*Automated Analysis Findings:*"
|
471
|
+
comment_parts << ""
|
472
|
+
|
473
|
+
# Critical Issues (collapsed by default)
|
474
|
+
critical_issues = classify_issues_by_severity(data[:issues] || [], 'CRITICAL')
|
475
|
+
if critical_issues.any?
|
476
|
+
comment_parts << "{expand:🚨 Critical Issues (Must Fix Before Merge)}"
|
477
|
+
critical_issues.each { |issue| comment_parts << "☐ #{issue}" }
|
478
|
+
comment_parts << "{expand}"
|
479
|
+
else
|
480
|
+
comment_parts << "✅ No critical issues found"
|
481
|
+
end
|
482
|
+
comment_parts << ""
|
483
|
+
|
484
|
+
# Important Issues (collapsed by default)
|
485
|
+
important_issues = classify_issues_by_severity(data[:issues] || [], 'IMPORTANT')
|
486
|
+
if important_issues.any?
|
487
|
+
comment_parts << "{expand:⚠️ Important Issues (Should Address)}"
|
488
|
+
important_issues.each { |issue| comment_parts << "☐ #{issue}" }
|
489
|
+
comment_parts << "{expand}"
|
490
|
+
else
|
491
|
+
comment_parts << "✅ No important issues found"
|
492
|
+
end
|
493
|
+
comment_parts << ""
|
494
|
+
|
495
|
+
# Suggested Improvements (collapsed by default)
|
496
|
+
if data[:improvements] && data[:improvements].any?
|
497
|
+
comment_parts << "{expand:💡 Suggested Improvements (Nice to Have)}"
|
498
|
+
data[:improvements].each { |improvement| comment_parts << "☐ #{improvement}" }
|
499
|
+
comment_parts << "{expand}"
|
500
|
+
else
|
501
|
+
comment_parts << "✅ No specific improvements suggested"
|
502
|
+
end
|
503
|
+
comment_parts << ""
|
504
|
+
|
505
|
+
# Test Coverage Assessment
|
506
|
+
comment_parts << "{expand:🧪 Test Coverage Assessment}"
|
507
|
+
if data[:test_coverage] && !data[:test_coverage].empty?
|
508
|
+
comment_parts << "*Overall Assessment:* #{data[:test_coverage]}"
|
509
|
+
else
|
510
|
+
comment_parts << "*Overall Assessment:* Not assessed"
|
511
|
+
end
|
512
|
+
comment_parts << "{expand}"
|
513
|
+
comment_parts << ""
|
514
|
+
|
515
|
+
# Missing Test Coverage
|
516
|
+
comment_parts << "*Missing Test Coverage:*"
|
517
|
+
comment_parts << "☐ No specific missing tests identified"
|
518
|
+
comment_parts << ""
|
519
|
+
|
520
|
+
# Requirements Evaluation
|
521
|
+
comment_parts << "*📋 Requirements Evaluation:*"
|
522
|
+
if data[:requirements_evaluation] && !data[:requirements_evaluation].empty?
|
523
|
+
comment_parts << "#{data[:requirements_evaluation]}"
|
524
|
+
else
|
525
|
+
comment_parts << "🔍 *UNCLEAR:* Requirements not provided or assessed"
|
526
|
+
end
|
527
|
+
comment_parts << ""
|
528
|
+
|
529
|
+
comment_parts << "---"
|
530
|
+
comment_parts << ""
|
531
|
+
|
532
|
+
# Footer with metadata (simplified)
|
533
|
+
comment_parts << "Analysis completed on #{timestamp} | Branch: #{git_info[:branch]}"
|
534
|
+
|
535
|
+
comment_parts.join("\n")
|
536
|
+
end
|
537
|
+
|
538
|
+
def classify_issues_by_severity(issues, target_severity)
|
539
|
+
return [] unless issues.is_a?(Array)
|
540
|
+
|
541
|
+
issues.select do |issue|
|
542
|
+
severity = classify_error_severity(issue)
|
543
|
+
severity == target_severity
|
544
|
+
end
|
545
|
+
end
|
546
|
+
|
547
|
+
def prepare_template_data(comment_data)
|
548
|
+
# Handle both string and hash inputs
|
403
549
|
if comment_data.is_a?(String)
|
550
|
+
# For simple string comments, create a basic template data structure
|
551
|
+
git_info = extract_git_info
|
404
552
|
return {
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
553
|
+
'implementation_summary' => comment_data,
|
554
|
+
'critical_errors' => [],
|
555
|
+
'important_errors' => [],
|
556
|
+
'improvements' => [],
|
557
|
+
'missing_tests' => [],
|
558
|
+
'requirements' => [],
|
559
|
+
'test_coverage_summary' => "No specific test coverage analysis available",
|
560
|
+
'timestamp' => Time.now.strftime("%Y-%m-%d %H:%M UTC"),
|
561
|
+
'branch_name' => git_info[:branch],
|
562
|
+
'files_changed' => git_info[:files_changed],
|
563
|
+
'lines_added' => git_info[:lines_added],
|
564
|
+
'lines_removed' => git_info[:lines_removed],
|
565
|
+
'critical_errors_empty' => true,
|
566
|
+
'important_errors_empty' => true,
|
567
|
+
'improvements_empty' => true,
|
568
|
+
'missing_tests_empty' => true
|
569
|
+
}
|
570
|
+
end
|
571
|
+
|
572
|
+
# Handle hash input (structured analysis data)
|
573
|
+
# Extract and classify errors by severity
|
574
|
+
errors = comment_data[:issues] || comment_data['issues'] || []
|
575
|
+
critical_errors = []
|
576
|
+
important_errors = []
|
577
|
+
low_errors = []
|
578
|
+
|
579
|
+
errors.each do |error|
|
580
|
+
severity = classify_error_severity(error)
|
581
|
+
file_ref = extract_file_reference(error)
|
582
|
+
|
583
|
+
error_item = {
|
584
|
+
'file_reference' => file_ref,
|
585
|
+
'description' => clean_error_description(error),
|
586
|
+
'severity' => severity
|
587
|
+
}
|
588
|
+
|
589
|
+
case severity
|
590
|
+
when 'CRITICAL'
|
591
|
+
critical_errors << error_item
|
592
|
+
when 'IMPORTANT'
|
593
|
+
important_errors << error_item
|
594
|
+
else
|
595
|
+
low_errors << error_item
|
596
|
+
end
|
597
|
+
end
|
598
|
+
|
599
|
+
# Process improvements
|
600
|
+
improvements = (comment_data[:improvements] || comment_data['improvements'] || []).map do |improvement|
|
601
|
+
{
|
602
|
+
'file_reference' => extract_file_reference(improvement),
|
603
|
+
'description' => clean_error_description(improvement)
|
418
604
|
}
|
419
605
|
end
|
420
606
|
|
607
|
+
# Process missing tests
|
608
|
+
missing_tests = extract_missing_tests(comment_data[:test_coverage] || comment_data['test_coverage'] || "")
|
609
|
+
|
610
|
+
# Process requirements
|
611
|
+
requirements = extract_requirements_status(comment_data[:requirements_evaluation] || comment_data['requirements_evaluation'] || "")
|
612
|
+
|
613
|
+
# Get git/hg info
|
614
|
+
git_info = extract_git_info
|
615
|
+
|
616
|
+
{
|
617
|
+
'implementation_summary' => comment_data[:implementation_summary] || comment_data['implementation_summary'] || "Code analysis completed",
|
618
|
+
'critical_errors' => critical_errors,
|
619
|
+
'important_errors' => important_errors,
|
620
|
+
'improvements' => improvements,
|
621
|
+
'missing_tests' => missing_tests,
|
622
|
+
'requirements' => requirements,
|
623
|
+
'test_coverage_summary' => comment_data[:test_coverage] || comment_data['test_coverage'] || "No specific test coverage analysis available",
|
624
|
+
'timestamp' => Time.now.strftime("%Y-%m-%d %H:%M UTC"),
|
625
|
+
'branch_name' => git_info[:branch],
|
626
|
+
'files_changed' => git_info[:files_changed],
|
627
|
+
'lines_added' => git_info[:lines_added],
|
628
|
+
'lines_removed' => git_info[:lines_removed],
|
629
|
+
'critical_errors_empty' => critical_errors.empty?,
|
630
|
+
'important_errors_empty' => important_errors.empty?,
|
631
|
+
'improvements_empty' => improvements.empty?,
|
632
|
+
'missing_tests_empty' => missing_tests.empty?
|
633
|
+
}
|
634
|
+
end
|
635
|
+
|
636
|
+
def classify_error_severity(error_text)
|
637
|
+
text = error_text.downcase
|
638
|
+
case text
|
639
|
+
when /security|sql injection|xss|csrf|vulnerability|exploit|attack/
|
640
|
+
'CRITICAL'
|
641
|
+
when /performance|n\+1|timeout|memory leak|slow query|bottleneck/
|
642
|
+
'IMPORTANT'
|
643
|
+
when /error|exception|bug|fail|crash|break/
|
644
|
+
'IMPORTANT'
|
645
|
+
when /style|convention|naming|format|indent|space/
|
646
|
+
'LOW'
|
647
|
+
else
|
648
|
+
'IMPORTANT'
|
649
|
+
end
|
650
|
+
end
|
651
|
+
|
652
|
+
def extract_file_reference(text)
|
653
|
+
# Parse various file reference formats
|
654
|
+
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)
|
655
|
+
file = match[1]
|
656
|
+
line = match[2] || match[3] || match[4]
|
657
|
+
line ? "*#{file}:#{line}*" : "*#{file}*"
|
658
|
+
else
|
659
|
+
"*General*"
|
660
|
+
end
|
661
|
+
end
|
662
|
+
|
663
|
+
def clean_error_description(text)
|
664
|
+
# Remove file references from description to avoid duplication
|
665
|
+
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
|
666
|
+
end
|
667
|
+
|
668
|
+
def extract_missing_tests(test_coverage_text)
|
669
|
+
# Extract test-related items from coverage analysis
|
670
|
+
missing_tests = []
|
671
|
+
|
672
|
+
# Look for common patterns indicating missing tests
|
673
|
+
test_coverage_text.scan(/(?:missing|need|add|require).*?test.*?(?:\.|$)/i) do |match|
|
674
|
+
missing_tests << { 'description' => match.strip }
|
675
|
+
end
|
676
|
+
|
677
|
+
# If no specific missing tests found, create generic ones based on coverage
|
678
|
+
if missing_tests.empty? && test_coverage_text.include?('%')
|
679
|
+
if coverage_match = test_coverage_text.match(/(\d+)%/)
|
680
|
+
coverage = coverage_match[1].to_i
|
681
|
+
if coverage < 80
|
682
|
+
missing_tests << { 'description' => "Increase test coverage from #{coverage}% to target 80%+" }
|
683
|
+
end
|
684
|
+
end
|
685
|
+
end
|
686
|
+
|
687
|
+
missing_tests
|
688
|
+
end
|
689
|
+
|
690
|
+
def extract_requirements_status(requirements_text)
|
691
|
+
requirements = []
|
692
|
+
|
693
|
+
# Split by lines and process each line
|
694
|
+
requirements_text.split("\n").each do |line|
|
695
|
+
line = line.strip
|
696
|
+
next if line.empty?
|
697
|
+
|
698
|
+
# Parse requirements with status indicators - order matters for regex matching
|
699
|
+
if match = line.match(/(✅|⚠️|❌|🔍)?\s*(PARTIALLY\s+IMPLEMENTED|NOT\s+IMPLEMENTED|IMPLEMENTED|UNCLEAR)?:?\s*(.+)/i)
|
700
|
+
status_emoji, status_text, description = match.captures
|
701
|
+
status = case
|
702
|
+
when status_text&.include?('PARTIALLY')
|
703
|
+
'PARTIALLY_IMPLEMENTED'
|
704
|
+
when status_text&.include?('NOT')
|
705
|
+
'NOT_IMPLEMENTED'
|
706
|
+
when status_emoji == '✅' || (status_text&.include?('IMPLEMENTED') && !status_text&.include?('NOT') && !status_text&.include?('PARTIALLY'))
|
707
|
+
'IMPLEMENTED'
|
708
|
+
when status_emoji == '⚠️'
|
709
|
+
'PARTIALLY_IMPLEMENTED'
|
710
|
+
when status_emoji == '❌'
|
711
|
+
'NOT_IMPLEMENTED'
|
712
|
+
else
|
713
|
+
'UNCLEAR'
|
714
|
+
end
|
715
|
+
|
716
|
+
requirements << {
|
717
|
+
'status' => status,
|
718
|
+
'description' => description.strip,
|
719
|
+
'status_icon' => status_emoji || (status == 'IMPLEMENTED' ? '✅' : status == 'PARTIALLY_IMPLEMENTED' ? '⚠️' : status == 'NOT_IMPLEMENTED' ? '❌' : '🔍')
|
720
|
+
}
|
721
|
+
end
|
722
|
+
end
|
723
|
+
|
724
|
+
requirements
|
725
|
+
end
|
726
|
+
|
727
|
+
def extract_git_info
|
728
|
+
begin
|
729
|
+
if File.exist?('.git')
|
730
|
+
branch = `git branch --show-current 2>/dev/null`.strip
|
731
|
+
branch = 'unknown' if branch.empty?
|
732
|
+
|
733
|
+
# Get diff stats
|
734
|
+
diff_stats = `git diff --stat HEAD~1 2>/dev/null`.strip
|
735
|
+
files_changed = diff_stats.scan(/(\d+) files? changed/).flatten.first || "0"
|
736
|
+
lines_added = diff_stats.scan(/(\d+) insertions?/).flatten.first || "0"
|
737
|
+
lines_removed = diff_stats.scan(/(\d+) deletions?/).flatten.first || "0"
|
738
|
+
elsif File.exist?('.hg')
|
739
|
+
branch = `hg branch 2>/dev/null`.strip
|
740
|
+
branch = 'default' if branch.empty?
|
741
|
+
|
742
|
+
# Get diff stats for hg
|
743
|
+
diff_stats = `hg diff --stat 2>/dev/null`.strip
|
744
|
+
files_changed = diff_stats.lines.count.to_s
|
745
|
+
lines_added = "0" # hg diff --stat doesn't show +/- easily
|
746
|
+
lines_removed = "0"
|
747
|
+
else
|
748
|
+
branch = 'unknown'
|
749
|
+
files_changed = "0"
|
750
|
+
lines_added = "0"
|
751
|
+
lines_removed = "0"
|
752
|
+
end
|
753
|
+
rescue
|
754
|
+
branch = 'unknown'
|
755
|
+
files_changed = "0"
|
756
|
+
lines_added = "0"
|
757
|
+
lines_removed = "0"
|
758
|
+
end
|
759
|
+
|
760
|
+
{
|
761
|
+
branch: branch,
|
762
|
+
files_changed: files_changed,
|
763
|
+
lines_added: lines_added,
|
764
|
+
lines_removed: lines_removed
|
765
|
+
}
|
766
|
+
end
|
767
|
+
|
768
|
+
def resolve_template_path(template_key, config)
|
769
|
+
user_path = config.dig('templates', template_key) if config.is_a?(Hash)
|
770
|
+
return user_path if user_path && File.exist?(user_path)
|
771
|
+
|
772
|
+
File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
|
773
|
+
end
|
774
|
+
|
775
|
+
def get_config(reconfigure: false, advanced_flow: false)
|
776
|
+
# Return the config that was passed during initialization
|
777
|
+
# This is used for template resolution and other configuration needs
|
778
|
+
@config
|
779
|
+
end
|
780
|
+
|
781
|
+
def convert_markdown_to_adf(markdown_text)
|
782
|
+
content = []
|
783
|
+
lines = markdown_text.split("\n")
|
784
|
+
current_paragraph = []
|
785
|
+
current_expand = nil
|
786
|
+
expand_content = []
|
787
|
+
|
788
|
+
lines.each do |line|
|
789
|
+
case line
|
790
|
+
when /^\*(.+)\*$/ # Bold headers like *N2B Code Analysis Report*
|
791
|
+
# Flush current paragraph
|
792
|
+
if current_paragraph.any?
|
793
|
+
content << create_paragraph(current_paragraph.join(" "))
|
794
|
+
current_paragraph = []
|
795
|
+
end
|
796
|
+
|
797
|
+
content << {
|
798
|
+
"type" => "heading",
|
799
|
+
"attrs" => { "level" => 2 },
|
800
|
+
"content" => [
|
801
|
+
{
|
802
|
+
"type" => "text",
|
803
|
+
"text" => $1.strip,
|
804
|
+
"marks" => [{ "type" => "strong" }]
|
805
|
+
}
|
806
|
+
]
|
807
|
+
}
|
808
|
+
when /^=+$/ # Separator lines
|
809
|
+
# Skip separator lines
|
810
|
+
when /^\{expand:(.+)\}$/ # Jira expand start
|
811
|
+
# Flush current paragraph
|
812
|
+
if current_paragraph.any?
|
813
|
+
content << create_paragraph(current_paragraph.join(" "))
|
814
|
+
current_paragraph = []
|
815
|
+
end
|
816
|
+
|
817
|
+
# Start collecting expand content
|
818
|
+
expand_title = $1.strip
|
819
|
+
current_expand = {
|
820
|
+
"type" => "expand",
|
821
|
+
"attrs" => { "title" => expand_title },
|
822
|
+
"content" => []
|
823
|
+
}
|
824
|
+
expand_content = []
|
825
|
+
when /^\{expand\}$/ # Jira expand end
|
826
|
+
# End of expand section - add collected content
|
827
|
+
if current_expand
|
828
|
+
current_expand["content"] = expand_content
|
829
|
+
content << current_expand if expand_content.any? # Only add if has content
|
830
|
+
current_expand = nil
|
831
|
+
expand_content = []
|
832
|
+
end
|
833
|
+
when /^☐\s+(.+)$/ # Unchecked checkbox
|
834
|
+
# Flush current paragraph
|
835
|
+
if current_paragraph.any?
|
836
|
+
paragraph = create_paragraph(current_paragraph.join(" "))
|
837
|
+
if current_expand
|
838
|
+
expand_content << paragraph
|
839
|
+
else
|
840
|
+
content << paragraph
|
841
|
+
end
|
842
|
+
current_paragraph = []
|
843
|
+
end
|
844
|
+
|
845
|
+
# Convert checkbox to simple paragraph (no bullet points)
|
846
|
+
checkbox_paragraph = create_paragraph("☐ " + $1.strip)
|
847
|
+
|
848
|
+
if current_expand
|
849
|
+
expand_content << checkbox_paragraph
|
850
|
+
else
|
851
|
+
content << checkbox_paragraph
|
852
|
+
end
|
853
|
+
when /^☑\s+(.+)$/ # Checked checkbox
|
854
|
+
# Flush current paragraph
|
855
|
+
if current_paragraph.any?
|
856
|
+
paragraph = create_paragraph(current_paragraph.join(" "))
|
857
|
+
if current_expand
|
858
|
+
expand_content << paragraph
|
859
|
+
else
|
860
|
+
content << paragraph
|
861
|
+
end
|
862
|
+
current_paragraph = []
|
863
|
+
end
|
864
|
+
|
865
|
+
# Convert checkbox to simple paragraph (no bullet points)
|
866
|
+
checkbox_paragraph = create_paragraph("☑ " + $1.strip)
|
867
|
+
|
868
|
+
if current_expand
|
869
|
+
expand_content << checkbox_paragraph
|
870
|
+
else
|
871
|
+
content << checkbox_paragraph
|
872
|
+
end
|
873
|
+
when /^---$/ # Horizontal rule
|
874
|
+
# Flush current paragraph
|
875
|
+
if current_paragraph.any?
|
876
|
+
paragraph = create_paragraph(current_paragraph.join(" "))
|
877
|
+
if current_expand
|
878
|
+
expand_content << paragraph
|
879
|
+
else
|
880
|
+
content << paragraph
|
881
|
+
end
|
882
|
+
current_paragraph = []
|
883
|
+
end
|
884
|
+
|
885
|
+
rule = { "type" => "rule" }
|
886
|
+
if current_expand
|
887
|
+
expand_content << rule
|
888
|
+
else
|
889
|
+
content << rule
|
890
|
+
end
|
891
|
+
when "" # Empty line
|
892
|
+
# Flush current paragraph
|
893
|
+
if current_paragraph.any?
|
894
|
+
paragraph = create_paragraph(current_paragraph.join(" "))
|
895
|
+
if current_expand
|
896
|
+
expand_content << paragraph
|
897
|
+
else
|
898
|
+
content << paragraph
|
899
|
+
end
|
900
|
+
current_paragraph = []
|
901
|
+
end
|
902
|
+
else # Regular text
|
903
|
+
# Skip empty or whitespace-only content
|
904
|
+
unless line.strip.empty? || line.strip == "{}"
|
905
|
+
current_paragraph << line
|
906
|
+
end
|
907
|
+
end
|
908
|
+
end
|
909
|
+
|
910
|
+
# Flush any remaining paragraph
|
911
|
+
if current_paragraph.any?
|
912
|
+
paragraph = create_paragraph(current_paragraph.join(" "))
|
913
|
+
if current_expand
|
914
|
+
expand_content << paragraph
|
915
|
+
else
|
916
|
+
content << paragraph
|
917
|
+
end
|
918
|
+
end
|
919
|
+
|
920
|
+
# Close any remaining expand section
|
921
|
+
if current_expand && expand_content.any?
|
922
|
+
current_expand["content"] = expand_content
|
923
|
+
content << current_expand
|
924
|
+
end
|
925
|
+
|
926
|
+
# Ensure we have at least one content element
|
927
|
+
if content.empty?
|
928
|
+
content << create_paragraph("Analysis completed.")
|
929
|
+
end
|
930
|
+
|
931
|
+
{
|
932
|
+
"type" => "doc",
|
933
|
+
"version" => 1,
|
934
|
+
"content" => content
|
935
|
+
}
|
936
|
+
end
|
937
|
+
|
938
|
+
def create_paragraph(text)
|
939
|
+
{
|
940
|
+
"type" => "paragraph",
|
941
|
+
"content" => [
|
942
|
+
{
|
943
|
+
"type" => "text",
|
944
|
+
"text" => text
|
945
|
+
}
|
946
|
+
]
|
947
|
+
}
|
948
|
+
end
|
949
|
+
|
950
|
+
private
|
951
|
+
|
952
|
+
def debug_mode?
|
953
|
+
ENV['N2B_DEBUG'] == 'true'
|
954
|
+
end
|
955
|
+
|
956
|
+
def format_comment_as_adf(comment_data)
|
957
|
+
# If comment_data is a string (from template), convert to simple ADF
|
958
|
+
if comment_data.is_a?(String)
|
959
|
+
return convert_markdown_to_adf(comment_data)
|
960
|
+
end
|
961
|
+
|
421
962
|
# If comment_data is structured (new format), build proper ADF
|
422
963
|
content = []
|
423
964
|
|
@@ -736,8 +1277,26 @@ module N2B
|
|
736
1277
|
request['Content-Type'] = 'application/json'
|
737
1278
|
request['Accept'] = 'application/json'
|
738
1279
|
|
1280
|
+
if debug_mode?
|
1281
|
+
puts "🔍 DEBUG: Making #{method} request to: #{full_url}"
|
1282
|
+
puts "🔍 DEBUG: Request headers: Content-Type=#{request['Content-Type']}, Accept=#{request['Accept']}"
|
1283
|
+
if body
|
1284
|
+
puts "🔍 DEBUG: Request body size: #{body.to_json.length} bytes"
|
1285
|
+
puts "🔍 DEBUG: Request body preview: #{body.to_json[0..500]}#{'...' if body.to_json.length > 500}"
|
1286
|
+
end
|
1287
|
+
end
|
1288
|
+
|
739
1289
|
response = http.request(request)
|
740
1290
|
|
1291
|
+
if debug_mode?
|
1292
|
+
puts "🔍 DEBUG: Response code: #{response.code} #{response.message}"
|
1293
|
+
if response.body && !response.body.empty?
|
1294
|
+
# Force UTF-8 encoding to handle character encoding issues
|
1295
|
+
response_body = response.body.force_encoding('UTF-8')
|
1296
|
+
puts "🔍 DEBUG: Response body: #{response_body}"
|
1297
|
+
end
|
1298
|
+
end
|
1299
|
+
|
741
1300
|
unless response.is_a?(Net::HTTPSuccess)
|
742
1301
|
error_message = "Jira API Error: #{response.code} #{response.message}"
|
743
1302
|
error_message += " - #{response.body}" if response.body && !response.body.empty?
|
data/lib/n2b/llm/claude.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
require_relative '../model_config'
|
2
2
|
|
3
|
-
module
|
3
|
+
module N2B
|
4
4
|
module Llm
|
5
5
|
class Claude
|
6
6
|
API_URI = URI.parse('https://api.anthropic.com/v1/messages')
|
@@ -32,7 +32,7 @@ module N2M
|
|
32
32
|
"messages" => [
|
33
33
|
{
|
34
34
|
"role" => "user",
|
35
|
-
"content" => content
|
35
|
+
"content" => content
|
36
36
|
}
|
37
37
|
]
|
38
38
|
})
|
@@ -44,29 +44,16 @@ module N2M
|
|
44
44
|
if response.code != '200'
|
45
45
|
raise N2B::LlmApiError.new("LLM API Error: #{response.code} #{response.message} - #{response.body}")
|
46
46
|
end
|
47
|
-
answer = JSON.parse(response.body)['content'].first['text']
|
48
|
-
begin
|
49
|
-
# The llm_response.json file is likely for debugging and can be kept or removed.
|
50
|
-
# For this refactoring, I'll keep it as it doesn't affect the error handling logic.
|
51
|
-
File.open('llm_response.json', 'w') do |f|
|
52
|
-
f.write(answer)
|
53
|
-
end
|
54
|
-
# remove everything before the first { and after the last }
|
55
|
-
|
47
|
+
answer = JSON.parse(response.body)['content'].first['text']
|
48
|
+
begin
|
56
49
|
answer = answer.sub(/.*?\{(.*)\}.*/m, '{\1}') unless answer.start_with?('{')
|
57
|
-
# gsub all \n with \\n that are inside "
|
58
|
-
#
|
59
50
|
answer.gsub!(/"([^"]*)"/) { |match| match.gsub(/\n/, "\\n") }
|
60
|
-
# The llm_response.json file is likely for debugging and can be kept or removed.
|
61
|
-
File.open('llm_response.json', 'w') do |f|
|
62
|
-
f.write(answer)
|
63
|
-
end
|
64
51
|
answer = JSON.parse(answer)
|
65
52
|
rescue JSON::ParserError
|
66
53
|
# This specific JSON parsing error is about the LLM's *response content*, not an API error.
|
67
54
|
# It should probably be handled differently, but the subtask is about LlmApiError.
|
68
55
|
# For now, keeping existing behavior for this part.
|
69
|
-
puts "Error parsing JSON from LLM response: #{answer}"
|
56
|
+
puts "Error parsing JSON from LLM response: #{answer}"
|
70
57
|
answer = { 'explanation' => answer} # Default fallback
|
71
58
|
end
|
72
59
|
answer
|
@@ -83,10 +70,10 @@ module N2M
|
|
83
70
|
|
84
71
|
request.body = JSON.dump({
|
85
72
|
"model" => get_model_name,
|
86
|
-
"max_tokens" => @config['max_tokens'] || 1024,
|
73
|
+
"max_tokens" => @config['max_tokens'] || 1024,
|
87
74
|
"messages" => [
|
88
75
|
{
|
89
|
-
"role" => "user",
|
76
|
+
"role" => "user",
|
90
77
|
"content" => prompt_content
|
91
78
|
}
|
92
79
|
]
|