codeclimate-services 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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