rails_error_dashboard 0.5.7 → 0.5.8
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 +27 -1
- data/app/controllers/rails_error_dashboard/errors_controller.rb +57 -4
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +192 -0
- data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +71 -0
- data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +43 -0
- data/app/jobs/rails_error_dashboard/create_issue_job.rb +68 -0
- data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +44 -0
- data/app/models/rails_error_dashboard/error_log.rb +2 -1
- data/app/views/layouts/rails_error_dashboard.html.erb +19 -6
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +61 -102
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +67 -0
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +148 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +3 -1
- data/config/routes.rb +7 -1
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +5 -0
- data/db/migrate/20260326000001_add_issue_tracking_to_error_logs.rb +15 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -4
- data/lib/rails_error_dashboard/commands/create_issue.rb +59 -0
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +65 -0
- data/lib/rails_error_dashboard/configuration.rb +99 -0
- data/lib/rails_error_dashboard/engine.rb +39 -0
- data/lib/rails_error_dashboard/queries/active_storage_summary.rb +101 -0
- data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +99 -0
- data/lib/rails_error_dashboard/services/github_issue_client.rb +94 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +19 -1
- data/lib/rails_error_dashboard/services/gitlab_issue_client.rb +98 -0
- data/lib/rails_error_dashboard/services/issue_body_formatter.rb +132 -0
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +162 -0
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +12 -0
- data/lib/rails_error_dashboard/subscribers/active_storage_subscriber.rb +112 -0
- data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +71 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +11 -1
- metadata +20 -2
|
@@ -81,6 +81,19 @@ module RailsErrorDashboard
|
|
|
81
81
|
# Git repository URL for linking commits (e.g., "https://github.com/user/repo")
|
|
82
82
|
attr_accessor :git_repository_url
|
|
83
83
|
|
|
84
|
+
# Issue tracker integration (GitHub, GitLab, Codeberg/Gitea/Forgejo)
|
|
85
|
+
attr_accessor :enable_issue_tracking # Master switch (default: false)
|
|
86
|
+
attr_accessor :issue_tracker_provider # :github, :gitlab, :codeberg (auto-detected from git_repository_url)
|
|
87
|
+
attr_accessor :issue_tracker_token # String or lambda/proc for Rails credentials
|
|
88
|
+
attr_accessor :issue_tracker_repo # "owner/repo" (auto-extracted from git_repository_url)
|
|
89
|
+
attr_accessor :issue_tracker_labels # Array of label strings (default: ["bug"])
|
|
90
|
+
attr_accessor :issue_tracker_api_url # Custom API base URL for self-hosted instances
|
|
91
|
+
attr_accessor :auto_create_issues # Boolean (default: false) — auto-create issues for new errors
|
|
92
|
+
attr_accessor :auto_create_issues_on_first_occurrence # Boolean (default: true) — create on first occurrence
|
|
93
|
+
attr_accessor :auto_create_issues_for_severities # Array of symbols (default: [:critical, :high])
|
|
94
|
+
attr_accessor :enable_issue_webhooks # Boolean (default: false) — receive webhooks for two-way sync
|
|
95
|
+
attr_accessor :issue_webhook_secret # String — HMAC secret for webhook signature verification
|
|
96
|
+
|
|
84
97
|
# Advanced error analysis features
|
|
85
98
|
attr_accessor :enable_similar_errors # Fuzzy error matching
|
|
86
99
|
attr_accessor :enable_co_occurring_errors # Detect errors happening together
|
|
@@ -160,6 +173,8 @@ module RailsErrorDashboard
|
|
|
160
173
|
|
|
161
174
|
# ActionCable event tracking (requires enable_breadcrumbs = true)
|
|
162
175
|
attr_accessor :enable_actioncable_tracking # Master switch (default: false)
|
|
176
|
+
# ActiveStorage event tracking (requires enable_breadcrumbs = true)
|
|
177
|
+
attr_accessor :enable_activestorage_tracking # Master switch (default: false)
|
|
163
178
|
|
|
164
179
|
# Notification callbacks (managed via helper methods, not set directly)
|
|
165
180
|
attr_reader :notification_callbacks
|
|
@@ -229,6 +244,19 @@ module RailsErrorDashboard
|
|
|
229
244
|
@total_users_for_impact = nil # Auto-detect if not set
|
|
230
245
|
@git_repository_url = ENV["GIT_REPOSITORY_URL"]
|
|
231
246
|
|
|
247
|
+
# Issue tracker integration defaults — OFF by default
|
|
248
|
+
@enable_issue_tracking = false
|
|
249
|
+
@issue_tracker_provider = nil # Auto-detect from git_repository_url
|
|
250
|
+
@issue_tracker_token = ENV["ISSUE_TRACKER_TOKEN"]
|
|
251
|
+
@issue_tracker_repo = nil # Auto-extract from git_repository_url
|
|
252
|
+
@issue_tracker_labels = [ "bug" ]
|
|
253
|
+
@issue_tracker_api_url = nil # For self-hosted instances
|
|
254
|
+
@auto_create_issues = false
|
|
255
|
+
@auto_create_issues_on_first_occurrence = true
|
|
256
|
+
@auto_create_issues_for_severities = [ :critical, :high ]
|
|
257
|
+
@enable_issue_webhooks = false
|
|
258
|
+
@issue_webhook_secret = ENV["ISSUE_WEBHOOK_SECRET"]
|
|
259
|
+
|
|
232
260
|
# Advanced error analysis features (all OFF by default - opt-in)
|
|
233
261
|
@enable_similar_errors = false # Fuzzy error matching
|
|
234
262
|
@enable_co_occurring_errors = false # Co-occurring error detection
|
|
@@ -305,6 +333,8 @@ module RailsErrorDashboard
|
|
|
305
333
|
|
|
306
334
|
# ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
307
335
|
@enable_actioncable_tracking = false
|
|
336
|
+
# ActiveStorage event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
337
|
+
@enable_activestorage_tracking = false
|
|
308
338
|
|
|
309
339
|
# Internal logging defaults - SILENT by default
|
|
310
340
|
@enable_internal_logging = false # Opt-in for debugging
|
|
@@ -461,6 +491,25 @@ module RailsErrorDashboard
|
|
|
461
491
|
@enable_actioncable_tracking = false
|
|
462
492
|
end
|
|
463
493
|
|
|
494
|
+
# Validate activestorage tracking requires breadcrumbs
|
|
495
|
+
if enable_activestorage_tracking && !enable_breadcrumbs
|
|
496
|
+
warnings << "enable_activestorage_tracking requires enable_breadcrumbs = true. " \
|
|
497
|
+
"ActiveStorage tracking has been auto-disabled."
|
|
498
|
+
@enable_activestorage_tracking = false
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Validate issue tracking configuration
|
|
502
|
+
if enable_issue_tracking && effective_issue_tracker_token.blank?
|
|
503
|
+
warnings << "enable_issue_tracking is true but no token configured. " \
|
|
504
|
+
"Set issue_tracker_token or RED_BOT_TOKEN env var. " \
|
|
505
|
+
"Tip: Create a dedicated RED (Rails Error Dashboard) bot account on your platform."
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
if enable_issue_tracking && effective_issue_tracker_provider.nil?
|
|
509
|
+
warnings << "enable_issue_tracking is true but provider could not be detected. " \
|
|
510
|
+
"Set issue_tracker_provider or git_repository_url."
|
|
511
|
+
end
|
|
512
|
+
|
|
464
513
|
# Validate crash capture path (must exist if custom path specified)
|
|
465
514
|
if enable_crash_capture && crash_capture_path
|
|
466
515
|
unless Dir.exist?(crash_capture_path)
|
|
@@ -556,6 +605,56 @@ module RailsErrorDashboard
|
|
|
556
605
|
default || blank
|
|
557
606
|
end
|
|
558
607
|
|
|
608
|
+
# Resolve the effective issue tracker provider (auto-detect from git_repository_url)
|
|
609
|
+
#
|
|
610
|
+
# @return [Symbol, nil] :github, :gitlab, :codeberg, or nil
|
|
611
|
+
def effective_issue_tracker_provider
|
|
612
|
+
return issue_tracker_provider&.to_sym if issue_tracker_provider.present?
|
|
613
|
+
return nil if git_repository_url.blank?
|
|
614
|
+
|
|
615
|
+
case git_repository_url
|
|
616
|
+
when /github\.com/i then :github
|
|
617
|
+
when /gitlab\.com/i then :gitlab
|
|
618
|
+
when /codeberg\.org/i then :codeberg
|
|
619
|
+
when /gitea\./i, /forgejo\./i then :codeberg # Gitea/Forgejo instances use same API
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Resolve the effective issue tracker repository ("owner/repo")
|
|
624
|
+
#
|
|
625
|
+
# @return [String, nil] "owner/repo" or nil
|
|
626
|
+
def effective_issue_tracker_repo
|
|
627
|
+
return issue_tracker_repo if issue_tracker_repo.present?
|
|
628
|
+
return nil if git_repository_url.blank?
|
|
629
|
+
|
|
630
|
+
# Extract owner/repo from URL: https://github.com/owner/repo(.git)
|
|
631
|
+
match = git_repository_url.match(%r{[:/]([^/]+/[^/]+?)(?:\.git)?$})
|
|
632
|
+
match&.[](1)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Resolve the issue tracker API token (supports string or lambda)
|
|
636
|
+
#
|
|
637
|
+
# @return [String, nil] The resolved token value
|
|
638
|
+
def effective_issue_tracker_token
|
|
639
|
+
return nil if issue_tracker_token.nil?
|
|
640
|
+
issue_tracker_token.respond_to?(:call) ? issue_tracker_token.call : issue_tracker_token
|
|
641
|
+
rescue => e
|
|
642
|
+
nil
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# Resolve the effective API base URL for the issue tracker
|
|
646
|
+
#
|
|
647
|
+
# @return [String] API base URL
|
|
648
|
+
def effective_issue_tracker_api_url
|
|
649
|
+
return issue_tracker_api_url if issue_tracker_api_url.present?
|
|
650
|
+
|
|
651
|
+
case effective_issue_tracker_provider
|
|
652
|
+
when :github then "https://api.github.com"
|
|
653
|
+
when :gitlab then "https://gitlab.com/api/v4"
|
|
654
|
+
when :codeberg then "https://codeberg.org/api/v1"
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
559
658
|
# Get the effective user model (auto-detected if not configured)
|
|
560
659
|
#
|
|
561
660
|
# @return [String, nil] User model class name
|
|
@@ -84,6 +84,13 @@ module RailsErrorDashboard
|
|
|
84
84
|
RailsErrorDashboard::Subscribers::ActionCableSubscriber.subscribe!
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
# Subscribe to ActiveStorage AS::Notifications events (requires breadcrumbs + ActiveStorage)
|
|
88
|
+
if RailsErrorDashboard.configuration.enable_activestorage_tracking &&
|
|
89
|
+
RailsErrorDashboard.configuration.enable_breadcrumbs &&
|
|
90
|
+
defined?(ActiveStorage)
|
|
91
|
+
RailsErrorDashboard::Subscribers::ActiveStorageSubscriber.subscribe!
|
|
92
|
+
end
|
|
93
|
+
|
|
87
94
|
# Enable TracePoint(:raise) for local variable and/or instance variable capture
|
|
88
95
|
if RailsErrorDashboard.configuration.enable_local_variables ||
|
|
89
96
|
RailsErrorDashboard.configuration.enable_instance_variables
|
|
@@ -100,6 +107,38 @@ module RailsErrorDashboard
|
|
|
100
107
|
RailsErrorDashboard::Services::CrashCapture.import!
|
|
101
108
|
RailsErrorDashboard::Services::CrashCapture.enable!
|
|
102
109
|
end
|
|
110
|
+
|
|
111
|
+
# Wire issue tracker lifecycle hooks (auto-create, close on resolve, reopen on recur)
|
|
112
|
+
if RailsErrorDashboard.configuration.enable_issue_tracking
|
|
113
|
+
config = RailsErrorDashboard.configuration
|
|
114
|
+
config.notification_callbacks[:error_logged] ||= []
|
|
115
|
+
config.notification_callbacks[:error_resolved] ||= []
|
|
116
|
+
|
|
117
|
+
# Ensure notification_callbacks entries are arrays (may be lambda from user config)
|
|
118
|
+
unless config.notification_callbacks[:error_logged].is_a?(Array)
|
|
119
|
+
existing = config.notification_callbacks[:error_logged]
|
|
120
|
+
config.notification_callbacks[:error_logged] = [ existing ].compact
|
|
121
|
+
end
|
|
122
|
+
unless config.notification_callbacks[:error_resolved].is_a?(Array)
|
|
123
|
+
existing = config.notification_callbacks[:error_resolved]
|
|
124
|
+
config.notification_callbacks[:error_resolved] = [ existing ].compact
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
config.notification_callbacks[:error_logged] << ->(error_log) {
|
|
128
|
+
# Dispatch to appropriate handler based on error state
|
|
129
|
+
if error_log.occurrence_count == 1
|
|
130
|
+
RailsErrorDashboard::Subscribers::IssueTrackerSubscriber.on_error_logged(error_log)
|
|
131
|
+
elsif error_log.respond_to?(:just_reopened) && error_log.just_reopened
|
|
132
|
+
RailsErrorDashboard::Subscribers::IssueTrackerSubscriber.on_error_reopened(error_log)
|
|
133
|
+
else
|
|
134
|
+
RailsErrorDashboard::Subscribers::IssueTrackerSubscriber.on_error_recurred(error_log)
|
|
135
|
+
end
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
config.notification_callbacks[:error_resolved] << ->(error_log) {
|
|
139
|
+
RailsErrorDashboard::Subscribers::IssueTrackerSubscriber.on_error_resolved(error_log)
|
|
140
|
+
}
|
|
141
|
+
end
|
|
103
142
|
end
|
|
104
143
|
end
|
|
105
144
|
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Aggregate ActiveStorage events from breadcrumbs across all errors
|
|
6
|
+
# Scans error_logs breadcrumbs JSON, filters for "active_storage" category crumbs,
|
|
7
|
+
# and groups by service name with counts by operation type.
|
|
8
|
+
class ActiveStorageSummary
|
|
9
|
+
def self.call(days = 30, application_id: nil)
|
|
10
|
+
new(days, application_id: application_id).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(days = 30, application_id: nil)
|
|
14
|
+
@days = days
|
|
15
|
+
@application_id = application_id
|
|
16
|
+
@start_date = days.days.ago
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
{
|
|
21
|
+
services: aggregated_services
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def base_query
|
|
28
|
+
scope = ErrorLog.where("occurred_at >= ?", @start_date)
|
|
29
|
+
.where.not(breadcrumbs: nil)
|
|
30
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
31
|
+
scope
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def aggregated_services
|
|
35
|
+
results = {}
|
|
36
|
+
|
|
37
|
+
base_query.select(:id, :breadcrumbs, :occurred_at).find_each(batch_size: 500) do |error_log|
|
|
38
|
+
crumbs = parse_breadcrumbs(error_log.breadcrumbs)
|
|
39
|
+
next if crumbs.empty?
|
|
40
|
+
|
|
41
|
+
as_crumbs = crumbs.select { |c| c["c"] == "active_storage" }
|
|
42
|
+
next if as_crumbs.empty?
|
|
43
|
+
|
|
44
|
+
as_crumbs.each do |crumb|
|
|
45
|
+
meta = crumb["meta"] || {}
|
|
46
|
+
service = meta["service"].to_s.presence || "Unknown"
|
|
47
|
+
operation = meta["operation"].to_s
|
|
48
|
+
|
|
49
|
+
results[service] ||= {
|
|
50
|
+
service: service,
|
|
51
|
+
upload_count: 0,
|
|
52
|
+
download_count: 0,
|
|
53
|
+
delete_count: 0,
|
|
54
|
+
exist_count: 0,
|
|
55
|
+
error_ids: [],
|
|
56
|
+
durations: [],
|
|
57
|
+
last_seen: nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
entry = results[service]
|
|
61
|
+
|
|
62
|
+
case operation
|
|
63
|
+
when "upload"
|
|
64
|
+
entry[:upload_count] += 1
|
|
65
|
+
when "download", "streaming_download"
|
|
66
|
+
entry[:download_count] += 1
|
|
67
|
+
when "delete", "delete_prefixed"
|
|
68
|
+
entry[:delete_count] += 1
|
|
69
|
+
when "exist"
|
|
70
|
+
entry[:exist_count] += 1
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
entry[:durations] << crumb["d"] if crumb["d"].is_a?(Numeric) && crumb["d"] > 0
|
|
74
|
+
entry[:error_ids] << error_log.id
|
|
75
|
+
entry[:last_seen] = [ entry[:last_seen], error_log.occurred_at ].compact.max
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
results.values.each do |r|
|
|
80
|
+
r[:error_ids] = r[:error_ids].uniq
|
|
81
|
+
r[:error_count] = r[:error_ids].size
|
|
82
|
+
r[:total_operations] = r[:upload_count] + r[:download_count] + r[:delete_count] + r[:exist_count]
|
|
83
|
+
r[:avg_duration_ms] = r[:durations].any? ? (r[:durations].sum / r[:durations].size).round(2) : nil
|
|
84
|
+
r[:slowest_ms] = r[:durations].max
|
|
85
|
+
r.delete(:durations)
|
|
86
|
+
end
|
|
87
|
+
results.values.sort_by { |r| [ -r[:total_operations], -r[:error_count] ] }
|
|
88
|
+
rescue => e
|
|
89
|
+
Rails.logger.error("[RailsErrorDashboard] ActiveStorageSummary query failed: #{e.class}: #{e.message}")
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_breadcrumbs(raw)
|
|
94
|
+
return [] if raw.blank?
|
|
95
|
+
JSON.parse(raw)
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
[]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Codeberg/Gitea/Forgejo REST API client for issue management.
|
|
6
|
+
#
|
|
7
|
+
# Codeberg runs Forgejo (hard fork of Gitea). The API is compatible with
|
|
8
|
+
# Gitea's /api/v1/ endpoints. Works with any Gitea or Forgejo instance.
|
|
9
|
+
#
|
|
10
|
+
# API Docs: https://docs.gitea.com/development/api-usage
|
|
11
|
+
# Codeberg: https://codeberg.org/api/swagger
|
|
12
|
+
# Auth: Personal access token
|
|
13
|
+
class CodebergIssueClient < IssueTrackerClient
|
|
14
|
+
def initialize(token:, repo:, api_url: nil)
|
|
15
|
+
super
|
|
16
|
+
@api_url = api_url || "https://codeberg.org/api/v1"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_issue(title:, body:, labels: [])
|
|
20
|
+
response = http_post(
|
|
21
|
+
"#{@api_url}/repos/#{@repo}/issues",
|
|
22
|
+
{ title: title, body: truncate_body(body) },
|
|
23
|
+
auth_headers
|
|
24
|
+
)
|
|
25
|
+
# Note: Gitea/Forgejo labels require label IDs, not names.
|
|
26
|
+
# We skip labels in the create call — users can add them on the platform.
|
|
27
|
+
|
|
28
|
+
if response[:status] == 201
|
|
29
|
+
data = response[:body]
|
|
30
|
+
success_response(url: data["html_url"], number: data["number"])
|
|
31
|
+
else
|
|
32
|
+
error_response("Codeberg API error (#{response[:status]}): #{response[:body]&.dig("message") || response[:error]}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def close_issue(number:)
|
|
37
|
+
response = http_patch(
|
|
38
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}",
|
|
39
|
+
{ state: "closed" },
|
|
40
|
+
auth_headers
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
response[:status] == 201 ? success_response({}) : error_response("Codeberg API error (#{response[:status]})")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reopen_issue(number:)
|
|
47
|
+
response = http_patch(
|
|
48
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}",
|
|
49
|
+
{ state: "open" },
|
|
50
|
+
auth_headers
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
response[:status] == 201 ? success_response({}) : error_response("Codeberg API error (#{response[:status]})")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_comment(number:, body:)
|
|
57
|
+
response = http_post(
|
|
58
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}/comments",
|
|
59
|
+
{ body: truncate_body(body) },
|
|
60
|
+
auth_headers
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if response[:status] == 201
|
|
64
|
+
success_response(url: response[:body]["html_url"])
|
|
65
|
+
else
|
|
66
|
+
error_response("Codeberg API error (#{response[:status]})")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fetch_comments(number:, per_page: 10)
|
|
71
|
+
response = http_get(
|
|
72
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}/comments?limit=#{per_page}",
|
|
73
|
+
auth_headers
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if response[:status] == 200
|
|
77
|
+
comments = (response[:body] || []).map { |c|
|
|
78
|
+
{
|
|
79
|
+
author: c.dig("user", "login"),
|
|
80
|
+
avatar_url: c.dig("user", "avatar_url"),
|
|
81
|
+
body: c["body"],
|
|
82
|
+
created_at: c["created_at"],
|
|
83
|
+
url: c["html_url"]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
success_response(comments: comments)
|
|
87
|
+
else
|
|
88
|
+
error_response("Codeberg API error (#{response[:status]})")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def auth_headers
|
|
95
|
+
{ "Authorization" => "token #{@token}" }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# GitHub REST API client for issue management.
|
|
6
|
+
#
|
|
7
|
+
# API Docs: https://docs.github.com/en/rest/issues/issues
|
|
8
|
+
# Auth: Personal access token with `repo` scope
|
|
9
|
+
# Rate limit: 5,000 requests/hour per authenticated user
|
|
10
|
+
class GitHubIssueClient < IssueTrackerClient
|
|
11
|
+
def initialize(token:, repo:, api_url: nil)
|
|
12
|
+
super
|
|
13
|
+
@api_url = api_url || "https://api.github.com"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create_issue(title:, body:, labels: [])
|
|
17
|
+
response = http_post(
|
|
18
|
+
"#{@api_url}/repos/#{@repo}/issues",
|
|
19
|
+
{ title: title, body: truncate_body(body), labels: labels },
|
|
20
|
+
auth_headers
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if response[:status] == 201
|
|
24
|
+
data = response[:body]
|
|
25
|
+
success_response(url: data["html_url"], number: data["number"])
|
|
26
|
+
else
|
|
27
|
+
error_response("GitHub API error (#{response[:status]}): #{response[:body]&.dig("message") || response[:error]}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def close_issue(number:)
|
|
32
|
+
response = http_patch(
|
|
33
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}",
|
|
34
|
+
{ state: "closed" },
|
|
35
|
+
auth_headers
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
response[:status] == 200 ? success_response({}) : error_response("GitHub API error (#{response[:status]})")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reopen_issue(number:)
|
|
42
|
+
response = http_patch(
|
|
43
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}",
|
|
44
|
+
{ state: "open" },
|
|
45
|
+
auth_headers
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
response[:status] == 200 ? success_response({}) : error_response("GitHub API error (#{response[:status]})")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_comment(number:, body:)
|
|
52
|
+
response = http_post(
|
|
53
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}/comments",
|
|
54
|
+
{ body: truncate_body(body) },
|
|
55
|
+
auth_headers
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if response[:status] == 201
|
|
59
|
+
success_response(url: response[:body]["html_url"])
|
|
60
|
+
else
|
|
61
|
+
error_response("GitHub API error (#{response[:status]})")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fetch_comments(number:, per_page: 10)
|
|
66
|
+
response = http_get(
|
|
67
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}/comments?per_page=#{per_page}&sort=created&direction=desc",
|
|
68
|
+
auth_headers
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if response[:status] == 200
|
|
72
|
+
comments = (response[:body] || []).map { |c|
|
|
73
|
+
{
|
|
74
|
+
author: c.dig("user", "login"),
|
|
75
|
+
avatar_url: c.dig("user", "avatar_url"),
|
|
76
|
+
body: c["body"],
|
|
77
|
+
created_at: c["created_at"],
|
|
78
|
+
url: c["html_url"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
success_response(comments: comments)
|
|
82
|
+
else
|
|
83
|
+
error_response("GitHub API error (#{response[:status]})")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def auth_headers
|
|
90
|
+
{ "Authorization" => "Bearer #{@token}", "Accept" => "application/vnd.github+json" }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -53,6 +53,8 @@ module RailsErrorDashboard
|
|
|
53
53
|
generate_gitlab_link(normalized_repo, reference)
|
|
54
54
|
when :bitbucket
|
|
55
55
|
generate_bitbucket_link(normalized_repo, reference)
|
|
56
|
+
when :codeberg
|
|
57
|
+
generate_codeberg_link(normalized_repo, reference)
|
|
56
58
|
else
|
|
57
59
|
@error = "Unsupported repository type"
|
|
58
60
|
nil
|
|
@@ -77,13 +79,14 @@ module RailsErrorDashboard
|
|
|
77
79
|
|
|
78
80
|
# Detect repository type from URL
|
|
79
81
|
#
|
|
80
|
-
# @return [Symbol] :github, :gitlab, :bitbucket, or :unknown
|
|
82
|
+
# @return [Symbol] :github, :gitlab, :bitbucket, :codeberg, or :unknown
|
|
81
83
|
def detect_repository_type
|
|
82
84
|
normalized = normalize_repository_url.downcase
|
|
83
85
|
|
|
84
86
|
return :github if normalized.include?("github.com")
|
|
85
87
|
return :gitlab if normalized.include?("gitlab.com") || normalized.include?("gitlab.")
|
|
86
88
|
return :bitbucket if normalized.include?("bitbucket.org") || normalized.include?("bitbucket.")
|
|
89
|
+
return :codeberg if normalized.include?("codeberg.org") || normalized.include?("gitea.") || normalized.include?("forgejo.")
|
|
87
90
|
|
|
88
91
|
:unknown
|
|
89
92
|
end
|
|
@@ -154,6 +157,21 @@ module RailsErrorDashboard
|
|
|
154
157
|
normalized_path = normalize_file_path
|
|
155
158
|
"#{repo_url}/src/#{ref}/#{normalized_path}#lines-#{line_number}"
|
|
156
159
|
end
|
|
160
|
+
|
|
161
|
+
# Generate Codeberg/Gitea/Forgejo link
|
|
162
|
+
#
|
|
163
|
+
# Format: https://codeberg.org/user/repo/src/commit/{ref}/path/to/file.rb#L42
|
|
164
|
+
# Same as GitHub's /blob/ but Codeberg uses /src/commit/ or /src/branch/
|
|
165
|
+
#
|
|
166
|
+
# @param repo_url [String] Normalized repository URL
|
|
167
|
+
# @param ref [String] Commit SHA or branch name
|
|
168
|
+
# @return [String]
|
|
169
|
+
def generate_codeberg_link(repo_url, ref)
|
|
170
|
+
normalized_path = normalize_file_path
|
|
171
|
+
# Codeberg uses /src/commit/{sha} for commits and /src/branch/{name} for branches
|
|
172
|
+
ref_type = ref.match?(/\A[0-9a-f]{7,40}\z/i) ? "commit" : "branch"
|
|
173
|
+
"#{repo_url}/src/#{ref_type}/#{ref}/#{normalized_path}#L#{line_number}"
|
|
174
|
+
end
|
|
157
175
|
end
|
|
158
176
|
end
|
|
159
177
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# GitLab REST API client for issue management.
|
|
6
|
+
#
|
|
7
|
+
# API Docs: https://docs.gitlab.com/api/issues/
|
|
8
|
+
# Auth: Personal access token or project access token
|
|
9
|
+
# Project ID: URL-encoded path ("user%2Frepo") or numeric ID
|
|
10
|
+
class GitLabIssueClient < IssueTrackerClient
|
|
11
|
+
def initialize(token:, repo:, api_url: nil)
|
|
12
|
+
super
|
|
13
|
+
@api_url = api_url || "https://gitlab.com/api/v4"
|
|
14
|
+
@encoded_repo = URI.encode_www_form_component(@repo)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_issue(title:, body:, labels: [])
|
|
18
|
+
response = http_post(
|
|
19
|
+
"#{@api_url}/projects/#{@encoded_repo}/issues",
|
|
20
|
+
{ title: title, description: truncate_body(body), labels: labels.join(",") },
|
|
21
|
+
auth_headers
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if response[:status] == 201
|
|
25
|
+
data = response[:body]
|
|
26
|
+
success_response(url: data["web_url"], number: data["iid"])
|
|
27
|
+
else
|
|
28
|
+
error_response("GitLab API error (#{response[:status]}): #{response[:body]&.dig("message") || response[:error]}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def close_issue(number:)
|
|
33
|
+
response = http_put(
|
|
34
|
+
"#{@api_url}/projects/#{@encoded_repo}/issues/#{number}",
|
|
35
|
+
{ state_event: "close" },
|
|
36
|
+
auth_headers
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
response[:status] == 200 ? success_response({}) : error_response("GitLab API error (#{response[:status]})")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reopen_issue(number:)
|
|
43
|
+
response = http_put(
|
|
44
|
+
"#{@api_url}/projects/#{@encoded_repo}/issues/#{number}",
|
|
45
|
+
{ state_event: "reopen" },
|
|
46
|
+
auth_headers
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
response[:status] == 200 ? success_response({}) : error_response("GitLab API error (#{response[:status]})")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_comment(number:, body:)
|
|
53
|
+
response = http_post(
|
|
54
|
+
"#{@api_url}/projects/#{@encoded_repo}/issues/#{number}/notes",
|
|
55
|
+
{ body: truncate_body(body) },
|
|
56
|
+
auth_headers
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if response[:status] == 201
|
|
60
|
+
# GitLab notes don't have a direct URL — construct from issue URL + note anchor
|
|
61
|
+
note_id = response[:body]["id"]
|
|
62
|
+
issue_url = "#{@api_url.sub("/api/v4", "")}/#{@repo}/-/issues/#{number}#note_#{note_id}"
|
|
63
|
+
success_response(url: issue_url)
|
|
64
|
+
else
|
|
65
|
+
error_response("GitLab API error (#{response[:status]})")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fetch_comments(number:, per_page: 10)
|
|
70
|
+
response = http_get(
|
|
71
|
+
"#{@api_url}/projects/#{@encoded_repo}/issues/#{number}/notes?per_page=#{per_page}&sort=desc",
|
|
72
|
+
auth_headers
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if response[:status] == 200
|
|
76
|
+
comments = (response[:body] || []).reject { |n| n["system"] }.map { |n|
|
|
77
|
+
{
|
|
78
|
+
author: n.dig("author", "username"),
|
|
79
|
+
avatar_url: n.dig("author", "avatar_url"),
|
|
80
|
+
body: n["body"],
|
|
81
|
+
created_at: n["created_at"],
|
|
82
|
+
url: nil # GitLab notes don't have individual URLs in API response
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
success_response(comments: comments)
|
|
86
|
+
else
|
|
87
|
+
error_response("GitLab API error (#{response[:status]})")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def auth_headers
|
|
94
|
+
{ "PRIVATE-TOKEN" => @token }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|