diff --git a/README.md b/README.md
index 1a3d42d..fbb78c4 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,21 @@ Event-specific attributes:
}
```
+### Pull Request
+
+Event name: `pull_request`
+
+Event-specific attributes:
+
+```javascript
+{
+ "state": String, // "pending", or "success"
+ "github_slug": String, // user/repo
+ "number": String,
+ "commit_sha": String,
+}
+```
+
## Other Events
The following are not fully implemented yet.
@@ -68,7 +83,6 @@ The following are not fully implemented yet.
* :issue
* :unit
* :snapshot
-* :pull\_request
## License
diff --git a/lib/cc/service.rb b/lib/cc/service.rb
index cf2f98d..b5664e3 100644
--- a/lib/cc/service.rb
+++ b/lib/cc/service.rb
@@ -30,7 +30,7 @@ def self.load_services
attr_reader :event, :config, :payload
- ALL_EVENTS = %w[test unit coverage quality vulnerability snapshot]
+ ALL_EVENTS = %w[test unit coverage quality vulnerability snapshot pull_request]
# Tracks the defined services.
def self.services
diff --git a/lib/cc/services/github_pull_requests.rb b/lib/cc/services/github_pull_requests.rb
new file mode 100644
index 0000000..395f82d
--- /dev/null
+++ b/lib/cc/services/github_pull_requests.rb
@@ -0,0 +1,103 @@
+class CC::Service::GitHubPullRequests < CC::Service
+ class Config < CC::Service::Config
+ attribute :oauth_token, String,
+ label: "OAuth Token",
+ description: "A personal OAuth token with permissions for the repo"
+ attribute :update_status, Boolean,
+ label: "Update status?",
+ description: "Update the pull request status after analyzing?"
+ attribute :add_comment, Boolean,
+ label: "Add a comment?",
+ description: "Comment on the pull request after analyzing?"
+
+ validates :oauth_token, presence: true
+ end
+
+ self.title = "GitHub Pull Requests"
+ self.description = "Update pull requests on on GitHub"
+
+ BASE_URL = "https://api.github.com"
+ BODY_REGEX = %r{Code Climate has analyzed this pull request}
+ COMMENT_BODY = '
Code Climate has analyzed this pull request.'
+
+ # Just make sure we can access GH using the configured token. Without
+ # additional information (github-slug, PR number, etc) we can't test much
+ # else.
+ def receive_test
+ setup_http
+
+ http_get("#{BASE_URL}")
+
+ nil
+ end
+
+ def receive_pull_request
+ setup_http
+
+ case @payload["state"]
+ when "pending"
+ update_status("pending", "Code Climate is analyzing this code.")
+ when "success"
+ add_comment
+ update_status("success", "Code Climate has analyzed this pull request.")
+ end
+ end
+
+private
+
+ def update_status(state, description)
+ if config.update_status
+ body = {
+ state: state,
+ description: description,
+ target_url: @payload["details_url"],
+ }.to_json
+
+ http_post(status_url, body)
+ end
+ end
+
+ def add_comment
+ if config.add_comment && !comment_present?
+ body = {
+ body: COMMENT_BODY % @payload["compare_url"]
+ }.to_json
+
+ http_post(comments_url, body)
+ end
+ end
+
+ def comment_present?
+ response = http_get(comments_url)
+ comments = JSON.parse(response.body)
+
+ comments.any? { |comment| comment["body"] =~ BODY_REGEX }
+ end
+
+ def setup_http
+ http.headers["Content-Type"] = "application/json"
+ http.headers["Authorization"] = "token #{config.oauth_token}"
+ http.headers["User-Agent"] = "Code Climate"
+ end
+
+ def status_url
+ "#{BASE_URL}/repos/#{github_slug}/statuses/#{commit_sha}"
+ end
+
+ def comments_url
+ "#{BASE_URL}/repos/#{github_slug}/issues/#{number}/comments"
+ end
+
+ def github_slug
+ @payload.fetch("github_slug")
+ end
+
+ def commit_sha
+ @payload.fetch("commit_sha")
+ end
+
+ def number
+ @payload.fetch("number")
+ end
+
+end
diff --git a/pull_request_test.rb b/pull_request_test.rb
new file mode 100755
index 0000000..8d0664b
--- /dev/null
+++ b/pull_request_test.rb
@@ -0,0 +1,38 @@
+#!/usr/bin/env ruby
+#
+# Ad-hoc script for updating a pull request using our service.
+#
+# Usage:
+#
+# $ OAUTH_TOKEN="..." bundle exec ruby pull_request_test.rb
+#
+###
+require 'cc/services'
+CC::Service.load_services
+
+class WithResponseLogging
+ def initialize(invocation)
+ @invocation = invocation
+ end
+
+ def call
+ @invocation.call.tap { |r| p r }
+ end
+end
+
+service = CC::Service::GitHubPullRequests.new({
+ oauth_token: ENV.fetch("OAUTH_TOKEN"),
+ update_status: true,
+ add_comment: true,
+}, {
+ name: "pull_request",
+ # https://github.com/codeclimate/nillson/pull/33
+ state: "success",
+ github_slug: "codeclimate/nillson",
+ number: 33,
+ commit_sha: "986ec903b8420f4e8c8d696d8950f7bd0667ff0c"
+})
+
+CC::Service::Invocation.new(service) do |i|
+ i.wrap(WithResponseLogging)
+end
diff --git a/test/github_pull_requests_test.rb b/test/github_pull_requests_test.rb
new file mode 100644
index 0000000..5729210
--- /dev/null
+++ b/test/github_pull_requests_test.rb
@@ -0,0 +1,95 @@
+require File.expand_path('../helper', __FILE__)
+
+class TestGitHubPullRequests < CC::Service::TestCase
+ def test_pull_request_status_pending
+ expect_status_update("pbrisbin/foo", "abc123", {
+ "state" => "pending",
+ "description" => /is analyzing/,
+ })
+
+ receive_pull_request({ update_status: true }, {
+ github_slug: "pbrisbin/foo",
+ commit_sha: "abc123",
+ state: "pending",
+ })
+ end
+
+ def test_pull_request_status_success
+ expect_status_update("pbrisbin/foo", "abc123", {
+ "state" => "success",
+ "description" => /has analyzed/,
+ })
+
+ receive_pull_request({ update_status: true }, {
+ github_slug: "pbrisbin/foo",
+ commit_sha: "abc123",
+ state: "success",
+ })
+ end
+
+ def test_pull_request_comment
+ stub_existing_comments("pbrisbin/foo", 1, %w[Hey Yo])
+
+ expect_comment("pbrisbin/foo", 1, %r{href="http://example.com">analyzed})
+
+ receive_pull_request({ add_comment: true }, {
+ github_slug: "pbrisbin/foo",
+ number: 1,
+ state: "success",
+ compare_url: "http://example.com",
+ })
+ end
+
+ def test_pull_request_comment_already_present
+ stub_existing_comments("pbrisbin/foo", 1, [
+ 'Code Climate has analyzed this pull request'
+ ])
+
+ # With no POST expectation, test will fail if request is made.
+
+ receive_pull_request({ add_comment: true }, {
+ github_slug: "pbrisbin/foo",
+ number: 1,
+ state: "success",
+ })
+ end
+
+private
+
+ def expect_status_update(repo, commit_sha, params)
+ @stubs.post "repos/#{repo}/statuses/#{commit_sha}" do |env|
+ assert_equal "token 123", env[:request_headers]["Authorization"]
+
+ body = JSON.parse(env[:body])
+
+ params.each do |k, v|
+ assert v === body[k],
+ "Unexpected value for #{k}. #{v.inspect} !== #{body[k].inspect}"
+ end
+ end
+ end
+
+ def stub_existing_comments(repo, number, bodies)
+ body = bodies.map { |b| { body: b } }.to_json
+
+ @stubs.get("repos/#{repo}/issues/#{number}/comments") { [200, {}, body] }
+ end
+
+ def expect_comment(repo, number, content)
+ @stubs.post "repos/#{repo}/issues/#{number}/comments" do |env|
+ body = JSON.parse(env[:body])
+ assert_equal "token 123", env[:request_headers]["Authorization"]
+ assert content === body["body"],
+ "Unexpected comment body. #{content.inspect} !== #{body["body"].inspect}"
+ end
+ end
+
+ def receive_pull_request(config, event_data)
+ receive(
+ CC::Service::GitHubPullRequests,
+ { oauth_token: "123" }.merge(config),
+ { name: "pull_request" }.merge(event_data)
+ )
+ end
+
+end