diff --git a/Rakefile b/Rakefile index 3ff612b..0dd2636 100644 --- a/Rakefile +++ b/Rakefile @@ -3,7 +3,8 @@ require 'rake/testtask' Rake::TestTask.new do |t| t.libs.push "lib" - t.test_files = FileList['test/*_test.rb'] + t.libs.push "test" + t.test_files = FileList['test/**/*_test.rb'] t.verbose = true end diff --git a/lib/cc/formatters/snapshot_formatter.rb b/lib/cc/formatters/snapshot_formatter.rb new file mode 100644 index 0000000..e8f7704 --- /dev/null +++ b/lib/cc/formatters/snapshot_formatter.rb @@ -0,0 +1,101 @@ +module CC::Formatters + module SnapshotFormatter + # Simple Comparator for rating letters. + class Rating + include Comparable + + def initialize(letter) + @letter = letter + end + + def <=>(other) + other.to_s <=> to_s + end + + def hash + @letter.hash + end + + def eql?(other) + to_s == other.to_s + end + + def inspect + "" + end + + def to_s + @letter.to_s + end + end + + C = Rating.new("C") + D = Rating.new("D") + + # SnapshotFormatter::Base takes the quality information from the payload and divides it + # between alerts and improvements. + # + # The information in the payload must be a comparison in time between two quality reports, aka snapshot. + # This information is in the payload when the service receive a `receive_snapshot` and also + # when it receives a `receive_test`. In this latest case, the comparison is between today and seven days ago. + class Base + attr_reader :alert_constants_payload, :improved_constants_payload, :details_url, :compare_url + + def initialize(payload) + new_constants = Array(payload["new_constants"]) + changed_constants = Array(payload["changed_constants"]) + + alert_constants = new_constants.select(&new_constants_selector) + alert_constants += changed_constants.select(&decreased_constants_selector) + + improved_constants = changed_constants.select(&improved_constants_selector) + + data = { + "from" => { "commit_sha" => payload["previous_commit_sha"] }, + "to" => { "commit_sha" => payload["commit_sha"] } + } + + @alert_constants_payload = data.merge("constants" => alert_constants) if alert_constants.any? + @improved_constants_payload = data.merge("constants" => improved_constants) if improved_constants.any? + end + + private + + def new_constants_selector + Proc.new { |constant| to_rating(constant) < C } + end + + def decreased_constants_selector + Proc.new { |constant| from_rating(constant) > D && to_rating(constant) < C } + end + + def improved_constants_selector + Proc.new { |constant| from_rating(constant) < C && to_rating(constant) > from_rating(constant) } + end + + def to_rating(constant) + Rating.new(constant["to"]["rating"]) + end + + def from_rating(constant) + Rating.new(constant["from"]["rating"]) + end + end + + # Override the base snapshot formatter for be more lax grouping information. + # This is useful to show more information for testing the service. + class Sample < Base + def new_constants_selector + Proc.new { |_| true } + end + + def decreased_constants_selector + Proc.new { |constant| to_rating(constant) < from_rating(constant) } + end + + def improved_constants_selector + Proc.new { |constant| to_rating(constant) > from_rating(constant) } + end + end + end +end diff --git a/lib/cc/helpers/quality_helper.rb b/lib/cc/helpers/quality_helper.rb index 46b8fb1..ffb0205 100644 --- a/lib/cc/helpers/quality_helper.rb +++ b/lib/cc/helpers/quality_helper.rb @@ -23,13 +23,22 @@ def previous_remediation_cost payload.fetch("previous_remediation_cost", 0) end - def with_article(letter) + def with_article(letter, bold = false) letter ||= '?' - if %w( A F ).include?(letter) - "an #{letter}" + text = bold ? "*#{letter}*" : letter + if %w( A F ).include?(letter.to_s) + "an #{text}" else - "a #{letter}" + "a #{text}" + end + end + + def constant_basename(name) + if name.include?(".") + File.basename(name) + else + name end end end diff --git a/lib/cc/service/helper.rb b/lib/cc/service/helper.rb index 2bcecff..d6d2598 100644 --- a/lib/cc/service/helper.rb +++ b/lib/cc/service/helper.rb @@ -1,4 +1,6 @@ module CC::Service::Helper + GREEN_HEX = "#38ae6f" + RED_HEX = "#ed2f00" def repo_name payload["repo_name"] @@ -30,9 +32,9 @@ def color def hex_color if improved? - "#38ae6f" + GREEN_HEX else - "#ed2f00" + RED_HEX end end diff --git a/lib/cc/services/slack.rb b/lib/cc/services/slack.rb index f982447..605dd80 100644 --- a/lib/cc/services/slack.rb +++ b/lib/cc/services/slack.rb @@ -1,4 +1,8 @@ + # encoding: UTF-8 + class CC::Service::Slack < CC::Service + include CC::Service::QualityHelper + class Config < CC::Service::Config attribute :webhook_url, String, label: "Webhook URL", @@ -13,17 +17,20 @@ class Config < CC::Service::Config def receive_test speak(formatter.format_test) + # payloads for test receivers include the weekly quality report. + send_snapshot_to_slack(CC::Formatters::SnapshotFormatter::Sample.new(payload)) + { ok: true, message: "Test message sent" } rescue => ex { ok: false, message: ex.message } end - def receive_coverage - speak(formatter.format_coverage, hex_color) + def receive_snapshot + send_snapshot_to_slack(CC::Formatters::SnapshotFormatter::Base.new(payload)) end - def receive_quality - speak(formatter.format_quality, hex_color) + def receive_coverage + speak(formatter.format_coverage, hex_color) end def receive_vulnerability @@ -51,4 +58,61 @@ def speak(message, color = nil) http.headers['Content-Type'] = 'application/json' http_post(config.webhook_url, body.to_json) end + + def send_snapshot_to_slack(snapshot) + if snapshot.alert_constants_payload + speak(alerts_message(snapshot.alert_constants_payload), RED_HEX) + end + + if snapshot.improved_constants_payload + speak(improvements_message(snapshot.improved_constants_payload), GREEN_HEX) + end + end + + def alerts_message(constants_payload) + constants = constants_payload["constants"] + message = ["Quality alert triggered for *#{repo_name}* (<#{compare_url}|Compare>)\n"] + + constants[0..2].each do |constant| + object_identifier = constant_basename(constant["name"]) + + if constant["from"] + from_rating = constant["from"]["rating"] + to_rating = constant["to"]["rating"] + + message << "• _#{object_identifier}_ just declined from #{with_article(from_rating, :bold)} to #{with_article(to_rating, :bold)}" + else + rating = constant["to"]["rating"] + + message << "• _#{object_identifier}_ was just created and is #{with_article(rating, :bold)}" + end + end + + if constants.size > 3 + remaining = constants.size - 3 + message << "\nAnd <#{details_url}|#{remaining} other #{"change".pluralize(remaining)}>" + end + + message.join("\n") + end + + def improvements_message(constants_payload) + constants = constants_payload["constants"] + message = ["Quality improvements in *#{repo_name}* (<#{compare_url}|Compare>)\n"] + + constants[0..2].each do |constant| + object_identifier = constant_basename(constant["name"]) + from_rating = constant["from"]["rating"] + to_rating = constant["to"]["rating"] + + message << "• _#{object_identifier}_ just improved from #{with_article(from_rating, :bold)} to #{with_article(to_rating, :bold)}" + end + + if constants.size > 3 + remaining = constants.size - 3 + message << "\nAnd <#{details_url}|#{remaining} other #{"improvement".pluralize(remaining)}>" + end + + message.join("\n") + end end diff --git a/test/formatters/snapshot_formatter_test.rb b/test/formatters/snapshot_formatter_test.rb new file mode 100644 index 0000000..c6b729c --- /dev/null +++ b/test/formatters/snapshot_formatter_test.rb @@ -0,0 +1,47 @@ +require "helper" + +class TestSnapshotFormatter < Test::Unit::TestCase + def described_class + CC::Formatters::SnapshotFormatter::Base + end + + def test_quality_alert_with_new_constants + f = described_class.new({"new_constants" => [{"to" => {"rating" => "D"}}], "changed_constants" => []}) + refute_nil f.alert_constants_payload + end + + def test_quality_alert_with_decreased_constants + f = described_class.new({"new_constants" => [], + "changed_constants" => [{"to" => {"rating" => "D"}, "from" => {"rating" => "A"}}] + }) + refute_nil f.alert_constants_payload + end + + def test_quality_improvements_with_better_ratings + f = described_class.new({"new_constants" => [], + "changed_constants" => [{"to" => {"rating" => "A"}, "from" => {"rating" => "D"}}] + }) + refute_nil f.improved_constants_payload + end + + def test_nothing_set_without_changes + f = described_class.new({"new_constants" => [], "changed_constants" => []}) + assert_nil f.alert_constants_payload + assert_nil f.improved_constants_payload + end + + def test_snapshot_formatter_test_with_relaxed_constraints + f = CC::Formatters::SnapshotFormatter::Sample.new({ + "new_constants" => [{"name" => "foo", "to" => {"rating" => "A"}}, {"name" => "bar", "to" => {"rating" => "A"}}], + "changed_constants" => [ + {"from" => {"rating" => "B"}, "to" => {"rating" => "C"}}, + {"from" => {"rating" => "D"}, "to" => {"rating" => "D"}}, + {"from" => {"rating" => "D"}, "to" => {"rating" => "D"}}, + {"from" => {"rating" => "A"}, "to" => {"rating" => "B"}}, + {"from" => {"rating" => "C"}, "to" => {"rating" => "B"}} + ]}) + + refute_nil f.alert_constants_payload + refute_nil f.improved_constants_payload + end +end diff --git a/test/slack_test.rb b/test/slack_test.rb index 473c943..9db6098 100644 --- a/test/slack_test.rb +++ b/test/slack_test.rb @@ -1,3 +1,5 @@ +# encoding: UTF-8 + require File.expand_path('../helper', __FILE__) class TestSlack < CC::Service::TestCase @@ -31,28 +33,6 @@ def test_coverage_declined ].join(" ")) end - def test_quality_improved - e = event(:quality, to: "A", from: "B") - - assert_slack_receives("#38ae6f", e, [ - "[Example]", - "", - "has improved from a B to an A", - "()" - ].join(" ")) - end - - def test_quality_declined_without_compare_url - e = event(:quality, to: "D", from: "C") - - assert_slack_receives("#ed2f00", e, [ - "[Example]", - "", - "has declined from a C to a D", - "()" - ].join(" ")) - end - def test_single_vulnerability e = event(:vulnerability, vulnerabilities: [ { "warning_type" => "critical" } @@ -94,6 +74,96 @@ def test_multiple_vulnerabilities ].join(" ")) end + def test_quality_alert_with_new_constants + data = { "name" => "snapshot", "repo_name" => "Rails", + "new_constants" => [{"name" => "Foo", "to" => {"rating" => "D"}}, {"name" => "bar.js", "to" => {"rating" => "F"}}], + "changed_constants" => [], + "compare_url" => "https://codeclimate.com/repos/1/compare/a...z" } + + assert_slack_receives(CC::Service::Slack::RED_HEX, data, +"""Quality alert triggered for *Rails* () + +• _Foo_ was just created and is a *D* +• _bar.js_ was just created and is an *F*""") + end + + def test_quality_alert_with_new_constants_and_declined_constants + data = { "name" => "snapshot", "repo_name" => "Rails", + "new_constants" => [{"name" => "Foo", "to" => {"rating" => "D"}}], + "changed_constants" => [{"name" => "bar.js", "from" => {"rating" => "A"}, "to" => {"rating" => "F"}}], + "compare_url" => "https://codeclimate.com/repos/1/compare/a...z" } + + assert_slack_receives(CC::Service::Slack::RED_HEX, data, +"""Quality alert triggered for *Rails* () + +• _Foo_ was just created and is a *D* +• _bar.js_ just declined from an *A* to an *F*""") + end + + def test_quality_alert_with_new_constants_and_declined_constants_overflown + data = { "name" => "snapshot", "repo_name" => "Rails", + "new_constants" => [{"name" => "Foo", "to" => {"rating" => "D"}}], + "changed_constants" => [ + {"name" => "bar.js", "from" => {"rating" => "A"}, "to" => {"rating" => "F"}}, + {"name" => "baz.js", "from" => {"rating" => "B"}, "to" => {"rating" => "D"}}, + {"name" => "Qux", "from" => {"rating" => "A"}, "to" => {"rating" => "D"}} + ], + "compare_url" => "https://codeclimate.com/repos/1/compare/a...z", + "details_url" => "https://codeclimate.com/repos/1/feed" + } + + + assert_slack_receives(CC::Service::Slack::RED_HEX, data, +"""Quality alert triggered for *Rails* () + +• _Foo_ was just created and is a *D* +• _bar.js_ just declined from an *A* to an *F* +• _baz.js_ just declined from a *B* to a *D* + +And """) + end + + def test_quality_improvements + data = { "name" => "snapshot", "repo_name" => "Rails", + "new_constants" => [], + "changed_constants" => [ + {"name" => "bar.js", "from" => {"rating" => "F"}, "to" => {"rating" => "A"}}, + ], + "compare_url" => "https://codeclimate.com/repos/1/compare/a...z", + "details_url" => "https://codeclimate.com/repos/1/feed" + } + + + assert_slack_receives(CC::Service::Slack::GREEN_HEX, data, +"""Quality improvements in *Rails* () + +• _bar.js_ just improved from an *F* to an *A*""") + end + + def test_quality_improvements_overflown + data = { "name" => "snapshot", "repo_name" => "Rails", + "new_constants" => [], + "changed_constants" => [ + {"name" => "Foo", "from" => {"rating" => "F"}, "to" => {"rating" => "A"}}, + {"name" => "bar.js", "from" => {"rating" => "D"}, "to" => {"rating" => "B"}}, + {"name" => "baz.js", "from" => {"rating" => "D"}, "to" => {"rating" => "A"}}, + {"name" => "Qux", "from" => {"rating" => "F"}, "to" => {"rating" => "A"}}, + ], + "compare_url" => "https://codeclimate.com/repos/1/compare/a...z", + "details_url" => "https://codeclimate.com/repos/1/feed" + } + + + assert_slack_receives(CC::Service::Slack::GREEN_HEX, data, +"""Quality improvements in *Rails* () + +• _Foo_ just improved from an *F* to an *A* +• _bar.js_ just improved from a *D* to a *B* +• _baz.js_ just improved from a *D* to an *A* + +And """) + end + private def assert_slack_receives(color, event_data, expected_body)