codeclimate-services 0.3.0

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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.ruby-version +1 -0
  4. data/.travis.yml +13 -0
  5. data/Gemfile +7 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +121 -0
  8. data/Rakefile +11 -0
  9. data/bin/bundler +16 -0
  10. data/bin/coderay +16 -0
  11. data/bin/nokogiri +16 -0
  12. data/bin/pry +16 -0
  13. data/bin/rake +16 -0
  14. data/codeclimate-services.gemspec +29 -0
  15. data/config/cacert.pem +4026 -0
  16. data/config/load.rb +4 -0
  17. data/lib/axiom/types/password.rb +7 -0
  18. data/lib/cc/formatters/linked_formatter.rb +60 -0
  19. data/lib/cc/formatters/plain_formatter.rb +36 -0
  20. data/lib/cc/formatters/snapshot_formatter.rb +101 -0
  21. data/lib/cc/formatters/ticket_formatter.rb +27 -0
  22. data/lib/cc/helpers/coverage_helper.rb +25 -0
  23. data/lib/cc/helpers/issue_helper.rb +9 -0
  24. data/lib/cc/helpers/quality_helper.rb +44 -0
  25. data/lib/cc/helpers/vulnerability_helper.rb +31 -0
  26. data/lib/cc/presenters/github_pull_requests_presenter.rb +54 -0
  27. data/lib/cc/service/config.rb +4 -0
  28. data/lib/cc/service/formatter.rb +34 -0
  29. data/lib/cc/service/helper.rb +54 -0
  30. data/lib/cc/service/http.rb +87 -0
  31. data/lib/cc/service/invocation/invocation_chain.rb +15 -0
  32. data/lib/cc/service/invocation/with_error_handling.rb +45 -0
  33. data/lib/cc/service/invocation/with_metrics.rb +37 -0
  34. data/lib/cc/service/invocation/with_retries.rb +17 -0
  35. data/lib/cc/service/invocation/with_return_values.rb +18 -0
  36. data/lib/cc/service/invocation.rb +57 -0
  37. data/lib/cc/service/response_check.rb +42 -0
  38. data/lib/cc/service.rb +127 -0
  39. data/lib/cc/services/asana.rb +90 -0
  40. data/lib/cc/services/campfire.rb +55 -0
  41. data/lib/cc/services/flowdock.rb +61 -0
  42. data/lib/cc/services/github_issues.rb +80 -0
  43. data/lib/cc/services/github_pull_requests.rb +210 -0
  44. data/lib/cc/services/hipchat.rb +57 -0
  45. data/lib/cc/services/jira.rb +93 -0
  46. data/lib/cc/services/lighthouse.rb +79 -0
  47. data/lib/cc/services/pivotal_tracker.rb +78 -0
  48. data/lib/cc/services/slack.rb +124 -0
  49. data/lib/cc/services/version.rb +5 -0
  50. data/lib/cc/services.rb +9 -0
  51. data/pull_request_test.rb +47 -0
  52. data/service_test.rb +86 -0
  53. data/test/asana_test.rb +85 -0
  54. data/test/axiom/types/password_test.rb +22 -0
  55. data/test/campfire_test.rb +144 -0
  56. data/test/fixtures.rb +68 -0
  57. data/test/flowdock_test.rb +148 -0
  58. data/test/formatters/snapshot_formatter_test.rb +47 -0
  59. data/test/github_issues_test.rb +96 -0
  60. data/test/github_pull_requests_test.rb +293 -0
  61. data/test/helper.rb +50 -0
  62. data/test/hipchat_test.rb +130 -0
  63. data/test/invocation_error_handling_test.rb +51 -0
  64. data/test/invocation_return_values_test.rb +21 -0
  65. data/test/invocation_test.rb +167 -0
  66. data/test/jira_test.rb +80 -0
  67. data/test/lighthouse_test.rb +74 -0
  68. data/test/pivotal_tracker_test.rb +73 -0
  69. data/test/presenters/github_pull_requests_presenter_test.rb +49 -0
  70. data/test/service_test.rb +63 -0
  71. data/test/slack_test.rb +222 -0
  72. data/test/support/fake_logger.rb +11 -0
  73. data/test/with_metrics_test.rb +19 -0
  74. metadata +263 -0
@@ -0,0 +1,17 @@
1
+ class CC::Service::Invocation
2
+ class WithRetries
3
+ def initialize(invocation, retries)
4
+ @invocation = invocation
5
+ @retries = retries
6
+ end
7
+
8
+ def call
9
+ @invocation.call
10
+ rescue => ex
11
+ raise ex if @retries.zero?
12
+
13
+ @retries -= 1
14
+ retry
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ class CC::Service::Invocation
2
+ class WithReturnValues
3
+ def initialize(invocation, message = nil)
4
+ @invocation = invocation
5
+ @message = message || "An internal error happened"
6
+ end
7
+
8
+ def call
9
+ result = @invocation.call
10
+ if result.nil?
11
+ { ok: false, message: @message }
12
+ else
13
+ result
14
+ end
15
+ end
16
+ end
17
+ end
18
+
@@ -0,0 +1,57 @@
1
+ require 'cc/service/invocation/invocation_chain'
2
+ require 'cc/service/invocation/with_retries'
3
+ require 'cc/service/invocation/with_metrics'
4
+ require 'cc/service/invocation/with_error_handling'
5
+ require 'cc/service/invocation/with_return_values'
6
+
7
+ class CC::Service::Invocation
8
+ MIDDLEWARE = {
9
+ retries: WithRetries,
10
+ metrics: WithMetrics,
11
+ error_handling: WithErrorHandling,
12
+ return_values: WithReturnValues,
13
+ }
14
+
15
+ attr_reader :result
16
+
17
+ # Build a chain of invocation wrappers which eventually calls receive
18
+ # on the given service, then execute that chain.
19
+ #
20
+ # Order is important. Each call to #with, wraps the last.
21
+ #
22
+ # Usage:
23
+ #
24
+ # CC::Service::Invocation.invoke(service) do |i|
25
+ # i.with :retries, 3
26
+ # i.with :metrics, $statsd
27
+ # i.with :error_handling, Rails.logger
28
+ # end
29
+ #
30
+ # In the above example, service.receive could happen 4 times (once,
31
+ # then three retries) before an exception is re-raised up to the
32
+ # metrics collector, then up again to the error handling. If the order
33
+ # were reversed, the error handling middleware would prevent the other
34
+ # middleware from seeing any exceptions at all.
35
+ def self.invoke(service, &block)
36
+ instance = new(service, &block)
37
+ instance.result
38
+ end
39
+
40
+ def initialize(service)
41
+ @chain = InvocationChain.new { service.receive }
42
+
43
+ yield(self) if block_given?
44
+
45
+ @result = @chain.call
46
+ end
47
+
48
+ def with(middleware, *args)
49
+ if klass = MIDDLEWARE[middleware]
50
+ wrap(klass, *args)
51
+ end
52
+ end
53
+
54
+ def wrap(klass, *args)
55
+ @chain.wrap(klass, *args)
56
+ end
57
+ end
@@ -0,0 +1,42 @@
1
+ class CC::Service
2
+ class HTTPError < StandardError
3
+ attr_reader :response_body, :status, :params, :endpoint_url
4
+ attr_accessor :user_message
5
+
6
+ def initialize(message, env)
7
+ @response_body = env[:body]
8
+ @status = env[:status]
9
+ @params = env[:params]
10
+ @endpoint_url = env[:url].to_s
11
+
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ class ResponseCheck < Faraday::Response::Middleware
17
+ ErrorStatuses = 400...600
18
+
19
+ def on_complete(env)
20
+ if ErrorStatuses === env[:status]
21
+ message = error_message(env) ||
22
+ "API request unsuccessful (#{env[:status]})"
23
+
24
+ raise HTTPError.new(message, env)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def error_message(env)
31
+ # We only handle Jira (or responses which look like Jira's). We will add
32
+ # more logic here over time to account for other service's typical error
33
+ # responses as we see them.
34
+ if env[:response_headers]["content-type"] =~ /application\/json/
35
+ errors = JSON.parse(env[:body])["errors"]
36
+ errors.is_a?(Hash) && errors.values.map(&:capitalize).join(", ")
37
+ end
38
+ rescue JSON::ParserError
39
+ end
40
+
41
+ end
42
+ end
data/lib/cc/service.rb ADDED
@@ -0,0 +1,127 @@
1
+ module CC
2
+ class Service
3
+ require "cc/service/config"
4
+ require "cc/service/http"
5
+ require "cc/service/helper"
6
+ require "cc/service/formatter"
7
+ require "cc/service/invocation"
8
+ require "axiom/types/password"
9
+
10
+ dir = File.expand_path '../helpers', __FILE__
11
+ Dir["#{dir}/*_helper.rb"].sort.each do |helper|
12
+ require helper
13
+ end
14
+
15
+ dir = File.expand_path '../formatters', __FILE__
16
+ Dir["#{dir}/*_formatter.rb"].sort.each do |formatter|
17
+ require formatter
18
+ end
19
+
20
+ def self.load_services
21
+ path = File.expand_path("../services/**/*.rb", __FILE__)
22
+ Dir[path].sort.each { |lib| require(lib) }
23
+ end
24
+
25
+ Error = Class.new(StandardError)
26
+ ConfigurationError = Class.new(Error)
27
+
28
+ include HTTP
29
+ include Helper
30
+
31
+ attr_reader :event, :config, :payload
32
+
33
+ ALL_EVENTS = %w[test unit coverage quality vulnerability snapshot pull_request issue]
34
+
35
+ # Tracks the defined services.
36
+ def self.services
37
+ @services ||= []
38
+ end
39
+
40
+ def self.inherited(svc)
41
+ Service.services << svc
42
+ super
43
+ end
44
+
45
+ def self.by_slug(slug)
46
+ services.detect { |s| s.slug == slug }
47
+ end
48
+
49
+ class << self
50
+ attr_writer :title
51
+ attr_accessor :description
52
+ attr_accessor :issue_tracker
53
+ end
54
+
55
+ def self.title
56
+ @title ||= begin
57
+ hook = name.dup
58
+ hook.sub! /.*:/, ''
59
+ hook
60
+ end
61
+ end
62
+
63
+ def self.slug
64
+ @slug ||= begin
65
+ hook = name.dup
66
+ hook.downcase!
67
+ hook.sub! /.*:/, ''
68
+ hook
69
+ end
70
+ end
71
+
72
+ def initialize(config, payload)
73
+ @payload = payload.stringify_keys
74
+ @config = create_config(config)
75
+ @event = @payload["name"].to_s
76
+
77
+ load_helper
78
+ validate_event
79
+ end
80
+
81
+ def receive
82
+ methods = [:receive_event, :"receive_#{event}"]
83
+
84
+ methods.each do |method|
85
+ if respond_to?(method)
86
+ return public_send(method)
87
+ end
88
+ end
89
+
90
+ { ok: false, ignored: true, message: "No service handler found" }
91
+ end
92
+
93
+ private
94
+
95
+ def load_helper
96
+ helper_name = "#{event.classify}Helper"
97
+
98
+ if Service.const_defined?(helper_name)
99
+ @helper = Service.const_get(helper_name)
100
+ extend @helper
101
+ end
102
+ end
103
+
104
+ def validate_event
105
+ unless ALL_EVENTS.include?(event)
106
+ raise ArgumentError.new("Invalid event: #{event}")
107
+ end
108
+ end
109
+
110
+ def create_config(config)
111
+ config_class.new(config).tap do |c|
112
+ unless c.valid?
113
+ raise ConfigurationError, "Invalid config: #{config.inspect}"
114
+ end
115
+ end
116
+ end
117
+
118
+ def config_class
119
+ if defined?("#{self.class.name}::Config")
120
+ "#{self.class.name}::Config".constantize
121
+ else
122
+ Config
123
+ end
124
+ end
125
+
126
+ end
127
+ end
@@ -0,0 +1,90 @@
1
+ class CC::Service::Asana < CC::Service
2
+ class Config < CC::Service::Config
3
+ attribute :api_key, String, label: "API key"
4
+
5
+ attribute :workspace_id, String, label: "Workspace ID"
6
+
7
+ attribute :project_id, String, label: "Project ID",
8
+ description: "(optional)"
9
+
10
+ attribute :assignee, String, label: "Assignee",
11
+ description: "Assignee email address (optional)"
12
+
13
+ validates :api_key, presence: true
14
+ validates :workspace_id, presence: true
15
+ end
16
+
17
+ ENDPOINT = "https://app.asana.com/api/1.0/tasks"
18
+
19
+ self.title = "Asana"
20
+ self.description = "Create tasks in Asana"
21
+ self.issue_tracker = true
22
+
23
+ def receive_test
24
+ result = create_task("Test task from Code Climate")
25
+ result.merge(
26
+ message: "Ticket <a href='#{result[:url]}'>#{result[:id]}</a> created."
27
+ )
28
+ rescue CC::Service::HTTPError => ex
29
+ body = JSON.parse(ex.response_body)
30
+ ex.user_message = body["errors"].map{|e| e["message"] }.join(" ")
31
+ raise ex
32
+ end
33
+
34
+ def receive_issue
35
+ title = %{Fix "#{issue["check_name"]}" issue in #{constant_name}}
36
+
37
+ body = [issue["description"], details_url].join("\n\n")
38
+
39
+ create_task(title, body)
40
+ end
41
+
42
+ def receive_quality
43
+ title = "Refactor #{constant_name} from #{rating} on Code Climate"
44
+
45
+ create_task("#{title} - #{details_url}")
46
+ end
47
+
48
+ def receive_vulnerability
49
+ formatter = CC::Formatters::TicketFormatter.new(self)
50
+ title = formatter.format_vulnerability_title
51
+
52
+ create_task("#{title} - #{details_url}")
53
+ end
54
+
55
+ private
56
+
57
+ def create_task(name, notes = nil)
58
+ params = generate_params(name, notes)
59
+ authenticate_http
60
+ http.headers["Content-Type"] = "application/json"
61
+ service_post(ENDPOINT, params.to_json) do |response|
62
+ body = JSON.parse(response.body)
63
+ id = body['data']['id']
64
+ url = "https://app.asana.com/0/#{config.workspace_id}/#{id}"
65
+ { id: id, url: url }
66
+ end
67
+ end
68
+
69
+ def generate_params(name, notes = nil)
70
+ params = {
71
+ data: { workspace: config.workspace_id, name: name, notes: notes }
72
+ }
73
+
74
+ if config.project_id.present?
75
+ # Note this is undocumented, found via trial & error
76
+ params[:data][:projects] = [config.project_id]
77
+ end
78
+
79
+ if config.assignee.present?
80
+ params[:data][:assignee] = config.assignee
81
+ end
82
+
83
+ params
84
+ end
85
+
86
+ def authenticate_http
87
+ http.basic_auth(config.api_key, "")
88
+ end
89
+
90
+ end
@@ -0,0 +1,55 @@
1
+ class CC::Service::Campfire < CC::Service
2
+ class Config < CC::Service::Config
3
+ attribute :subdomain, String,
4
+ description: "The Campfire subdomain for the account"
5
+ attribute :token, String,
6
+ description: "Your Campfire API auth token"
7
+ attribute :room_id, String,
8
+ description: "Check your campfire URL for a room ID. Usually 6 digits."
9
+
10
+ validates :subdomain, presence: true
11
+ validates :room_id, presence: true
12
+ validates :token, presence: true
13
+ end
14
+
15
+ self.description = "Send messages to a Campfire chat room"
16
+
17
+ def receive_test
18
+ speak(formatter.format_test).merge(
19
+ message: "Test message sent"
20
+ )
21
+ end
22
+
23
+ def receive_coverage
24
+ speak(formatter.format_coverage)
25
+ end
26
+
27
+ def receive_quality
28
+ speak(formatter.format_quality)
29
+ end
30
+
31
+ def receive_vulnerability
32
+ speak(formatter.format_vulnerability)
33
+ end
34
+
35
+ private
36
+
37
+ def formatter
38
+ CC::Formatters::PlainFormatter.new(self)
39
+ end
40
+
41
+ def speak(line)
42
+ http.headers['Content-Type'] = 'application/json'
43
+ params = { message: { body: line } }
44
+
45
+ http.basic_auth(config.token, "X")
46
+ service_post(speak_uri, params.to_json)
47
+ end
48
+
49
+ def speak_uri
50
+ subdomain = config.subdomain
51
+ room_id = config.room_id
52
+ "https://#{subdomain}.campfirenow.com/room/#{room_id}/speak.json"
53
+ end
54
+
55
+ end
@@ -0,0 +1,61 @@
1
+ class CC::Service::Flowdock < CC::Service
2
+ class Config < CC::Service::Config
3
+ attribute :api_token, String,
4
+ label: "API Token",
5
+ description: "The API token of the Flow to send notifications to",
6
+ link: "https://www.flowdock.com/account/tokens"
7
+ validates :api_token, presence: true
8
+ end
9
+
10
+ BASE_URL = "https://api.flowdock.com/v1"
11
+ INVALID_PROJECT_CHARACTERS = /[^0-9a-z\-_ ]+/i
12
+
13
+ self.description = "Send messages to a Flowdock inbox"
14
+
15
+ def receive_test
16
+ notify("Test", repo_name, formatter.format_test).merge(
17
+ message: "Test message sent"
18
+ )
19
+ end
20
+
21
+ def receive_coverage
22
+ notify("Coverage", repo_name, formatter.format_coverage)
23
+ end
24
+
25
+ def receive_quality
26
+ notify("Quality", repo_name, formatter.format_quality)
27
+ end
28
+
29
+ def receive_vulnerability
30
+ notify("Vulnerability", repo_name, formatter.format_vulnerability)
31
+ end
32
+
33
+ private
34
+
35
+ def formatter
36
+ CC::Formatters::LinkedFormatter.new(
37
+ self,
38
+ prefix: "",
39
+ prefix_with_repo: false,
40
+ link_style: :html
41
+ )
42
+ end
43
+
44
+ def notify(subject, project, content)
45
+ params = {
46
+ source: "Code Climate",
47
+ from_address: "hello@codeclimate.com",
48
+ from_name: "Code Climate",
49
+ format: "html",
50
+ subject: subject,
51
+ project: project.gsub(INVALID_PROJECT_CHARACTERS, ''),
52
+ content: content,
53
+ link: "https://codeclimate.com"
54
+ }
55
+
56
+ url = "#{BASE_URL}/messages/team_inbox/#{config.api_token}"
57
+ http.headers["User-Agent"] = "Code Climate"
58
+
59
+ service_post(url, params)
60
+ end
61
+ end
@@ -0,0 +1,80 @@
1
+ class CC::Service::GitHubIssues < CC::Service
2
+ class Config < CC::Service::Config
3
+ attribute :oauth_token, String,
4
+ label: "OAuth Token",
5
+ description: "A personal OAuth token with permissions for the repo"
6
+ attribute :project, String,
7
+ label: "Project",
8
+ description: "Project name on GitHub (e.g 'thoughtbot/paperclip')"
9
+ attribute :labels, String,
10
+ label: "Labels (comma separated)",
11
+ description: "Comma separated list of labels to apply to the issue"
12
+
13
+ validates :oauth_token, presence: true
14
+ end
15
+
16
+ self.title = "GitHub Issues"
17
+ self.description = "Open issues on GitHub"
18
+ self.issue_tracker = true
19
+
20
+ BASE_URL = "https://api.github.com"
21
+
22
+ def receive_test
23
+ result = create_issue("Test ticket from Code Climate", "")
24
+ result.merge(
25
+ message: "Issue <a href='#{result[:url]}'>##{result[:number]}</a> created."
26
+ )
27
+ rescue CC::Service::HTTPError => e
28
+ body = JSON.parse(e.response_body)
29
+ e.user_message = body["message"]
30
+ raise e
31
+ end
32
+
33
+ def receive_quality
34
+ title = "Refactor #{constant_name} from #{rating} on Code Climate"
35
+
36
+ create_issue(title, details_url)
37
+ end
38
+
39
+ def receive_vulnerability
40
+ formatter = CC::Formatters::TicketFormatter.new(self)
41
+
42
+ create_issue(
43
+ formatter.format_vulnerability_title,
44
+ formatter.format_vulnerability_body
45
+ )
46
+ end
47
+
48
+ def receive_issue
49
+ title = %{Fix "#{issue["check_name"]}" issue in #{constant_name}}
50
+
51
+ body = [issue["description"], details_url].join("\n\n")
52
+
53
+ create_issue(title, body)
54
+ end
55
+
56
+ private
57
+
58
+ def create_issue(title, issue_body)
59
+ params = { title: title, body: issue_body }
60
+
61
+ if config.labels.present?
62
+ params[:labels] = config.labels.split(",").map(&:strip).reject(&:blank?).compact
63
+ end
64
+
65
+ http.headers["Content-Type"] = "application/json"
66
+ http.headers["Authorization"] = "token #{config.oauth_token}"
67
+ http.headers["User-Agent"] = "Code Climate"
68
+
69
+ url = "#{BASE_URL}/repos/#{config.project}/issues"
70
+ service_post(url, params.to_json) do |response|
71
+ body = JSON.parse(response.body)
72
+ {
73
+ id: body["id"],
74
+ number: body["number"],
75
+ url: body["html_url"]
76
+ }
77
+ end
78
+ end
79
+
80
+ end