Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ gem "propshaft"
gem "pg", "~> 1.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
gem "rails_error_dashboard"
gem "rails_error_dashboard", github: "j4rs/rails_error_dashboard", branch: "feature/mute-errors"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add a TODO comment to track switching back to the official gem.

Using a GitHub fork branch is appropriate for development, but there's no inline reminder to switch back once the upstream PR is merged. This could be forgotten and lead to running a divergent fork in production long-term.

📝 Proposed fix
-gem "rails_error_dashboard", github: "j4rs/rails_error_dashboard", branch: "feature/mute-errors"
+# TODO: Switch back to official gem once https://github.com/AnjanJ/rails_error_dashboard/pull/92 is merged
+gem "rails_error_dashboard", github: "j4rs/rails_error_dashboard", branch: "feature/mute-errors"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
gem "rails_error_dashboard", github: "j4rs/rails_error_dashboard", branch: "feature/mute-errors"
# TODO: Switch back to official gem once https://github.com/AnjanJ/rails_error_dashboard/pull/92 is merged
gem "rails_error_dashboard", github: "j4rs/rails_error_dashboard", branch: "feature/mute-errors"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Gemfile` at line 11, Add an inline TODO comment next to the gem declaration
for rails_error_dashboard (the gem "rails_error_dashboard", github:
"j4rs/rails_error_dashboard", branch: "feature/mute-errors") that notes to
switch back to the official gem once the upstream PR is merged (include PR link
or issue ID if known and an owner or date to revisit); this will make it easy to
find and revert the GitHub-forked branch when the upstream change lands.

gem "solid_queue"
gem "mission_control-jobs"

Expand Down
18 changes: 12 additions & 6 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
GIT
remote: https://github.com/j4rs/rails_error_dashboard.git
revision: 090963e11b0dc13132dae4f10170f69c2f2fa462
branch: feature/mute-errors
specs:
rails_error_dashboard (0.4.1)
concurrent-ruby (~> 1.3.0, < 1.3.7)
groupdate (~> 6.0)
pagy (~> 43.0)
rails (>= 7.0.0)

GEM
remote: https://rubygems.org/
specs:
Expand Down Expand Up @@ -221,11 +232,6 @@ GEM
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails_error_dashboard (0.4.1)
concurrent-ruby (~> 1.3.0, < 1.3.7)
groupdate (~> 6.0)
pagy (~> 43.0)
rails (>= 7.0.0)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
Expand Down Expand Up @@ -311,7 +317,7 @@ DEPENDENCIES
propshaft
puma (>= 5.0)
rails (~> 8.1.2)
rails_error_dashboard
rails_error_dashboard!
rspec-rails
solid_queue
tzinfo-data
Expand Down
43 changes: 43 additions & 0 deletions app/controllers/slack_interactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ def create
case action&.dig("action_id")
when /^resolve_error_(\d+)$/
resolve_error($1.to_i, payload)
when /^mute_error_(\d+)$/
mute_error($1.to_i, payload)
else
head :ok
end
Expand Down Expand Up @@ -61,6 +63,47 @@ def resolve_error(error_id, payload)
replace_original_message(payload, updated_blocks)
end

def mute_error(error_id, payload)
error = RailsErrorDashboard::ErrorLog.find_by(id: error_id)

unless error
respond_to_slack(payload, "Error ##{error_id} not found.")
return
end

if error.muted?
respond_to_slack(payload, "Already muted.")
return
end

username = payload.dig("user", "username") || "someone"
RailsErrorDashboard::Commands::MuteError.call(error_id, muted_by: username, reason: "Muted from Slack")

Comment on lines +79 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for MuteError.call.

If RailsErrorDashboard::Commands::MuteError.call raises an exception or fails silently, the Slack message will still be updated to show "Muted" even though the mute operation may not have succeeded. Consider wrapping this in error handling or verifying the result.

🛡️ Proposed fix
     username = payload.dig("user", "username") || "someone"
-    RailsErrorDashboard::Commands::MuteError.call(error_id, muted_by: username, reason: "Muted from Slack")
+    begin
+      RailsErrorDashboard::Commands::MuteError.call(error_id, muted_by: username, reason: "Muted from Slack")
+    rescue => e
+      Rails.logger.error("[SlackInteractions] MuteError failed: #{e.message}")
+      respond_to_slack(payload, "Failed to mute error: #{e.message}")
+      return
+    end
 
     user_mention = "@#{username}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
username = payload.dig("user", "username") || "someone"
RailsErrorDashboard::Commands::MuteError.call(error_id, muted_by: username, reason: "Muted from Slack")
username = payload.dig("user", "username") || "someone"
begin
RailsErrorDashboard::Commands::MuteError.call(error_id, muted_by: username, reason: "Muted from Slack")
rescue => e
Rails.logger.error("[SlackInteractions] MuteError failed: #{e.message}")
respond_to_slack(payload, "Failed to mute error: #{e.message}")
return
end
user_mention = "@#{username}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/slack_interactions_controller.rb` around lines 79 - 81, Wrap
the call to RailsErrorDashboard::Commands::MuteError.call(error_id, muted_by:
username, reason: "Muted from Slack") in error handling: invoke the command
inside a begin/rescue block (or handle the returned result if the command
returns a status), log or notify failures (using Rails.logger or an existing
logger) including the error/exception and error_id, and only update the Slack
message to "Muted" when the mute call succeeds; on failure update Slack with an
error/failure message so the user sees the correct outcome.

user_mention = "@#{username}"

# Replace the Mute button with a muted label
original_blocks = payload.dig("message", "blocks") || []
updated_blocks = original_blocks.flat_map do |block|
if block["type"] == "actions"
# Keep View Source and Resolve buttons, remove Mute button
remaining = block["elements"]&.reject { |e| e["action_id"]&.start_with?("mute_error_") } || []
[
{ type: "actions", elements: remaining },
{
type: "context",
elements: [
{ type: "mrkdwn", text: ":bell_slash: *Muted* by #{user_mention}" }
]
}
]
else
[block]
end
end
Comment on lines +86 to +102
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extracting shared block manipulation logic.

The flat_map pattern for updating Slack message blocks is duplicated between resolve_error (lines 46-61) and mute_error (lines 86-102). Extracting a helper method would reduce cyclomatic complexity and improve maintainability.

♻️ Proposed refactor
# Add this private method:
def update_action_blocks(original_blocks, filter_action_prefix:, context_text:)
  original_blocks.flat_map do |block|
    if block["type"] == "actions"
      remaining = block["elements"]&.reject { |e| e["action_id"]&.start_with?(filter_action_prefix) } || []
      [
        { type: "actions", elements: remaining },
        { type: "context", elements: [{ type: "mrkdwn", text: context_text }] }
      ]
    else
      [block]
    end
  end
end

# Then in mute_error:
updated_blocks = update_action_blocks(
  original_blocks,
  filter_action_prefix: "mute_error_",
  context_text: ":bell_slash: *Muted* by #{user_mention}"
)
🧰 Tools
🪛 RuboCop (1.85.1)

[convention] 95-95: Put a comma after the last item of a multiline array.

(Style/TrailingCommaInArrayLiteral)


[convention] 97-97: Put a comma after the last item of a multiline array.

(Style/TrailingCommaInArrayLiteral)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/slack_interactions_controller.rb` around lines 86 - 102, The
duplicated flat_map block-manipulation in mute_error and resolve_error should be
extracted into a private helper (e.g., update_action_blocks) to reduce
duplication and cyclomatic complexity; implement
update_action_blocks(original_blocks, filter_action_prefix:, context_text:) that
performs the flat_map, rejects elements by action_id prefix and appends a
context block, then replace the inline logic in both mute_error and
resolve_error to call this helper passing "mute_error_" / "resolve_error_" (or
appropriate prefixes) and the respective context_text (e.g., ":bell_slash:
*Muted* by #{user_mention}").


replace_original_message(payload, updated_blocks)
end

def respond_to_slack(payload, text)
response_url = payload["response_url"]
return head :ok unless response_url
Expand Down
15 changes: 15 additions & 0 deletions config/initializers/rails_error_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,21 @@ def actions_block(error_log)
deny: { type: "plain_text", text: "Cancel" }
}
}
block[:elements] << {
type: "button",
text: {
type: "plain_text",
text: "Mute",
emoji: true
},
action_id: "mute_error_#{error_log.id}",
confirm: {
title: { type: "plain_text", text: "Mute notifications?" },
text: { type: "mrkdwn", text: "Stop notifications for *#{error_log.error_type}*? The error will still be tracked in the dashboard." },
confirm: { type: "plain_text", text: "Mute" },
deny: { type: "plain_text", text: "Cancel" }
}
}
block
end
end
Expand Down
78 changes: 78 additions & 0 deletions spec/integration/mute_notification_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe "Muted error notification suppression", type: :request do
let(:token) { "test-token" }
let(:headers) { { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" } }
let(:application) { RailsErrorDashboard::Application.find_or_create_by!(name: "getonbrd") }

before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with("API_BEARER_TOKEN").and_return(token)

RailsErrorDashboard.configure do |config|
config.enable_slack_notifications = true
config.slack_webhook_url = "https://hooks.slack.com/test"
config.notification_cooldown_minutes = 0
config.async_logging = false
end
end

let(:error_payload) do
{
error: {
error_type: "TestMuteError",
message: "This error should be muted",
severity: "error",
platform: "ruby",
backtrace: ["app/test.rb:1:in `test`"],
occurred_at: Time.current.iso8601
}
}
end

it "sends Slack notification for unmuted errors" do
expect(RailsErrorDashboard::SlackErrorNotificationJob).to receive(:perform_later).at_least(:once)

post "/api/v1/errors", params: error_payload.to_json, headers: headers
expect(response).to have_http_status(:created)
end

it "does NOT send Slack notification for muted errors" do
# Create the error first
post "/api/v1/errors", params: error_payload.to_json, headers: headers
error_log = RailsErrorDashboard::ErrorLog.last

# Mute it
error_log.update!(muted: true, muted_at: Time.current)

# Send the same error again — deduplication increments occurrence_count on the muted record
expect(RailsErrorDashboard::SlackErrorNotificationJob).not_to receive(:perform_later)

post "/api/v1/errors", params: error_payload.to_json, headers: headers
expect(response).to have_http_status(:created)

# Verify it was deduplicated (same record, higher count)
expect(error_log.reload.occurrence_count).to be >= 2
expect(error_log.muted).to be true
end

it "resumes Slack notifications after unmuting" do
# Create and mute the error
post "/api/v1/errors", params: error_payload.to_json, headers: headers
error_log = RailsErrorDashboard::ErrorLog.last
error_log.update!(muted: true, muted_at: Time.current)

# Unmute it
error_log.update!(muted: false, muted_at: nil)

# Resolve it so the next occurrence triggers a "reopened" notification
error_log.update!(resolved: true, resolved_at: Time.current)

expect(RailsErrorDashboard::SlackErrorNotificationJob).to receive(:perform_later).at_least(:once)

post "/api/v1/errors", params: error_payload.to_json, headers: headers
expect(response).to have_http_status(:created)
end
Comment on lines +61 to +77
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Test name doesn't fully reflect the test scenario.

The test involves both unmuting and resolving the error (lines 68-71), but the test name only mentions "unmuting". The resolve step is necessary to trigger a notification (reopened scenario), but this could confuse future readers.

📝 Proposed improvement
-  it "resumes Slack notifications after unmuting" do
+  it "resumes Slack notifications after unmuting and reopening a resolved error" do

Or add a comment explaining why the resolve step is necessary:

     # Unmute it
     error_log.update!(muted: false, muted_at: nil)
 
-    # Resolve it so the next occurrence triggers a "reopened" notification
+    # Mark as resolved so the next occurrence triggers a notification
+    # (deduplicated errors only notify on first occurrence or when reopened)
     error_log.update!(resolved: true, resolved_at: Time.current)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it "resumes Slack notifications after unmuting" do
# Create and mute the error
post "/api/v1/errors", params: error_payload.to_json, headers: headers
error_log = RailsErrorDashboard::ErrorLog.last
error_log.update!(muted: true, muted_at: Time.current)
# Unmute it
error_log.update!(muted: false, muted_at: nil)
# Resolve it so the next occurrence triggers a "reopened" notification
error_log.update!(resolved: true, resolved_at: Time.current)
expect(RailsErrorDashboard::SlackErrorNotificationJob).to receive(:perform_later).at_least(:once)
post "/api/v1/errors", params: error_payload.to_json, headers: headers
expect(response).to have_http_status(:created)
end
it "resumes Slack notifications after unmuting and reopening a resolved error" do
# Create and mute the error
post "/api/v1/errors", params: error_payload.to_json, headers: headers
error_log = RailsErrorDashboard::ErrorLog.last
error_log.update!(muted: true, muted_at: Time.current)
# Unmute it
error_log.update!(muted: false, muted_at: nil)
# Resolve it so the next occurrence triggers a "reopened" notification
error_log.update!(resolved: true, resolved_at: Time.current)
expect(RailsErrorDashboard::SlackErrorNotificationJob).to receive(:perform_later).at_least(:once)
post "/api/v1/errors", params: error_payload.to_json, headers: headers
expect(response).to have_http_status(:created)
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/integration/mute_notification_spec.rb` around lines 61 - 77, The test
description "resumes Slack notifications after unmuting" is misleading because
the spec also resolves the error to trigger a reopened notification; either
update the it block description to something like "resumes Slack notifications
after unmuting and resolving (reopened)" or add a one-line comment above the
resolve step (the error_log.update!(resolved: true, resolved_at: Time.current)
call) explaining that resolving is required to generate a "reopened"
notification on the next occurrence; change the string or add the comment in the
it block containing the SlackErrorNotificationJob expectation to make the intent
clear.

end
109 changes: 109 additions & 0 deletions spec/requests/slack_interactions_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

require "rails_helper"
require "net/http"

RSpec.describe "Slack Interactions", type: :request do
let(:signing_secret) { "test-signing-secret" }
let(:application) { RailsErrorDashboard::Application.find_or_create_by!(name: "getonbrd") }

before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("SLACK_SIGNING_SECRET").and_return(signing_secret)
allow(RailsErrorDashboard::Commands::LogError).to receive(:call).and_return(nil)

# Stub all outbound HTTP to Slack response_url
allow_any_instance_of(Net::HTTP).to receive(:request).and_return(
instance_double(Net::HTTPSuccess, code: "200", body: "ok")
)
end

def build_body(action_id:, error_id: 1, username: "testuser")
payload = {
actions: [{ action_id: action_id }],
user: { username: username },
response_url: "https://hooks.slack.com/actions/test/response",
message: {
ts: "1234567890.123456",
blocks: [
{ type: "header", text: { type: "plain_text", text: "TestError" } },
{
type: "actions",
elements: [
{ url: "https://github.com/test", type: "button", text: { type: "plain_text", text: "View Source" } },
{ action_id: "resolve_error_#{error_id}", type: "button", text: { type: "plain_text", text: "Resolve" } },
{ action_id: "mute_error_#{error_id}", type: "button", text: { type: "plain_text", text: "Mute" } }
]
}
]
}
}
"payload=#{CGI.escape(payload.to_json)}"
end

def signed_headers(body)
timestamp = Time.now.to_i.to_s
sig_basestring = "v0:#{timestamp}:#{body}"
signature = "v0=#{OpenSSL::HMAC.hexdigest("SHA256", signing_secret, sig_basestring)}"

{
"Content-Type" => "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp" => timestamp,
"X-Slack-Signature" => signature
}
end

describe "mute action" do
let!(:error_log) do
RailsErrorDashboard::ErrorLog.create!(
application: application,
error_type: "TestError",
message: "test",
platform: "ruby",
occurred_at: Time.current,
occurrence_count: 1,
priority_level: 0
)
end

it "mutes the error and records who muted it" do
body = build_body(action_id: "mute_error_#{error_log.id}", error_id: error_log.id)

post "/slack/interactions", params: body, headers: signed_headers(body)

expect(response).to have_http_status(:ok)
expect(error_log.reload.muted).to be true
expect(error_log.muted_by).to eq("testuser")
expect(error_log.muted_reason).to eq("Muted from Slack")
end

it "responds with already muted when error is muted" do
error_log.update!(muted: true, muted_at: Time.current)
body = build_body(action_id: "mute_error_#{error_log.id}", error_id: error_log.id)

post "/slack/interactions", params: body, headers: signed_headers(body)

expect(response).to have_http_status(:ok)
end
Comment on lines +80 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Strengthen the "already muted" test by verifying no state change.

The test confirms the response status but doesn't verify that the error record wasn't modified. Adding an assertion would ensure the idempotent behavior is properly tested.

💚 Proposed improvement
     it "responds with already muted when error is muted" do
       error_log.update!(muted: true, muted_at: Time.current)
+      original_muted_at = error_log.muted_at
       body = build_body(action_id: "mute_error_#{error_log.id}", error_id: error_log.id)
 
       post "/slack/interactions", params: body, headers: signed_headers(body)
 
       expect(response).to have_http_status(:ok)
+      expect(error_log.reload.muted_at).to eq(original_muted_at)
     end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/requests/slack_interactions_spec.rb` around lines 80 - 87, The test for
"responds with already muted when error is muted" currently only checks the HTTP
status; capture the error_log's pre-request state (e.g., muted and muted_at),
perform the POST to "/slack/interactions" using build_body(action_id:
"mute_error_#{error_log.id}", error_id: error_log.id) and signed_headers(body),
then reload the error_log and assert that muted remains true and muted_at did
not change (or equals the previously saved timestamp) to ensure no state change;
reference the error_log variable, build_body helper, and the POST to
"/slack/interactions" when adding the assertion.


it "responds with not found for invalid error" do
body = build_body(action_id: "mute_error_999999", error_id: 999999)

post "/slack/interactions", params: body, headers: signed_headers(body)

expect(response).to have_http_status(:ok)
end

it "rejects requests without valid signature" do
body = build_body(action_id: "mute_error_#{error_log.id}", error_id: error_log.id)

post "/slack/interactions", params: body, headers: {
"Content-Type" => "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp" => Time.now.to_i.to_s,
"X-Slack-Signature" => "v0=invalid"
}

expect(response).to have_http_status(:unauthorized)
end
end
end