earl-bot 0.1.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.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +40 -0
  4. data/CLAUDE.md +260 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +177 -0
  7. data/LICENSE +21 -0
  8. data/README.md +106 -0
  9. data/Rakefile +11 -0
  10. data/bin/README.md +21 -0
  11. data/bin/ci +49 -0
  12. data/bin/claude-context +155 -0
  13. data/bin/claude-usage +110 -0
  14. data/bin/coverage +221 -0
  15. data/bin/rubocop +10 -0
  16. data/bin/watch-ci +198 -0
  17. data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
  18. data/config/earl-claude-home/.claude/settings.json +34 -0
  19. data/earl-bot.gemspec +42 -0
  20. data/exe/earl +51 -0
  21. data/exe/earl-install +129 -0
  22. data/exe/earl-permission-server +39 -0
  23. data/lib/earl/claude_session/stats.rb +76 -0
  24. data/lib/earl/claude_session.rb +468 -0
  25. data/lib/earl/command_executor/constants.rb +53 -0
  26. data/lib/earl/command_executor/heartbeat_display.rb +54 -0
  27. data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
  28. data/lib/earl/command_executor/session_handler.rb +126 -0
  29. data/lib/earl/command_executor/spawn_handler.rb +99 -0
  30. data/lib/earl/command_executor/stats_formatter.rb +66 -0
  31. data/lib/earl/command_executor/usage_handler.rb +132 -0
  32. data/lib/earl/command_executor.rb +128 -0
  33. data/lib/earl/command_parser.rb +57 -0
  34. data/lib/earl/config.rb +94 -0
  35. data/lib/earl/cron_parser.rb +105 -0
  36. data/lib/earl/formatting.rb +14 -0
  37. data/lib/earl/heartbeat_config.rb +101 -0
  38. data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
  39. data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
  40. data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
  41. data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
  42. data/lib/earl/heartbeat_scheduler.rb +131 -0
  43. data/lib/earl/logging.rb +12 -0
  44. data/lib/earl/mattermost/api_client.rb +85 -0
  45. data/lib/earl/mattermost.rb +261 -0
  46. data/lib/earl/mcp/approval_handler.rb +304 -0
  47. data/lib/earl/mcp/config.rb +62 -0
  48. data/lib/earl/mcp/github_pat_handler.rb +450 -0
  49. data/lib/earl/mcp/handler_base.rb +13 -0
  50. data/lib/earl/mcp/heartbeat_handler.rb +310 -0
  51. data/lib/earl/mcp/memory_handler.rb +89 -0
  52. data/lib/earl/mcp/server.rb +123 -0
  53. data/lib/earl/mcp/tmux_handler.rb +562 -0
  54. data/lib/earl/memory/prompt_builder.rb +40 -0
  55. data/lib/earl/memory/store.rb +125 -0
  56. data/lib/earl/message_queue.rb +56 -0
  57. data/lib/earl/permission_config.rb +22 -0
  58. data/lib/earl/question_handler/question_posting.rb +58 -0
  59. data/lib/earl/question_handler.rb +116 -0
  60. data/lib/earl/runner/idle_management.rb +44 -0
  61. data/lib/earl/runner/lifecycle.rb +73 -0
  62. data/lib/earl/runner/message_handling.rb +121 -0
  63. data/lib/earl/runner/reaction_handling.rb +42 -0
  64. data/lib/earl/runner/response_lifecycle.rb +96 -0
  65. data/lib/earl/runner/service_builder.rb +48 -0
  66. data/lib/earl/runner/startup.rb +73 -0
  67. data/lib/earl/runner/thread_context_builder.rb +43 -0
  68. data/lib/earl/runner.rb +70 -0
  69. data/lib/earl/safari_automation.rb +497 -0
  70. data/lib/earl/session_manager/persistence.rb +46 -0
  71. data/lib/earl/session_manager/session_creation.rb +108 -0
  72. data/lib/earl/session_manager.rb +92 -0
  73. data/lib/earl/session_store.rb +84 -0
  74. data/lib/earl/streaming_response.rb +219 -0
  75. data/lib/earl/tmux/parsing.rb +80 -0
  76. data/lib/earl/tmux/processes.rb +34 -0
  77. data/lib/earl/tmux/sessions.rb +41 -0
  78. data/lib/earl/tmux.rb +122 -0
  79. data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
  80. data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
  81. data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
  82. data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
  83. data/lib/earl/tmux_monitor.rb +249 -0
  84. data/lib/earl/tmux_session_store.rb +133 -0
  85. data/lib/earl/tool_input_formatter.rb +44 -0
  86. data/lib/earl/version.rb +5 -0
  87. data/lib/earl.rb +87 -0
  88. data/lib/tasks/.keep +1 -0
  89. metadata +248 -0
data/bin/ci ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "open3"
5
+
6
+ steps = []
7
+ failures = []
8
+
9
+ def run_step(name, command, steps, failures)
10
+ print " #{name}... "
11
+ $stdout.flush
12
+ output, status = Open3.capture2e(command)
13
+ if status.success?
14
+ puts "OK"
15
+ steps << { name: name, status: :ok }
16
+ else
17
+ puts "FAIL"
18
+ failures << { name: name, output: output }
19
+ steps << { name: name, status: :fail }
20
+ end
21
+ rescue Errno::ENOENT => e
22
+ puts "FAIL"
23
+ failures << { name: name, output: "Command not found: #{e.message}" }
24
+ steps << { name: name, status: :fail }
25
+ end
26
+
27
+ puts "Running CI pipeline..."
28
+ puts ""
29
+
30
+ run_step("Style: RuboCop", "bundle exec rubocop --no-server", steps, failures)
31
+ run_step("Quality: Reek", "bundle exec reek", steps, failures)
32
+ run_step("Security: Bundler audit", "bundle exec bundle-audit check --update", steps, failures)
33
+ run_step("Security: Semgrep", "semgrep --config=r/ruby --metrics=off --error --quiet lib/", steps, failures)
34
+ run_step("Lint: Actionlint", "actionlint .github/workflows/*.yml", steps, failures)
35
+ run_step("Tests: Minitest", "bundle exec rake test", steps, failures)
36
+ run_step("Coverage: Report", "ruby bin/coverage", steps, failures)
37
+
38
+ puts ""
39
+ if failures.empty?
40
+ puts "All #{steps.size} steps passed!"
41
+ else
42
+ puts "#{failures.size} of #{steps.size} steps failed:"
43
+ failures.each do |f|
44
+ puts ""
45
+ puts "--- #{f[:name]} ---"
46
+ puts f[:output]
47
+ end
48
+ exit 1
49
+ end
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env bash
2
+ # Scrape Claude Code /context output via tmux.
3
+ # Resumes an existing session, runs /context, captures output, exits.
4
+ #
5
+ # Usage:
6
+ # bin/claude-context <session-id> # Human-readable output
7
+ # bin/claude-context <session-id> --json # JSON output for programmatic use
8
+ #
9
+ # Requires: tmux, claude CLI
10
+ set -euo pipefail
11
+
12
+ SESSION_ID="${1:?Usage: $0 <session-id> [--json]}"
13
+ JSON_MODE=false
14
+ if [[ "${2:-}" == "--json" ]]; then
15
+ JSON_MODE=true
16
+ fi
17
+
18
+ TMUX_SESSION="earl-ctx-$$"
19
+ TIMEOUT=30
20
+
21
+ cleanup() { tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true; }
22
+ trap cleanup EXIT
23
+
24
+ # Resume the Claude session in detached tmux (wide to avoid text wrapping)
25
+ tmux new-session -d -s "$TMUX_SESSION" -x 200 -y 80 "CLAUDECODE= claude --resume $SESSION_ID --fork-session 2>&1; sleep 30"
26
+
27
+ # Wait for the session to be ready (prompt indicators)
28
+ for _ in $(seq 1 "$TIMEOUT"); do
29
+ sleep 1
30
+ if tmux capture-pane -t "$TMUX_SESSION" -p -S -20 2>/dev/null | grep -q 'PR #\|Try \|Welcome\|INSERT'; then
31
+ break
32
+ fi
33
+ done
34
+
35
+ # Extra settle time after prompt appears
36
+ sleep 5
37
+
38
+ # Clear any pending input and send /context
39
+ # NOTE: Do NOT send Escape — Claude TUI uses vim-style modes and Escape
40
+ # switches from INSERT to NORMAL mode, where "/" starts a search.
41
+ # First Enter triggers autocomplete menu, second Enter selects /context
42
+ tmux send-keys -t "$TMUX_SESSION" C-u
43
+ sleep 0.5
44
+ tmux send-keys -t "$TMUX_SESSION" '/context' Enter
45
+ sleep 2
46
+ tmux send-keys -t "$TMUX_SESSION" Enter
47
+ sleep 5
48
+
49
+ # Capture the pane content (large scrollback to get full /context output)
50
+ OUTPUT=$(tmux capture-pane -t "$TMUX_SESSION" -p -S -200 2>/dev/null)
51
+
52
+ # Exit Claude cleanly
53
+ tmux send-keys -t "$TMUX_SESSION" C-u
54
+ sleep 0.5
55
+ tmux send-keys -t "$TMUX_SESSION" '/exit' Enter
56
+ sleep 1
57
+
58
+ # /context output uses unicode icons (⛁ ⛶ ⛝) as prefixes for each category line.
59
+ # We anchor our greps on these icons to avoid matching conversation history that may
60
+ # contain similar text (e.g., diffs of this very script).
61
+ #
62
+ # Example lines:
63
+ # ⛁ System prompt: 3k tokens (1.5%)
64
+ # ⛶ Free space: 133k (66.7%)
65
+ # ⛝ Autocompact buffer: 33k tokens (16.5%)
66
+
67
+ # Parse the context summary line: "claude-opus-4-6 · 34k/200k tokens (17%)"
68
+ # The · is a UTF-8 middle dot (U+00B7), match with [^a-z]*
69
+ # Take the LAST match in case scrollback has older /context outputs
70
+ summary_line=$(echo "$OUTPUT" | grep -o '[a-z0-9-]* [^a-z]* [0-9.]*k*/[0-9.]*k* tokens ([0-9.]*%)' | tail -1 || echo "")
71
+
72
+ if [[ -z "$summary_line" ]]; then
73
+ if [[ "$JSON_MODE" == true ]]; then
74
+ echo '{"error": "Could not parse context data"}'
75
+ else
76
+ echo "⚠️ Could not parse context data."
77
+ echo "Raw output (last 30 lines):"
78
+ echo "$OUTPUT" | tail -30
79
+ fi
80
+ exit 1
81
+ fi
82
+
83
+ model=$(echo "$summary_line" | sed 's/ [^a-z].*//')
84
+ used_tokens=$(echo "$summary_line" | grep -o '[0-9.]*k*/' | sed 's/\///')
85
+ total_tokens=$(echo "$summary_line" | grep -o '/[0-9.]*k* tokens' | sed 's|^/||' | sed 's/ tokens//')
86
+ percent=$(echo "$summary_line" | grep -o '([0-9.]*%)' | tr -d '()')
87
+
88
+ # Helper: extract token count from a /context category line.
89
+ # Anchors on unicode icons (⛁ ⛶ ⛝) to avoid matching conversation history.
90
+ parse_tokens() {
91
+ echo "$OUTPUT" | grep "[⛁⛶⛝] $1" | tail -1 | grep -o '[0-9.]*k* tokens' | sed 's/ tokens//' || echo ""
92
+ }
93
+
94
+ # Helper: extract percentage from a /context category line
95
+ parse_pct() {
96
+ echo "$OUTPUT" | grep "[⛁⛶⛝] $1" | tail -1 | grep -o '([0-9.]*%)' | tr -d '()' || echo ""
97
+ }
98
+
99
+ # Parse category breakdowns
100
+ system_prompt=$(parse_tokens 'System prompt:')
101
+ system_tools=$(parse_tokens 'System tools:')
102
+ custom_agents=$(parse_tokens 'Custom agents:')
103
+ memory_files=$(parse_tokens 'Memory files:')
104
+ skills_val=$(parse_tokens 'Skills:')
105
+ messages=$(parse_tokens 'Messages:')
106
+ free_space=$(echo "$OUTPUT" | grep '[⛁⛶⛝] Free space:' | tail -1 | grep -o '[0-9.]*k*' | head -1 || echo "")
107
+ autocompact=$(echo "$OUTPUT" | grep '[⛁⛶⛝] Autocompact buffer:' | tail -1 | grep -o '[0-9.]*k* tokens' | sed 's/ tokens//' || echo "")
108
+
109
+ # Parse percentage breakdowns
110
+ system_prompt_pct=$(parse_pct 'System prompt:')
111
+ system_tools_pct=$(parse_pct 'System tools:')
112
+ custom_agents_pct=$(parse_pct 'Custom agents:')
113
+ memory_files_pct=$(parse_pct 'Memory files:')
114
+ skills_pct=$(parse_pct 'Skills:')
115
+ messages_pct=$(parse_pct 'Messages:')
116
+ free_space_pct=$(parse_pct 'Free space:')
117
+ autocompact_pct=$(echo "$OUTPUT" | grep '[⛁⛶⛝] Autocompact buffer:' | tail -1 | grep -o '([0-9.]*%)' | tr -d '()' || echo "")
118
+
119
+ if [[ "$JSON_MODE" == true ]]; then
120
+ # Helper to output a JSON string or null
121
+ json_str() { if [[ -n "$1" ]]; then echo "\"$1\""; else echo "null"; fi; }
122
+
123
+ cat <<JSON
124
+ {
125
+ "model": "$model",
126
+ "used_tokens": "$used_tokens",
127
+ "total_tokens": "$total_tokens",
128
+ "percent_used": "$percent",
129
+ "categories": {
130
+ "system_prompt": {"tokens": $(json_str "$system_prompt"), "percent": $(json_str "$system_prompt_pct")},
131
+ "system_tools": {"tokens": $(json_str "$system_tools"), "percent": $(json_str "$system_tools_pct")},
132
+ "custom_agents": {"tokens": $(json_str "$custom_agents"), "percent": $(json_str "$custom_agents_pct")},
133
+ "memory_files": {"tokens": $(json_str "$memory_files"), "percent": $(json_str "$memory_files_pct")},
134
+ "skills": {"tokens": $(json_str "$skills_val"), "percent": $(json_str "$skills_pct")},
135
+ "messages": {"tokens": $(json_str "$messages"), "percent": $(json_str "$messages_pct")},
136
+ "free_space": {"tokens": $(json_str "$free_space"), "percent": $(json_str "$free_space_pct")},
137
+ "autocompact_buffer": {"tokens": $(json_str "$autocompact"), "percent": $(json_str "$autocompact_pct")}
138
+ }
139
+ }
140
+ JSON
141
+ else
142
+ echo "📊 Claude Context Usage"
143
+ echo ""
144
+ echo " Model: $model"
145
+ echo " Context: $used_tokens / $total_tokens tokens ($percent)"
146
+ echo ""
147
+ [[ -n "$messages" ]] && echo " Messages: $messages tokens ($messages_pct)"
148
+ [[ -n "$system_prompt" ]] && echo " System prompt: $system_prompt tokens ($system_prompt_pct)"
149
+ [[ -n "$system_tools" ]] && echo " System tools: $system_tools tokens ($system_tools_pct)"
150
+ [[ -n "$custom_agents" ]] && echo " Custom agents: $custom_agents tokens ($custom_agents_pct)"
151
+ [[ -n "$memory_files" ]] && echo " Memory files: $memory_files tokens ($memory_files_pct)"
152
+ [[ -n "$skills_val" ]] && echo " Skills: $skills_val tokens ($skills_pct)"
153
+ [[ -n "$free_space" ]] && echo " Free space: $free_space ($free_space_pct)"
154
+ [[ -n "$autocompact" ]] && echo " Autocompact buf: $autocompact tokens ($autocompact_pct)"
155
+ fi
data/bin/claude-usage ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env bash
2
+ # Scrape Claude Code /usage output via tmux.
3
+ # Spawns an interactive Claude session, runs /usage, captures output, exits.
4
+ #
5
+ # Usage:
6
+ # bin/claude-usage # Human-readable output
7
+ # bin/claude-usage --json # JSON output for programmatic use
8
+ #
9
+ # Requires: tmux, claude CLI
10
+ set -euo pipefail
11
+
12
+ SESSION="claude-usage-$$"
13
+ TIMEOUT=15
14
+
15
+ cleanup() { tmux kill-session -t "$SESSION" 2>/dev/null || true; }
16
+ trap cleanup EXIT
17
+
18
+ # Start interactive Claude in detached tmux (wide to avoid text wrapping)
19
+ tmux new-session -d -s "$SESSION" -x 200 -y 50 "CLAUDECODE= claude 2>&1"
20
+
21
+ # Wait for the welcome screen
22
+ for _ in $(seq 1 "$TIMEOUT"); do
23
+ sleep 1
24
+ if tmux capture-pane -t "$SESSION" -p 2>/dev/null | grep -q "Welcome\|Opus\|Claude"; then
25
+ break
26
+ fi
27
+ done
28
+
29
+ # Send /usage — first Enter selects from autocomplete, second confirms
30
+ tmux send-keys -t "$SESSION" '/usage' Enter
31
+ sleep 1
32
+ tmux send-keys -t "$SESSION" Enter
33
+ sleep 3
34
+
35
+ # Capture the pane content
36
+ OUTPUT=$(tmux capture-pane -t "$SESSION" -p 2>/dev/null)
37
+
38
+ # Exit Claude cleanly
39
+ tmux send-keys -t "$SESSION" C-u
40
+ sleep 0.5
41
+ tmux send-keys -t "$SESSION" '/exit' Enter
42
+ sleep 1
43
+
44
+ # Parse the captured output
45
+ session_pct=$(echo "$OUTPUT" | grep -A1 "Current session" | grep -o '[0-9]*% used' | grep -o '[0-9]*' || echo "")
46
+ session_reset=$(echo "$OUTPUT" | grep -A2 "Current session" | grep "Resets" | sed 's/^[[:space:]]*//' | sed 's/Resets //' || echo "")
47
+
48
+ # "Current week (all models)" — use head -1 to pick only the first match
49
+ week_pct=$(echo "$OUTPUT" | grep -A1 "Current week (all models)" | grep -o '[0-9]*% used' | head -1 | grep -o '[0-9]*' || echo "")
50
+ week_reset=$(echo "$OUTPUT" | grep -A2 "Current week (all models)" | grep "Resets" | head -1 | sed 's/^[[:space:]]*//' | sed 's/Resets //' || echo "")
51
+
52
+ # "Current week (Sonnet only)" — separate field
53
+ sonnet_pct=$(echo "$OUTPUT" | grep -A1 "Current week (Sonnet only)" | grep -o '[0-9]*% used' | head -1 | grep -o '[0-9]*' || echo "")
54
+ sonnet_reset=$(echo "$OUTPUT" | grep -A2 "Current week (Sonnet only)" | grep "Resets" | head -1 | sed 's/^[[:space:]]*//' | sed 's/Resets //' || echo "")
55
+
56
+ # Fallback: if "all models" label not found, try plain "Current week" (older CLI)
57
+ if [[ -z "$week_pct" ]]; then
58
+ week_pct=$(echo "$OUTPUT" | grep -A1 "Current week" | grep -o '[0-9]*% used' | head -1 | grep -o '[0-9]*' || echo "")
59
+ week_reset=$(echo "$OUTPUT" | grep -A2 "Current week" | grep "Resets" | head -1 | sed 's/^[[:space:]]*//' | sed 's/Resets //' || echo "")
60
+ fi
61
+
62
+ extra_pct=$(echo "$OUTPUT" | grep -A1 "Extra usage" | grep -o '[0-9]*% used' | grep -o '[0-9]*' || echo "")
63
+ extra_detail=$(echo "$OUTPUT" | grep -A2 "Extra usage" | grep "spent" | sed 's/^[[:space:]]*//' || echo "")
64
+ extra_reset=$(echo "$extra_detail" | sed 's/.*Resets //' || echo "")
65
+ extra_spent=$(echo "$extra_detail" | grep -o '\$[0-9.]*' | head -1 || echo "")
66
+ extra_budget=$(echo "$extra_detail" | grep -o '\$[0-9.]*' | tail -1 || echo "")
67
+
68
+ if [[ "${1:-}" == "--json" ]]; then
69
+ cat <<JSON
70
+ {
71
+ "session": {
72
+ "percent_used": ${session_pct:-null},
73
+ "resets": "${session_reset}"
74
+ },
75
+ "week": {
76
+ "percent_used": ${week_pct:-null},
77
+ "resets": "${week_reset}"
78
+ },
79
+ "sonnet_week": {
80
+ "percent_used": ${sonnet_pct:-null},
81
+ "resets": "${sonnet_reset}"
82
+ },
83
+ "extra": {
84
+ "percent_used": ${extra_pct:-null},
85
+ "spent": "${extra_spent}",
86
+ "budget": "${extra_budget}",
87
+ "resets": "${extra_reset}"
88
+ }
89
+ }
90
+ JSON
91
+ else
92
+ echo "📊 Claude Usage"
93
+ echo ""
94
+ if [[ -n "$session_pct" ]]; then
95
+ echo " Session: ${session_pct}% used — resets ${session_reset}"
96
+ fi
97
+ if [[ -n "$week_pct" ]]; then
98
+ echo " Week: ${week_pct}% used — resets ${week_reset}"
99
+ fi
100
+ if [[ -n "$sonnet_pct" ]]; then
101
+ echo " Sonnet: ${sonnet_pct}% used — resets ${sonnet_reset}"
102
+ fi
103
+ if [[ -n "$extra_pct" ]]; then
104
+ echo " Extra: ${extra_pct}% used (${extra_spent} / ${extra_budget}) — resets ${extra_reset}"
105
+ fi
106
+ if [[ -z "$session_pct" && -z "$week_pct" ]]; then
107
+ echo " ⚠️ Could not parse usage data. Raw output:"
108
+ echo "$OUTPUT" | sed -n '/Current session/,/Esc to cancel/p'
109
+ fi
110
+ fi
data/bin/coverage ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/inline"
5
+
6
+ gemfile do
7
+ source "https://rubygems.org"
8
+ gem "nokogiri"
9
+ end
10
+
11
+ # Path to the SimpleCov HTML report
12
+ coverage_file = File.join(Dir.pwd, "coverage", "index.html")
13
+
14
+ unless File.exist?(coverage_file)
15
+ puts "❌ No coverage report found at #{coverage_file}"
16
+ puts "Run tests first: bundle exec rake test"
17
+ exit 1
18
+ end
19
+
20
+ # Parse the HTML file
21
+ doc = Nokogiri::HTML(File.read(coverage_file))
22
+
23
+ # Extract overall line coverage
24
+ line_percent = doc.css(".covered_percent span").first&.text&.strip
25
+ total_lines = doc.css(".t-line-summary b").first&.text&.strip
26
+ covered_lines = doc.css(".t-line-summary .green b").first&.text&.strip
27
+ missed_lines = doc.css(".t-line-summary .red b").first&.text&.strip
28
+
29
+ # Extract overall branch coverage (first t-branch-summary is the overall stats)
30
+ overall_branch_summary = doc.css(".t-branch-summary").first
31
+ if overall_branch_summary
32
+ branch_percent = overall_branch_summary.css("span").last.text.strip.gsub(/[()]/, "")
33
+ branch_summary_spans = overall_branch_summary.css("span b")
34
+ overall_total_branches = branch_summary_spans[0]&.text&.strip
35
+ overall_covered_branches = branch_summary_spans[1]&.text&.strip
36
+ overall_missed_branches = branch_summary_spans[2]&.text&.strip
37
+ else
38
+ # No branch coverage available
39
+ branch_percent = nil
40
+ overall_total_branches = nil
41
+ overall_covered_branches = nil
42
+ overall_missed_branches = nil
43
+ end
44
+
45
+ # Extract timestamp
46
+ timestamp = doc.css(".timestamp .timeago").first&.attr("title")
47
+
48
+ puts "## 📊 SimpleCov Coverage Report"
49
+ puts "Generated: #{timestamp}"
50
+ puts ""
51
+
52
+ puts "### 📈 Line Coverage: #{line_percent}"
53
+ puts " ✅ #{covered_lines}/#{total_lines} lines covered"
54
+ puts " ❌ #{missed_lines} lines missed"
55
+ puts ""
56
+
57
+ if branch_percent
58
+ puts "### 🌳 Branch Coverage: #{branch_percent}"
59
+ puts " ✅ #{overall_covered_branches}/#{overall_total_branches} branches covered"
60
+ puts " ❌ #{overall_missed_branches} branches missed"
61
+ puts ""
62
+ end
63
+
64
+ # Show file-by-file breakdown if there are missed lines or branches
65
+ if missed_lines.to_i.positive? || overall_missed_branches&.to_i&.positive?
66
+ # First, collect all files with missing coverage
67
+ files_with_issues = []
68
+ files_shown = Set.new
69
+
70
+ doc.css("tbody .t-file").each do |row|
71
+ file_name = row.css(".t-file__name a").first&.text&.strip
72
+ line_coverage = row.css(".t-file__coverage").first&.text&.strip
73
+ branch_coverage = row.css(".t-file__branch-coverage").first&.text&.strip
74
+
75
+ # Only include files that aren't 100% covered and haven't been seen yet
76
+ should_skip = files_shown.include?(file_name) ||
77
+ (line_coverage == "100.00 %" && (!branch_coverage || branch_coverage == "100.00 %"))
78
+ next if should_skip
79
+
80
+ files_shown.add(file_name)
81
+ files_with_issues << row
82
+ end
83
+
84
+ total_files_with_issues = files_with_issues.length
85
+ files_to_show = files_with_issues.take(50)
86
+
87
+ puts "### 📋 Files with missing coverage:"
88
+ puts ""
89
+ if total_files_with_issues > 50
90
+ puts "Showing top 50 of #{total_files_with_issues} files with missing coverage:"
91
+ puts ""
92
+ end
93
+
94
+ files_to_show.each do |row|
95
+ file_name = row.css(".t-file__name a").first&.text&.strip
96
+ line_coverage = row.css(".t-file__coverage").first&.text&.strip
97
+ branch_coverage = row.css(".t-file__branch-coverage").first&.text&.strip
98
+ file_link = row.css(".t-file__name a").first&.attr("href")
99
+
100
+ # Extract detailed line information for this file
101
+ missed_lines = []
102
+ missed_branches = []
103
+ total_file_lines = 0
104
+ covered_file_lines = 0
105
+
106
+ if file_link
107
+ file_id = file_link.gsub("#", "")
108
+ file_section = doc.css("##{file_id}")
109
+
110
+ if file_section.any?
111
+ # Get the actual counts from SimpleCov's summary
112
+ line_summary = file_section.css(".t-line-summary")
113
+ if line_summary.any?
114
+ summary_text = line_summary.text
115
+ # Extract numbers from text like "13 relevant lines. 12 lines covered and 1 lines missed."
116
+ total_file_lines = Regexp.last_match(1).to_i if summary_text.match(/(\d+)\s+relevant\s+lines/)
117
+ covered_file_lines = Regexp.last_match(1).to_i if summary_text.match(/(\d+)\s+lines\s+covered/)
118
+ end
119
+
120
+ # Find missed lines and branches
121
+ file_section.css("li").each do |line_item|
122
+ line_number = line_item.attr("data-linenumber")
123
+ line_class = line_item.attr("class")
124
+
125
+ if line_class&.include?("missed") && !line_class.include?("missed-branch")
126
+ missed_lines << line_number
127
+ elsif line_class&.include?("missed-branch")
128
+ missed_branches << line_number
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ # Format the line ranges more clearly
135
+ def format_line_ranges(lines)
136
+ return "" if lines.empty?
137
+
138
+ ranges = []
139
+ current_range = [lines.first.to_i]
140
+
141
+ lines.map(&:to_i).sort[1..]&.each do |line|
142
+ if line == current_range.last + 1
143
+ current_range << line
144
+ else
145
+ ranges << format_range(current_range)
146
+ current_range = [line]
147
+ end
148
+ end
149
+ ranges << format_range(current_range)
150
+
151
+ "L#{ranges.join(", L")}"
152
+ end
153
+
154
+ def format_range(range)
155
+ if range.length == 1
156
+ range.first.to_s
157
+ else
158
+ "#{range.first}-#{range.last}"
159
+ end
160
+ end
161
+
162
+ # Get branch counts from the file section
163
+ covered_branches = 0
164
+ total_branches = 0
165
+
166
+ if file_link
167
+ file_id = file_link.gsub("#", "")
168
+ file_section = doc.css("##{file_id}")
169
+ branch_summary = file_section.css(".t-branch-summary")
170
+
171
+ if branch_summary.any?
172
+ branch_spans = branch_summary.css("span b")
173
+ total_branches = branch_spans[0]&.text.to_i
174
+ covered_branches = branch_spans[1]&.text.to_i
175
+ end
176
+ end
177
+
178
+ branch_display = branch_coverage ? "Branch: #{branch_coverage}" : "Branch: N/A"
179
+ puts " #{file_name} (Line: #{line_coverage}, #{branch_display}):"
180
+
181
+ line_info = "📍 Lines: #{covered_file_lines}/#{total_file_lines}"
182
+ line_info += " (missed: #{format_line_ranges(missed_lines)})" unless missed_lines.empty?
183
+ puts " #{line_info}"
184
+
185
+ if total_branches.positive?
186
+ branch_info = "🌿 Branches: #{covered_branches}/#{total_branches}"
187
+ branch_info += " (missed: #{format_line_ranges(missed_branches)})" unless missed_branches.empty?
188
+ puts " #{branch_info}"
189
+ end
190
+
191
+ puts ""
192
+ end
193
+ end
194
+
195
+ # Write to GitHub Actions job summary if available
196
+ if ENV["GITHUB_STEP_SUMMARY"]
197
+ File.open(ENV["GITHUB_STEP_SUMMARY"], "a") do |f|
198
+ f.puts ""
199
+ f.puts "## 📊 Test Coverage Report"
200
+ f.puts ""
201
+ f.puts "**Line Coverage:** #{line_percent} (#{covered_lines}/#{total_lines} lines)"
202
+ f.puts ""
203
+
204
+ if branch_percent
205
+ f.puts "**Branch Coverage:** #{branch_percent} (#{overall_covered_branches}/#{overall_total_branches} branches)"
206
+ f.puts ""
207
+ end
208
+
209
+ # Add visual indicators
210
+ line_pct_value = line_percent.gsub("%", "").to_f
211
+ branch_pct_value = branch_percent&.gsub("%", "")&.to_f
212
+
213
+ if line_pct_value >= 95 && (!branch_pct_value || branch_pct_value >= 95)
214
+ f.puts "✅ Coverage meets quality thresholds"
215
+ elsif line_pct_value >= 80 && (!branch_pct_value || branch_pct_value >= 80)
216
+ f.puts "⚠️ Coverage is acceptable but could be improved"
217
+ else
218
+ f.puts "❌ Coverage is below recommended thresholds"
219
+ end
220
+ end
221
+ end
data/bin/rubocop ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rubygems"
5
+ require "bundler/setup"
6
+
7
+ # Explicit RuboCop config increases performance slightly while avoiding config confusion.
8
+ ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
9
+
10
+ load Gem.bin_path("rubocop", "rubocop")