-
Notifications
You must be signed in to change notification settings - Fork 0
Add Slack mute button and mute notification suppression #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing error handling for If 🛡️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider extracting shared block manipulation logic. The ♻️ 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 |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| replace_original_message(payload, updated_blocks) | ||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def respond_to_slack(payload, text) | ||||||||||||||||||||||||||
| response_url = payload["response_url"] | ||||||||||||||||||||||||||
| return head :ok unless response_url | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" doOr 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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 | ||
There was a problem hiding this comment.
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
📝 Committable suggestion
🤖 Prompt for AI Agents