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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -1
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +57 -4
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +192 -0
  5. data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +71 -0
  6. data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +43 -0
  7. data/app/jobs/rails_error_dashboard/create_issue_job.rb +68 -0
  8. data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +44 -0
  9. data/app/models/rails_error_dashboard/error_log.rb +2 -1
  10. data/app/views/layouts/rails_error_dashboard.html.erb +19 -6
  11. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +61 -102
  12. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +67 -0
  13. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +148 -0
  14. data/app/views/rails_error_dashboard/errors/show.html.erb +3 -1
  15. data/config/routes.rb +7 -1
  16. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +5 -0
  17. data/db/migrate/20260326000001_add_issue_tracking_to_error_logs.rb +15 -0
  18. data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -4
  19. data/lib/rails_error_dashboard/commands/create_issue.rb +59 -0
  20. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +65 -0
  21. data/lib/rails_error_dashboard/configuration.rb +99 -0
  22. data/lib/rails_error_dashboard/engine.rb +39 -0
  23. data/lib/rails_error_dashboard/queries/active_storage_summary.rb +101 -0
  24. data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +99 -0
  25. data/lib/rails_error_dashboard/services/github_issue_client.rb +94 -0
  26. data/lib/rails_error_dashboard/services/github_link_generator.rb +19 -1
  27. data/lib/rails_error_dashboard/services/gitlab_issue_client.rb +98 -0
  28. data/lib/rails_error_dashboard/services/issue_body_formatter.rb +132 -0
  29. data/lib/rails_error_dashboard/services/issue_tracker_client.rb +162 -0
  30. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +12 -0
  31. data/lib/rails_error_dashboard/subscribers/active_storage_subscriber.rb +112 -0
  32. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +71 -0
  33. data/lib/rails_error_dashboard/version.rb +1 -1
  34. data/lib/rails_error_dashboard.rb +11 -1
  35. 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