rails_error_dashboard 0.5.7 → 0.5.9
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 +30 -3
- data/app/controllers/rails_error_dashboard/errors_controller.rb +81 -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/_breadcrumbs_group.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +92 -100
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +121 -0
- data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +77 -73
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +148 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +13 -9
- data/config/routes.rb +6 -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 +122 -0
- data/lib/rails_error_dashboard/services/github_issue_client.rb +117 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +19 -1
- data/lib/rails_error_dashboard/services/gitlab_issue_client.rb +121 -0
- data/lib/rails_error_dashboard/services/issue_body_formatter.rb +132 -0
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +168 -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 +21 -3
|
@@ -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,122 @@
|
|
|
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
|
+
def fetch_issue(number:)
|
|
93
|
+
response = http_get(
|
|
94
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}",
|
|
95
|
+
auth_headers
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if response[:status] == 200
|
|
99
|
+
data = response[:body]
|
|
100
|
+
success_response(
|
|
101
|
+
state: data["state"],
|
|
102
|
+
title: data["title"],
|
|
103
|
+
assignees: (data["assignees"] || []).map { |a|
|
|
104
|
+
{ login: a["login"], avatar_url: a["avatar_url"] }
|
|
105
|
+
},
|
|
106
|
+
labels: (data["labels"] || []).map { |l|
|
|
107
|
+
{ name: l["name"], color: l["color"] }
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
else
|
|
111
|
+
error_response("Codeberg API error (#{response[:status]})")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def auth_headers
|
|
118
|
+
{ "Authorization" => "token #{@token}" }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
def fetch_issue(number:)
|
|
88
|
+
response = http_get(
|
|
89
|
+
"#{@api_url}/repos/#{@repo}/issues/#{number}",
|
|
90
|
+
auth_headers
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if response[:status] == 200
|
|
94
|
+
data = response[:body]
|
|
95
|
+
success_response(
|
|
96
|
+
state: data["state"],
|
|
97
|
+
title: data["title"],
|
|
98
|
+
assignees: (data["assignees"] || []).map { |a|
|
|
99
|
+
{ login: a["login"], avatar_url: a["avatar_url"] }
|
|
100
|
+
},
|
|
101
|
+
labels: (data["labels"] || []).map { |l|
|
|
102
|
+
{ name: l["name"], color: l["color"] }
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
else
|
|
106
|
+
error_response("GitHub API error (#{response[:status]})")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def auth_headers
|
|
113
|
+
{ "Authorization" => "Bearer #{@token}", "Accept" => "application/vnd.github+json" }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
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,121 @@
|
|
|
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
|
+
def fetch_issue(number:)
|
|
92
|
+
response = http_get(
|
|
93
|
+
"#{@api_url}/projects/#{@encoded_repo}/issues/#{number}",
|
|
94
|
+
auth_headers
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if response[:status] == 200
|
|
98
|
+
data = response[:body]
|
|
99
|
+
success_response(
|
|
100
|
+
state: data["state"],
|
|
101
|
+
title: data["title"],
|
|
102
|
+
assignees: (data["assignees"] || []).map { |a|
|
|
103
|
+
{ login: a["username"], avatar_url: a["avatar_url"] }
|
|
104
|
+
},
|
|
105
|
+
labels: (data["labels"] || []).map { |l|
|
|
106
|
+
{ name: l, color: nil }
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
else
|
|
110
|
+
error_response("GitLab API error (#{response[:status]})")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def auth_headers
|
|
117
|
+
{ "PRIVATE-TOKEN" => @token }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Format error details as markdown for issue tracker body.
|
|
6
|
+
#
|
|
7
|
+
# Tailored for issue context — shorter than MarkdownErrorFormatter,
|
|
8
|
+
# includes a link back to the dashboard, and omits system health
|
|
9
|
+
# (not useful in a GitHub issue body).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# IssueBodyFormatter.call(error)
|
|
13
|
+
# # => "## NoMethodError\n\nundefined method 'foo'...\n\n[View in Dashboard](url)"
|
|
14
|
+
class IssueBodyFormatter
|
|
15
|
+
MAX_BACKTRACE_LINES = 20
|
|
16
|
+
|
|
17
|
+
def self.call(error, dashboard_url: nil)
|
|
18
|
+
new(error, dashboard_url).generate
|
|
19
|
+
rescue => e
|
|
20
|
+
"Error details could not be formatted: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(error, dashboard_url)
|
|
24
|
+
@error = error
|
|
25
|
+
@dashboard_url = dashboard_url
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def generate
|
|
29
|
+
sections = []
|
|
30
|
+
|
|
31
|
+
sections << heading_section
|
|
32
|
+
sections << backtrace_section
|
|
33
|
+
sections << cause_chain_section
|
|
34
|
+
sections << request_context_section
|
|
35
|
+
sections << environment_section
|
|
36
|
+
sections << metadata_section
|
|
37
|
+
sections << dashboard_link_section
|
|
38
|
+
|
|
39
|
+
sections.compact.join("\n\n")
|
|
40
|
+
rescue => e
|
|
41
|
+
"Error details could not be formatted."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def heading_section
|
|
47
|
+
"## #{@error.error_type}\n\n#{@error.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def backtrace_section
|
|
51
|
+
raw = @error.backtrace
|
|
52
|
+
return nil if raw.blank?
|
|
53
|
+
|
|
54
|
+
lines = raw.split("\n")
|
|
55
|
+
app_lines = lines.reject { |l| l.include?("/gems/") || l.include?("/ruby/") || l.include?("/vendor/") }
|
|
56
|
+
app_lines = lines.first(MAX_BACKTRACE_LINES) if app_lines.empty?
|
|
57
|
+
app_lines = app_lines.first(MAX_BACKTRACE_LINES)
|
|
58
|
+
|
|
59
|
+
"### Backtrace\n\n```\n#{app_lines.join("\n")}\n```"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cause_chain_section
|
|
63
|
+
raw = @error.exception_cause
|
|
64
|
+
return nil if raw.blank?
|
|
65
|
+
|
|
66
|
+
causes = parse_json(raw)
|
|
67
|
+
return nil unless causes.is_a?(Array) && causes.any?
|
|
68
|
+
|
|
69
|
+
items = causes.each_with_index.map { |cause, i|
|
|
70
|
+
"#{i + 1}. **#{cause["class_name"]}** — #{cause["message"]}"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
"### Exception Cause Chain\n\n#{items.join("\n")}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def request_context_section
|
|
77
|
+
return nil if @error.request_url.blank?
|
|
78
|
+
|
|
79
|
+
items = []
|
|
80
|
+
items << "- **Controller:** #{@error.controller_name}##{@error.action_name}" if @error.controller_name.present?
|
|
81
|
+
items << "- **Method:** #{@error.http_method}" if @error.http_method.present?
|
|
82
|
+
items << "- **URL:** #{@error.request_url}"
|
|
83
|
+
items << "- **Hostname:** #{@error.hostname}" if @error.hostname.present?
|
|
84
|
+
|
|
85
|
+
"### Request Context\n\n#{items.join("\n")}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def environment_section
|
|
89
|
+
raw = @error.environment_info
|
|
90
|
+
return nil if raw.blank?
|
|
91
|
+
|
|
92
|
+
env = parse_json(raw)
|
|
93
|
+
return nil unless env.is_a?(Hash) && env.any?
|
|
94
|
+
|
|
95
|
+
items = []
|
|
96
|
+
items << "- **Ruby:** #{env["ruby_version"]}" if env["ruby_version"]
|
|
97
|
+
items << "- **Rails:** #{env["rails_version"]}" if env["rails_version"]
|
|
98
|
+
items << "- **Env:** #{env["rails_env"]}" if env["rails_env"]
|
|
99
|
+
|
|
100
|
+
return nil if items.empty?
|
|
101
|
+
|
|
102
|
+
"### Environment\n\n#{items.join("\n")}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def metadata_section
|
|
106
|
+
items = []
|
|
107
|
+
items << "- **Platform:** #{@error.platform}" if @error.platform.present?
|
|
108
|
+
items << "- **First seen:** #{@error.first_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}" if @error.first_seen_at
|
|
109
|
+
items << "- **Occurrences:** #{@error.occurrence_count}" if @error.occurrence_count
|
|
110
|
+
|
|
111
|
+
return nil if items.empty?
|
|
112
|
+
|
|
113
|
+
"### Metadata\n\n#{items.join("\n")}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def dashboard_link_section
|
|
117
|
+
parts = []
|
|
118
|
+
parts << "[View in Dashboard](#{@dashboard_url})" if @dashboard_url.present?
|
|
119
|
+
parts << "Created by [RED](https://github.com/AnjanJ/rails_error_dashboard) (Rails Error Dashboard)"
|
|
120
|
+
|
|
121
|
+
"---\n\n#{parts.join(" | ")}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_json(raw)
|
|
125
|
+
return nil if raw.blank?
|
|
126
|
+
JSON.parse(raw)
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|