n2b 0.5.1 → 0.7.1

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.
data/lib/n2b/cli.rb CHANGED
@@ -316,32 +316,9 @@ module N2B
316
316
  end
317
317
  end
318
318
 
319
- def build_diff_analysis_prompt(diff_output, user_prompt_addition = "", requirements_content = nil)
320
- default_system_prompt = <<-SYSTEM_PROMPT.strip
321
- You are a senior software developer reviewing a code diff.
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
319
+ def build_diff_analysis_prompt(diff_output, user_prompt_addition = "", requirements_content = nil, config = {})
320
+ default_system_prompt_path = resolve_template_path('diff_system_prompt', config)
321
+ default_system_prompt = File.read(default_system_prompt_path).strip
345
322
 
346
323
  user_instructions_section = ""
347
324
  unless user_prompt_addition.to_s.strip.empty?
@@ -382,35 +359,8 @@ REQUIREMENTS_BLOCK
382
359
  end
383
360
  end
384
361
 
385
- json_instruction = <<-JSON_INSTRUCTION.strip
386
- CRITICAL: Return ONLY a valid JSON object.
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
362
+ json_instruction_path = resolve_template_path('diff_json_instruction', config)
363
+ json_instruction = File.read(json_instruction_path).strip
414
364
 
415
365
  full_prompt = [
416
366
  default_system_prompt,
@@ -426,7 +376,7 @@ JSON_INSTRUCTION
426
376
  end
427
377
 
428
378
  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)
379
+ prompt = build_diff_analysis_prompt(diff_output, user_prompt_addition, requirements_content, config)
430
380
  analysis_json_str = call_llm_for_diff_analysis(prompt, config)
431
381
 
432
382
  begin
@@ -607,7 +557,8 @@ JSON_INSTRUCTION
607
557
  raise N2B::Error, "Unsupported LLM service: #{llm_service_name}"
608
558
  end
609
559
 
610
- response_json_str = llm.analyze_code_diff(prompt) # Call the new dedicated method
560
+ puts "🔍 AI is analyzing your code diff..."
561
+ response_json_str = analyze_diff_with_spinner(llm, prompt)
611
562
  response_json_str
612
563
  rescue N2B::LlmApiError => e # This catches errors from analyze_code_diff
613
564
  puts "Error communicating with the LLM: #{e.message}"
@@ -667,23 +618,37 @@ JSON_INSTRUCTION
667
618
  EOF
668
619
 
669
620
 
670
- response_json_str = llm.make_request(content)
671
-
672
- append_to_llm_history_file("#{prompt}\n#{response_json_str}") # Storing the raw JSON string
673
- # The original call_llm was expected to return a hash after JSON.parse,
674
- # but it was actually returning the string. Let's assume it should return a parsed Hash.
675
- # However, the calling method `process_natural_language_command` accesses it like `bash_commands['commands']`
676
- # which implies it expects a Hash. Let's ensure call_llm returns a Hash.
677
- # This internal JSON parsing is for the *content* of a successful LLM response.
678
- # The LlmApiError for network/auth issues should be caught before this.
621
+ puts "🤖 AI is generating commands..."
622
+ response = make_request_with_spinner(llm, content)
623
+
624
+ # Handle both Hash (from JSON mode providers) and String responses
625
+ if response.is_a?(Hash)
626
+ # Already parsed by the LLM provider
627
+ parsed_response = response
628
+ response_str = response.to_json # For history logging
629
+ else
630
+ # String response that needs parsing
631
+ response_str = response
679
632
  begin
680
- parsed_response = JSON.parse(response_json_str)
681
- parsed_response
633
+ parsed_response = JSON.parse(response_str)
682
634
  rescue JSON::ParserError => e
683
- puts "Error parsing LLM response JSON for command generation: #{e.message}"
684
- # This is a fallback for when the LLM response *content* is not valid JSON.
685
- { "commands" => ["echo 'Error: LLM returned invalid JSON content.'"], "explanation" => "The response from the language model was not valid JSON." }
635
+ puts "⚠️ Invalid JSON detected, attempting automatic repair..."
636
+ repaired_response = attempt_json_repair_for_commands(response_str, llm)
637
+
638
+ if repaired_response
639
+ puts "✅ JSON repair successful!"
640
+ parsed_response = repaired_response
641
+ else
642
+ puts "❌ JSON repair failed"
643
+ puts "Error parsing LLM response JSON for command generation: #{e.message}"
644
+ # This is a fallback for when the LLM response *content* is not valid JSON.
645
+ parsed_response = { "commands" => ["echo 'Error: LLM returned invalid JSON content.'"], "explanation" => "The response from the language model was not valid JSON." }
646
+ end
686
647
  end
648
+ end
649
+
650
+ append_to_llm_history_file("#{prompt}\n#{response_str}") # Storing the response for history
651
+ parsed_response
687
652
  rescue N2B::LlmApiError => e
688
653
  puts "Error communicating with the LLM: #{e.message}"
689
654
 
@@ -784,7 +749,102 @@ JSON_INSTRUCTION
784
749
  end
785
750
  system("history -r") # Attempt to reload history in current session
786
751
  end
787
-
752
+
753
+ def resolve_template_path(template_key, config)
754
+ user_path = config.dig('templates', template_key) if config.is_a?(Hash)
755
+ return user_path if user_path && File.exist?(user_path)
756
+
757
+ File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
758
+ end
759
+
760
+ def make_request_with_spinner(llm, content)
761
+ spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
762
+ spinner_thread = Thread.new do
763
+ i = 0
764
+ while true
765
+ print "\r⠿ #{spinner_chars[i % spinner_chars.length]} Processing..."
766
+ $stdout.flush
767
+ sleep(0.1)
768
+ i += 1
769
+ end
770
+ end
771
+
772
+ begin
773
+ result = llm.make_request(content)
774
+ spinner_thread.kill
775
+ print "\r#{' ' * 25}\r" # Clear the spinner line
776
+ puts "✅ Commands generated!"
777
+ result
778
+ rescue => e
779
+ spinner_thread.kill
780
+ print "\r#{' ' * 25}\r" # Clear the spinner line
781
+ raise e
782
+ end
783
+ end
784
+
785
+ def analyze_diff_with_spinner(llm, prompt)
786
+ spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
787
+ spinner_thread = Thread.new do
788
+ i = 0
789
+ while true
790
+ print "\r🔍 #{spinner_chars[i % spinner_chars.length]} Analyzing diff..."
791
+ $stdout.flush
792
+ sleep(0.1)
793
+ i += 1
794
+ end
795
+ end
796
+
797
+ begin
798
+ result = llm.analyze_code_diff(prompt)
799
+ spinner_thread.kill
800
+ print "\r#{' ' * 30}\r" # Clear the spinner line
801
+ puts "✅ Diff analysis complete!"
802
+ result
803
+ rescue => e
804
+ spinner_thread.kill
805
+ print "\r#{' ' * 30}\r" # Clear the spinner line
806
+ raise e
807
+ end
808
+ end
809
+
810
+ def attempt_json_repair_for_commands(malformed_response, llm)
811
+ repair_prompt = <<~PROMPT
812
+ 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:
813
+
814
+ Original response:
815
+ #{malformed_response}
816
+
817
+ Requirements:
818
+ - Must be valid JSON
819
+ - Must have "commands" key with array of command strings
820
+ - Must have "explanation" key with explanation text
821
+ - Return ONLY the JSON, no other text
822
+
823
+ Fixed JSON:
824
+ PROMPT
825
+
826
+ begin
827
+ puts "🔧 Asking AI to fix the JSON..."
828
+ repaired_json_str = llm.make_request(repair_prompt)
829
+
830
+ # Handle both Hash and String responses
831
+ if repaired_json_str.is_a?(Hash)
832
+ repaired_response = repaired_json_str
833
+ else
834
+ repaired_response = JSON.parse(repaired_json_str)
835
+ end
836
+
837
+ # Validate the repaired response structure
838
+ if repaired_response.is_a?(Hash) && repaired_response.key?('commands') && repaired_response.key?('explanation')
839
+ return repaired_response
840
+ else
841
+ return nil
842
+ end
843
+ rescue JSON::ParserError, StandardError
844
+ return nil
845
+ end
846
+ end
847
+
788
848
 
789
849
  def parse_options
790
850
  options = {
@@ -834,6 +894,11 @@ JSON_INSTRUCTION
834
894
  exit
835
895
  end
836
896
 
897
+ opts.on('-v', '--version', 'Show version') do
898
+ puts "n2b version #{N2B::VERSION}"
899
+ exit
900
+ end
901
+
837
902
  opts.on('-c', '--config', 'Configure the API key and model') do
838
903
  options[:config] = true
839
904
  end
@@ -67,9 +67,12 @@ module N2B
67
67
 
68
68
  puts "Updating Jira ticket #{ticket_key} with analysis comment..."
69
69
 
70
+ # Generate comment using template system
71
+ template_comment = generate_templated_comment(comment)
72
+
70
73
  # Prepare the comment body in Jira's Atlassian Document Format (ADF)
71
74
  comment_body = {
72
- "body" => format_comment_as_adf(comment)
75
+ "body" => format_comment_as_adf(template_comment)
73
76
  }
74
77
 
75
78
  # Make the API call to add a comment
@@ -396,26 +399,383 @@ module N2B
396
399
  end
397
400
  end
398
401
 
399
- private
402
+ def generate_templated_comment(comment_data)
403
+ # Prepare template data from the analysis results
404
+ template_data = prepare_template_data(comment_data)
400
405
 
401
- def format_comment_as_adf(comment_data)
402
- # If comment_data is a string (legacy), convert to simple ADF
406
+ # Load and render template
407
+ config = get_config(reconfigure: false, advanced_flow: false)
408
+ template_path = resolve_template_path('jira_comment', config)
409
+ template_content = File.read(template_path)
410
+
411
+ engine = N2B::TemplateEngine.new(template_content, template_data)
412
+ engine.render
413
+ end
414
+
415
+ def prepare_template_data(comment_data)
416
+ # Handle both string and hash inputs
403
417
  if comment_data.is_a?(String)
418
+ # For simple string comments, create a basic template data structure
419
+ git_info = extract_git_info
404
420
  return {
405
- "type" => "doc",
406
- "version" => 1,
407
- "content" => [
408
- {
409
- "type" => "paragraph",
410
- "content" => [
411
- {
412
- "type" => "text",
413
- "text" => comment_data
414
- }
415
- ]
416
- }
417
- ]
421
+ 'implementation_summary' => comment_data,
422
+ 'critical_errors' => [],
423
+ 'important_errors' => [],
424
+ 'improvements' => [],
425
+ 'missing_tests' => [],
426
+ 'requirements' => [],
427
+ 'test_coverage_summary' => "No specific test coverage analysis available",
428
+ 'timestamp' => Time.now.strftime("%Y-%m-%d %H:%M UTC"),
429
+ 'branch_name' => git_info[:branch],
430
+ 'files_changed' => git_info[:files_changed],
431
+ 'lines_added' => git_info[:lines_added],
432
+ 'lines_removed' => git_info[:lines_removed],
433
+ 'critical_errors_empty' => true,
434
+ 'important_errors_empty' => true,
435
+ 'improvements_empty' => true,
436
+ 'missing_tests_empty' => true
437
+ }
438
+ end
439
+
440
+ # Handle hash input (structured analysis data)
441
+ # Extract and classify errors by severity
442
+ errors = comment_data[:issues] || comment_data['issues'] || []
443
+ critical_errors = []
444
+ important_errors = []
445
+ low_errors = []
446
+
447
+ errors.each do |error|
448
+ severity = classify_error_severity(error)
449
+ file_ref = extract_file_reference(error)
450
+
451
+ error_item = {
452
+ 'file_reference' => file_ref,
453
+ 'description' => clean_error_description(error),
454
+ 'severity' => severity
418
455
  }
456
+
457
+ case severity
458
+ when 'CRITICAL'
459
+ critical_errors << error_item
460
+ when 'IMPORTANT'
461
+ important_errors << error_item
462
+ else
463
+ low_errors << error_item
464
+ end
465
+ end
466
+
467
+ # Process improvements
468
+ improvements = (comment_data[:improvements] || comment_data['improvements'] || []).map do |improvement|
469
+ {
470
+ 'file_reference' => extract_file_reference(improvement),
471
+ 'description' => clean_error_description(improvement)
472
+ }
473
+ end
474
+
475
+ # Process missing tests
476
+ missing_tests = extract_missing_tests(comment_data[:test_coverage] || comment_data['test_coverage'] || "")
477
+
478
+ # Process requirements
479
+ requirements = extract_requirements_status(comment_data[:requirements_evaluation] || comment_data['requirements_evaluation'] || "")
480
+
481
+ # Get git/hg info
482
+ git_info = extract_git_info
483
+
484
+ {
485
+ 'implementation_summary' => comment_data[:implementation_summary] || comment_data['implementation_summary'] || "Code analysis completed",
486
+ 'critical_errors' => critical_errors,
487
+ 'important_errors' => important_errors,
488
+ 'improvements' => improvements,
489
+ 'missing_tests' => missing_tests,
490
+ 'requirements' => requirements,
491
+ 'test_coverage_summary' => comment_data[:test_coverage] || comment_data['test_coverage'] || "No specific test coverage analysis available",
492
+ 'timestamp' => Time.now.strftime("%Y-%m-%d %H:%M UTC"),
493
+ 'branch_name' => git_info[:branch],
494
+ 'files_changed' => git_info[:files_changed],
495
+ 'lines_added' => git_info[:lines_added],
496
+ 'lines_removed' => git_info[:lines_removed],
497
+ 'critical_errors_empty' => critical_errors.empty?,
498
+ 'important_errors_empty' => important_errors.empty?,
499
+ 'improvements_empty' => improvements.empty?,
500
+ 'missing_tests_empty' => missing_tests.empty?
501
+ }
502
+ end
503
+
504
+ def classify_error_severity(error_text)
505
+ text = error_text.downcase
506
+ case text
507
+ when /security|sql injection|xss|csrf|vulnerability|exploit|attack/
508
+ 'CRITICAL'
509
+ when /performance|n\+1|timeout|memory leak|slow query|bottleneck/
510
+ 'IMPORTANT'
511
+ when /error|exception|bug|fail|crash|break/
512
+ 'IMPORTANT'
513
+ when /style|convention|naming|format|indent|space/
514
+ 'LOW'
515
+ else
516
+ 'IMPORTANT'
517
+ end
518
+ end
519
+
520
+ def extract_file_reference(text)
521
+ # Parse various file reference formats
522
+ 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)
523
+ file = match[1]
524
+ line = match[2] || match[3] || match[4]
525
+ line ? "*#{file}:#{line}*" : "*#{file}*"
526
+ else
527
+ "*General*"
528
+ end
529
+ end
530
+
531
+ def clean_error_description(text)
532
+ # Remove file references from description to avoid duplication
533
+ 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
534
+ end
535
+
536
+ def extract_missing_tests(test_coverage_text)
537
+ # Extract test-related items from coverage analysis
538
+ missing_tests = []
539
+
540
+ # Look for common patterns indicating missing tests
541
+ test_coverage_text.scan(/(?:missing|need|add|require).*?test.*?(?:\.|$)/i) do |match|
542
+ missing_tests << { 'description' => match.strip }
543
+ end
544
+
545
+ # If no specific missing tests found, create generic ones based on coverage
546
+ if missing_tests.empty? && test_coverage_text.include?('%')
547
+ if coverage_match = test_coverage_text.match(/(\d+)%/)
548
+ coverage = coverage_match[1].to_i
549
+ if coverage < 80
550
+ missing_tests << { 'description' => "Increase test coverage from #{coverage}% to target 80%+" }
551
+ end
552
+ end
553
+ end
554
+
555
+ missing_tests
556
+ end
557
+
558
+ def extract_requirements_status(requirements_text)
559
+ requirements = []
560
+
561
+ # Split by lines and process each line
562
+ requirements_text.split("\n").each do |line|
563
+ line = line.strip
564
+ next if line.empty?
565
+
566
+ # Parse requirements with status indicators - order matters for regex matching
567
+ if match = line.match(/(✅|⚠️|❌|🔍)?\s*(PARTIALLY\s+IMPLEMENTED|NOT\s+IMPLEMENTED|IMPLEMENTED|UNCLEAR)?:?\s*(.+)/i)
568
+ status_emoji, status_text, description = match.captures
569
+ status = case
570
+ when status_text&.include?('PARTIALLY')
571
+ 'PARTIALLY_IMPLEMENTED'
572
+ when status_text&.include?('NOT')
573
+ 'NOT_IMPLEMENTED'
574
+ when status_emoji == '✅' || (status_text&.include?('IMPLEMENTED') && !status_text&.include?('NOT') && !status_text&.include?('PARTIALLY'))
575
+ 'IMPLEMENTED'
576
+ when status_emoji == '⚠️'
577
+ 'PARTIALLY_IMPLEMENTED'
578
+ when status_emoji == '❌'
579
+ 'NOT_IMPLEMENTED'
580
+ else
581
+ 'UNCLEAR'
582
+ end
583
+
584
+ requirements << {
585
+ 'status' => status,
586
+ 'description' => description.strip,
587
+ 'status_icon' => status_emoji || (status == 'IMPLEMENTED' ? '✅' : status == 'PARTIALLY_IMPLEMENTED' ? '⚠️' : status == 'NOT_IMPLEMENTED' ? '❌' : '🔍')
588
+ }
589
+ end
590
+ end
591
+
592
+ requirements
593
+ end
594
+
595
+ def extract_git_info
596
+ begin
597
+ if File.exist?('.git')
598
+ branch = `git branch --show-current 2>/dev/null`.strip
599
+ branch = 'unknown' if branch.empty?
600
+
601
+ # Get diff stats
602
+ diff_stats = `git diff --stat HEAD~1 2>/dev/null`.strip
603
+ files_changed = diff_stats.scan(/(\d+) files? changed/).flatten.first || "0"
604
+ lines_added = diff_stats.scan(/(\d+) insertions?/).flatten.first || "0"
605
+ lines_removed = diff_stats.scan(/(\d+) deletions?/).flatten.first || "0"
606
+ elsif File.exist?('.hg')
607
+ branch = `hg branch 2>/dev/null`.strip
608
+ branch = 'default' if branch.empty?
609
+
610
+ # Get diff stats for hg
611
+ diff_stats = `hg diff --stat 2>/dev/null`.strip
612
+ files_changed = diff_stats.lines.count.to_s
613
+ lines_added = "0" # hg diff --stat doesn't show +/- easily
614
+ lines_removed = "0"
615
+ else
616
+ branch = 'unknown'
617
+ files_changed = "0"
618
+ lines_added = "0"
619
+ lines_removed = "0"
620
+ end
621
+ rescue
622
+ branch = 'unknown'
623
+ files_changed = "0"
624
+ lines_added = "0"
625
+ lines_removed = "0"
626
+ end
627
+
628
+ {
629
+ branch: branch,
630
+ files_changed: files_changed,
631
+ lines_added: lines_added,
632
+ lines_removed: lines_removed
633
+ }
634
+ end
635
+
636
+ def resolve_template_path(template_key, config)
637
+ user_path = config.dig('templates', template_key) if config.is_a?(Hash)
638
+ return user_path if user_path && File.exist?(user_path)
639
+
640
+ File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
641
+ end
642
+
643
+ def get_config(reconfigure: false, advanced_flow: false)
644
+ # This should match the config loading from the main CLI
645
+ # For now, return empty hash - will be enhanced when config system is unified
646
+ {}
647
+ end
648
+
649
+ def convert_markdown_to_adf(markdown_text)
650
+ content = []
651
+ lines = markdown_text.split("\n")
652
+ current_paragraph = []
653
+
654
+ lines.each do |line|
655
+ case line
656
+ when /^\*(.+)\*$/ # Bold headers like *N2B Code Analysis Report*
657
+ # Flush current paragraph
658
+ if current_paragraph.any?
659
+ content << create_paragraph(current_paragraph.join(" "))
660
+ current_paragraph = []
661
+ end
662
+
663
+ content << {
664
+ "type" => "heading",
665
+ "attrs" => { "level" => 2 },
666
+ "content" => [
667
+ {
668
+ "type" => "text",
669
+ "text" => $1.strip,
670
+ "marks" => [{ "type" => "strong" }]
671
+ }
672
+ ]
673
+ }
674
+ when /^=+$/ # Separator lines
675
+ # Skip separator lines
676
+ when /^\{expand:(.+)\}$/ # Jira expand start
677
+ # Flush current paragraph
678
+ if current_paragraph.any?
679
+ content << create_paragraph(current_paragraph.join(" "))
680
+ current_paragraph = []
681
+ end
682
+
683
+ # Create expand section
684
+ expand_title = $1.strip
685
+ content << {
686
+ "type" => "expand",
687
+ "attrs" => { "title" => expand_title },
688
+ "content" => []
689
+ }
690
+ when /^\{expand\}$/ # Jira expand end
691
+ # End of expand section - handled by the expand start
692
+ when /^☐\s+(.+)$/ # Unchecked checkbox
693
+ # Flush current paragraph
694
+ if current_paragraph.any?
695
+ content << create_paragraph(current_paragraph.join(" "))
696
+ current_paragraph = []
697
+ end
698
+
699
+ content << {
700
+ "type" => "taskList",
701
+ "content" => [
702
+ {
703
+ "type" => "taskItem",
704
+ "attrs" => { "state" => "TODO" },
705
+ "content" => [
706
+ create_paragraph($1.strip)
707
+ ]
708
+ }
709
+ ]
710
+ }
711
+ when /^☑\s+(.+)$/ # Checked checkbox
712
+ # Flush current paragraph
713
+ if current_paragraph.any?
714
+ content << create_paragraph(current_paragraph.join(" "))
715
+ current_paragraph = []
716
+ end
717
+
718
+ content << {
719
+ "type" => "taskList",
720
+ "content" => [
721
+ {
722
+ "type" => "taskItem",
723
+ "attrs" => { "state" => "DONE" },
724
+ "content" => [
725
+ create_paragraph($1.strip)
726
+ ]
727
+ }
728
+ ]
729
+ }
730
+ when /^---$/ # Horizontal rule
731
+ # Flush current paragraph
732
+ if current_paragraph.any?
733
+ content << create_paragraph(current_paragraph.join(" "))
734
+ current_paragraph = []
735
+ end
736
+
737
+ content << { "type" => "rule" }
738
+ when "" # Empty line
739
+ # Flush current paragraph
740
+ if current_paragraph.any?
741
+ content << create_paragraph(current_paragraph.join(" "))
742
+ current_paragraph = []
743
+ end
744
+ else # Regular text
745
+ current_paragraph << line
746
+ end
747
+ end
748
+
749
+ # Flush any remaining paragraph
750
+ if current_paragraph.any?
751
+ content << create_paragraph(current_paragraph.join(" "))
752
+ end
753
+
754
+ {
755
+ "type" => "doc",
756
+ "version" => 1,
757
+ "content" => content
758
+ }
759
+ end
760
+
761
+ def create_paragraph(text)
762
+ {
763
+ "type" => "paragraph",
764
+ "content" => [
765
+ {
766
+ "type" => "text",
767
+ "text" => text
768
+ }
769
+ ]
770
+ }
771
+ end
772
+
773
+ private
774
+
775
+ def format_comment_as_adf(comment_data)
776
+ # If comment_data is a string (from template), convert to simple ADF
777
+ if comment_data.is_a?(String)
778
+ return convert_markdown_to_adf(comment_data)
419
779
  end
420
780
 
421
781
  # If comment_data is structured (new format), build proper ADF