Commit edab46e9fa5f568b1423c0021e81d30453d7dc1e

Authored by Ariejan de Vroom
1 parent 56fc53e8

Added web hooks functionality

This commit includes:

 * Projects can have zero or more WebHooks.
 * The PostReceive job will ask a project to execute any web hooks defined for that project.
 * WebHook has a URL, we post Github-compatible JSON to that URL.
 * Failure to execute a WebHook will be silently ignored.
@@ -24,6 +24,7 @@ gem "acts-as-taggable-on", "~> 2.1.0" @@ -24,6 +24,7 @@ gem "acts-as-taggable-on", "~> 2.1.0"
24 gem "drapper" 24 gem "drapper"
25 gem "rchardet19", "~> 1.3.5" 25 gem "rchardet19", "~> 1.3.5"
26 gem "resque" 26 gem "resque"
  27 +gem "httparty"
27 28
28 group :assets do 29 group :assets do
29 gem "sass-rails", "~> 3.1.0" 30 gem "sass-rails", "~> 3.1.0"
@@ -48,6 +49,7 @@ group :development, :test do @@ -48,6 +49,7 @@ group :development, :test do
48 gem "awesome_print" 49 gem "awesome_print"
49 gem "database_cleaner" 50 gem "database_cleaner"
50 gem "launchy" 51 gem "launchy"
  52 + gem "webmock"
51 end 53 end
52 54
53 group :test do 55 group :test do
@@ -87,6 +87,7 @@ GEM @@ -87,6 +87,7 @@ GEM
87 execjs 87 execjs
88 coffee-script-source (1.1.3) 88 coffee-script-source (1.1.3)
89 columnize (0.3.4) 89 columnize (0.3.4)
  90 + crack (0.3.1)
90 daemons (1.1.4) 91 daemons (1.1.4)
91 database_cleaner (0.7.0) 92 database_cleaner (0.7.0)
92 devise (1.5.0) 93 devise (1.5.0)
@@ -111,6 +112,9 @@ GEM @@ -111,6 +112,9 @@ GEM
111 railties (~> 3.0) 112 railties (~> 3.0)
112 hashery (1.4.0) 113 hashery (1.4.0)
113 hike (1.2.1) 114 hike (1.2.1)
  115 + httparty (0.8.1)
  116 + multi_json
  117 + multi_xml
114 i18n (0.6.0) 118 i18n (0.6.0)
115 jquery-rails (1.0.17) 119 jquery-rails (1.0.17)
116 railties (~> 3.0) 120 railties (~> 3.0)
@@ -132,6 +136,7 @@ GEM @@ -132,6 +136,7 @@ GEM
132 treetop (~> 1.4.8) 136 treetop (~> 1.4.8)
133 mime-types (1.17.2) 137 mime-types (1.17.2)
134 multi_json (1.0.3) 138 multi_json (1.0.3)
  139 + multi_xml (0.4.1)
135 nokogiri (1.5.0) 140 nokogiri (1.5.0)
136 orm_adapter (0.0.5) 141 orm_adapter (0.0.5)
137 polyglot (0.3.3) 142 polyglot (0.3.3)
@@ -262,6 +267,9 @@ GEM @@ -262,6 +267,9 @@ GEM
262 rack (>= 1.0.0) 267 rack (>= 1.0.0)
263 warden (1.1.0) 268 warden (1.1.0)
264 rack (>= 1.0) 269 rack (>= 1.0)
  270 + webmock (1.7.8)
  271 + addressable (~> 2.2, > 2.2.5)
  272 + crack (>= 0.1.7)
265 xpath (0.1.4) 273 xpath (0.1.4)
266 nokogiri (~> 1.3) 274 nokogiri (~> 1.3)
267 275
@@ -286,6 +294,7 @@ DEPENDENCIES @@ -286,6 +294,7 @@ DEPENDENCIES
286 gitolite! 294 gitolite!
287 grit! 295 grit!
288 haml-rails 296 haml-rails
  297 + httparty
289 jquery-rails 298 jquery-rails
290 kaminari 299 kaminari
291 launchy 300 launchy
@@ -309,3 +318,4 @@ DEPENDENCIES @@ -309,3 +318,4 @@ DEPENDENCIES
309 thin 318 thin
310 turn 319 turn
311 uglifier 320 uglifier
  321 + webmock
app/models/project.rb
@@ -14,6 +14,7 @@ class Project < ActiveRecord::Base @@ -14,6 +14,7 @@ class Project < ActiveRecord::Base
14 has_many :users, :through => :users_projects 14 has_many :users, :through => :users_projects
15 has_many :notes, :dependent => :destroy 15 has_many :notes, :dependent => :destroy
16 has_many :snippets, :dependent => :destroy 16 has_many :snippets, :dependent => :destroy
  17 + has_many :web_hooks, :dependent => :destroy
17 18
18 acts_as_taggable 19 acts_as_taggable
19 20
@@ -79,12 +80,53 @@ class Project < ActiveRecord::Base @@ -79,12 +80,53 @@ class Project < ActiveRecord::Base
79 :heads, 80 :heads,
80 :commits_since, 81 :commits_since,
81 :fresh_commits, 82 :fresh_commits,
  83 + :commits_between,
82 :to => :repository, :prefix => nil 84 :to => :repository, :prefix => nil
83 85
84 def to_param 86 def to_param
85 code 87 code
86 end 88 end
87 89
  90 + def web_url
  91 + [GIT_HOST['host'], code].join("/")
  92 + end
  93 +
  94 + def execute_web_hooks(oldrev, newrev, ref)
  95 + data = web_hook_data(oldrev, newrev, ref)
  96 + web_hooks.each { |web_hook| web_hook.execute(data) }
  97 + end
  98 +
  99 + def web_hook_data(oldrev, newrev, ref)
  100 + data = {
  101 + before: oldrev,
  102 + after: newrev,
  103 + ref: ref,
  104 + repository: {
  105 + name: name,
  106 + url: web_url,
  107 + description: description,
  108 + homepage: web_url,
  109 + private: private?
  110 + },
  111 + commits: []
  112 + }
  113 +
  114 + commits_between(oldrev, newrev).each do |commit|
  115 + data[:commits] << {
  116 + id: commit.id,
  117 + message: commit.safe_message,
  118 + timestamp: commit.date.xmlschema,
  119 + url: "http://#{GIT_HOST['host']}/#{code}/commits/#{commit.id}",
  120 + author: {
  121 + name: commit.author_name,
  122 + email: commit.author_email
  123 + }
  124 + }
  125 + end
  126 +
  127 + data
  128 + end
  129 +
88 def team_member_by_name_or_email(email = nil, name = nil) 130 def team_member_by_name_or_email(email = nil, name = nil)
89 user = users.where("email like ? or name like ?", email, name).first 131 user = users.where("email like ? or name like ?", email, name).first
90 users_projects.find_by_user_id(user.id) if user 132 users_projects.find_by_user_id(user.id) if user
@@ -157,7 +199,7 @@ class Project &lt; ActiveRecord::Base @@ -157,7 +199,7 @@ class Project &lt; ActiveRecord::Base
157 @admins ||= users_projects.includes(:user).where(:project_access => PROJECT_RWA).map(&:user) 199 @admins ||= users_projects.includes(:user).where(:project_access => PROJECT_RWA).map(&:user)
158 end 200 end
159 201
160 - def root_ref 202 + def root_ref
161 default_branch || "master" 203 default_branch || "master"
162 end 204 end
163 205
app/models/repository.rb
@@ -133,4 +133,8 @@ class Repository @@ -133,4 +133,8 @@ class Repository
133 repo.commits(ref) 133 repo.commits(ref)
134 end.map{ |c| Commit.new(c) } 134 end.map{ |c| Commit.new(c) }
135 end 135 end
  136 +
  137 + def commits_between(from, to)
  138 + repo.commits_between(from, to).map { |c| Commit.new(c) }
  139 + end
136 end 140 end
app/models/web_hook.rb 0 → 100644
@@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
  1 +class WebHook < ActiveRecord::Base
  2 + include HTTParty
  3 +
  4 + # HTTParty timeout
  5 + default_timeout 10
  6 +
  7 + belongs_to :project
  8 +
  9 + validates :url,
  10 + presence: true,
  11 + format: {
  12 + with: /(^$)|(^(http|https):\/\/[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(([0-9]{1,5})?\/.*)?$)/ix,
  13 + message: "should be a valid url" }
  14 +
  15 + def execute(data)
  16 + WebHook.post(url, body: data.to_json)
  17 + rescue
  18 + # There was a problem calling this web hook, let's forget about it.
  19 + end
  20 +end
app/workers/post_receive.rb
1 class PostReceive 1 class PostReceive
2 def self.perform(reponame, oldrev, newrev, ref) 2 def self.perform(reponame, oldrev, newrev, ref)
3 - puts "[#{reponame}] #{oldrev} => #{newrev} (#{ref})" 3 + project = Project.find_by_path(reponame)
  4 + return false if project.nil?
  5 +
  6 + project.execute_web_hooks(oldrev, newrev, ref)
4 end 7 end
5 end 8 end
db/migrate/20111214091851_create_web_hooks.rb 0 → 100644
@@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
  1 +class CreateWebHooks < ActiveRecord::Migration
  2 + def change
  3 + create_table :web_hooks do |t|
  4 + t.string :url
  5 + t.integer :project_id
  6 + t.timestamps
  7 + end
  8 + end
  9 +end
@@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
11 # 11 #
12 # It's strongly recommended to check this file into your version control system. 12 # It's strongly recommended to check this file into your version control system.
13 13
14 -ActiveRecord::Schema.define(:version => 20111207211728) do 14 +ActiveRecord::Schema.define(:version => 20111214091851) do
15 15
16 create_table "issues", :force => true do |t| 16 create_table "issues", :force => true do |t|
17 t.string "title" 17 t.string "title"
@@ -132,4 +132,18 @@ ActiveRecord::Schema.define(:version =&gt; 20111207211728) do @@ -132,4 +132,18 @@ ActiveRecord::Schema.define(:version =&gt; 20111207211728) do
132 t.integer "project_access", :default => 0, :null => false 132 t.integer "project_access", :default => 0, :null => false
133 end 133 end
134 134
  135 + create_table "web_hook_urls", :force => true do |t|
  136 + t.string "url"
  137 + t.integer "project_id"
  138 + t.datetime "created_at"
  139 + t.datetime "updated_at"
  140 + end
  141 +
  142 + create_table "web_hooks", :force => true do |t|
  143 + t.string "url"
  144 + t.integer "project_id"
  145 + t.datetime "created_at"
  146 + t.datetime "updated_at"
  147 + end
  148 +
135 end 149 end
spec/factories.rb
@@ -54,3 +54,7 @@ Factory.add(:key, Key) do |obj| @@ -54,3 +54,7 @@ Factory.add(:key, Key) do |obj|
54 obj.title = "Example key" 54 obj.title = "Example key"
55 obj.key = File.read(File.join(Rails.root, "db", "pkey.example")) 55 obj.key = File.read(File.join(Rails.root, "db", "pkey.example"))
56 end 56 end
  57 +
  58 +Factory.add(:web_hook, WebHook) do |obj|
  59 + obj.url = Faker::Internet.url
  60 +end
spec/models/project_spec.rb
@@ -7,6 +7,7 @@ describe Project do @@ -7,6 +7,7 @@ describe Project do
7 it { should have_many(:issues) } 7 it { should have_many(:issues) }
8 it { should have_many(:notes) } 8 it { should have_many(:notes) }
9 it { should have_many(:snippets) } 9 it { should have_many(:snippets) }
  10 + it { should have_many(:web_hooks).dependent(:destroy) }
10 end 11 end
11 12
12 describe "Validation" do 13 describe "Validation" do
@@ -33,6 +34,7 @@ describe Project do @@ -33,6 +34,7 @@ describe Project do
33 it { should respond_to(:repo) } 34 it { should respond_to(:repo) }
34 it { should respond_to(:tags) } 35 it { should respond_to(:tags) }
35 it { should respond_to(:commit) } 36 it { should respond_to(:commit) }
  37 + it { should respond_to(:commits_between) }
36 end 38 end
37 39
38 it "should not allow 'gitolite-admin' as repo name" do 40 it "should not allow 'gitolite-admin' as repo name" do
@@ -50,6 +52,11 @@ describe Project do @@ -50,6 +52,11 @@ describe Project do
50 project.path_to_repo.should == File.join(Rails.root, "tmp", "tests", "somewhere") 52 project.path_to_repo.should == File.join(Rails.root, "tmp", "tests", "somewhere")
51 end 53 end
52 54
  55 + it "returns the full web URL for this repo" do
  56 + project = Project.new(:code => "somewhere")
  57 + project.web_url.should == "#{GIT_HOST['host']}/somewhere"
  58 + end
  59 +
53 describe :valid_repo? do 60 describe :valid_repo? do
54 it "should be valid repo" do 61 it "should be valid repo" do
55 project = Factory :project 62 project = Factory :project
@@ -62,6 +69,85 @@ describe Project do @@ -62,6 +69,85 @@ describe Project do
62 end 69 end
63 end 70 end
64 71
  72 + describe "web hooks" do
  73 + let(:project) { Factory :project }
  74 +
  75 + context "with no web hooks" do
  76 + it "raises no errors" do
  77 + lambda {
  78 + project.execute_web_hooks('oldrev', 'newrev', 'ref')
  79 + }.should_not raise_error
  80 + end
  81 + end
  82 +
  83 + context "with web hooks" do
  84 + before do
  85 + @webhook = Factory(:web_hook)
  86 + @webhook_2 = Factory(:web_hook)
  87 + project.web_hooks << [@webhook, @webhook_2]
  88 + end
  89 +
  90 + it "executes multiple web hook" do
  91 + @webhook.should_receive(:execute).once
  92 + @webhook_2.should_receive(:execute).once
  93 +
  94 + project.execute_web_hooks('oldrev', 'newrev', 'ref')
  95 + end
  96 + end
  97 +
  98 + context "when gathering commit data" do
  99 + before do
  100 + @oldrev, @newrev, @ref = project.fresh_commits(2).last.sha, project.fresh_commits(2).first.sha, 'refs/heads/master'
  101 + @commit = project.fresh_commits(2).first
  102 +
  103 + # Fill nil/empty attributes
  104 + project.description = "This is a description"
  105 +
  106 + @data = project.web_hook_data(@oldrev, @newrev, @ref)
  107 + end
  108 +
  109 + subject { @data }
  110 +
  111 + it { should include(before: @oldrev) }
  112 + it { should include(after: @newrev) }
  113 + it { should include(ref: @ref) }
  114 +
  115 + context "with repository data" do
  116 + subject { @data[:repository] }
  117 +
  118 + it { should include(name: project.name) }
  119 + it { should include(url: project.web_url) }
  120 + it { should include(description: project.description) }
  121 + it { should include(homepage: project.web_url) }
  122 + it { should include(private: project.private?) }
  123 + end
  124 +
  125 + context "with commits" do
  126 + subject { @data[:commits] }
  127 +
  128 + it { should be_an(Array) }
  129 + it { should have(1).element }
  130 +
  131 + context "the commit" do
  132 + subject { @data[:commits].first }
  133 +
  134 + it { should include(id: @commit.id) }
  135 + it { should include(message: @commit.safe_message) }
  136 + it { should include(timestamp: @commit.date.xmlschema) }
  137 + it { should include(url: "http://localhost/#{project.code}/commits/#{@commit.id}") }
  138 +
  139 + context "with a author" do
  140 + subject { @data[:commits].first[:author] }
  141 +
  142 + it { should include(name: @commit.author_name) }
  143 + it { should include(email: @commit.author_email) }
  144 + end
  145 + end
  146 + end
  147 +
  148 + end
  149 + end
  150 +
65 describe "updates" do 151 describe "updates" do
66 let(:project) { Factory :project } 152 let(:project) { Factory :project }
67 153
@@ -107,6 +193,21 @@ describe Project do @@ -107,6 +193,21 @@ describe Project do
107 it { project.fresh_commits.last.id.should == "0dac878dbfe0b9c6104a87d65fe999149a8d862c" } 193 it { project.fresh_commits.last.id.should == "0dac878dbfe0b9c6104a87d65fe999149a8d862c" }
108 end 194 end
109 195
  196 + describe "commits_between" do
  197 + let(:project) { Factory :project }
  198 +
  199 + subject do
  200 + commits = project.commits_between("a6d1d4aca0c85816ddfd27d93773f43a31395033",
  201 + "2fb376f61875b58bceee0492e270e9c805294b1a")
  202 + commits.map { |c| c.id }
  203 + end
  204 +
  205 + it { should have(2).elements }
  206 + it { should include("2fb376f61875b58bceee0492e270e9c805294b1a") }
  207 + it { should include("4571e226fbcd7be1af16e9fa1e13b7ac003bebdf") }
  208 + it { should_not include("a6d1d4aca0c85816ddfd27d93773f43a31395033") }
  209 + end
  210 +
110 describe "Git methods" do 211 describe "Git methods" do
111 let(:project) { Factory :project } 212 let(:project) { Factory :project }
112 213
spec/models/web_hook_spec.rb 0 → 100644
@@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
  1 +require 'spec_helper'
  2 +
  3 +describe WebHook do
  4 + describe "Associations" do
  5 + it { should belong_to :project }
  6 + end
  7 +
  8 + describe "Validations" do
  9 + it { should validate_presence_of(:url) }
  10 +
  11 + context "url format" do
  12 + it { should allow_value("http://example.com").for(:url) }
  13 + it { should allow_value("https://excample.com").for(:url) }
  14 + it { should allow_value("http://test.com/api").for(:url) }
  15 + it { should allow_value("http://test.com/api?key=abc").for(:url) }
  16 + it { should allow_value("http://test.com/api?key=abc&type=def").for(:url) }
  17 +
  18 + it { should_not allow_value("example.com").for(:url) }
  19 + it { should_not allow_value("ftp://example.com").for(:url) }
  20 + it { should_not allow_value("herp-and-derp").for(:url) }
  21 + end
  22 + end
  23 +
  24 + describe "execute" do
  25 + before(:each) do
  26 + @webhook = Factory :web_hook
  27 + @project = Factory :project
  28 + @project.web_hooks << [@webhook]
  29 + @data = { before: 'oldrev', after: 'newrev', ref: 'ref'}
  30 +
  31 + WebMock.stub_request(:post, @webhook.url)
  32 + end
  33 +
  34 + it "POSTs to the web hook URL" do
  35 + @webhook.execute(@data)
  36 + WebMock.should have_requested(:post, @webhook.url).once
  37 + end
  38 +
  39 + it "POSTs the data as JSON" do
  40 + json = @data.to_json
  41 +
  42 + @webhook.execute(@data)
  43 + WebMock.should have_requested(:post, @webhook.url).with(body: json).once
  44 + end
  45 +
  46 + it "catches exceptions" do
  47 + WebHook.should_receive(:post).and_raise("Some HTTP Post error")
  48 +
  49 + lambda {
  50 + @webhook.execute(@data)
  51 + }.should_not raise_error
  52 + end
  53 + end
  54 +end
spec/requests/projects_tree_perfomance_spec.rb
@@ -9,7 +9,6 @@ describe &quot;Projects&quot; do @@ -9,7 +9,6 @@ describe &quot;Projects&quot; do
9 before do 9 before do
10 @project = Factory :project 10 @project = Factory :project
11 @project.add_access(@user, :read) 11 @project.add_access(@user, :read)
12 -  
13 end 12 end
14 13
15 it "should be fast" do 14 it "should be fast" do
spec/spec_helper.rb
@@ -8,6 +8,7 @@ require &#39;rspec/rails&#39; @@ -8,6 +8,7 @@ require &#39;rspec/rails&#39;
8 require 'capybara/rails' 8 require 'capybara/rails'
9 require 'capybara/rspec' 9 require 'capybara/rspec'
10 require 'capybara/dsl' 10 require 'capybara/dsl'
  11 +require 'webmock/rspec'
11 require 'factories' 12 require 'factories'
12 require 'monkeypatch' 13 require 'monkeypatch'
13 14
@@ -48,6 +49,8 @@ RSpec.configure do |config| @@ -48,6 +49,8 @@ RSpec.configure do |config|
48 end 49 end
49 50
50 DatabaseCleaner.start 51 DatabaseCleaner.start
  52 +
  53 + WebMock.disable_net_connect!(allow_localhost: true)
51 end 54 end
52 55
53 config.after do 56 config.after do
spec/workers/post_receive_spec.rb 0 → 100644
@@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
  1 +require 'spec_helper'
  2 +
  3 +describe PostReceive do
  4 +
  5 + context "as a resque worker" do
  6 + it "reponds to #perform" do
  7 + PostReceive.should respond_to(:perform)
  8 + end
  9 + end
  10 +
  11 + context "web hooks" do
  12 + let(:project) { Factory :project }
  13 +
  14 + it "it retrieves the correct project" do
  15 + Project.should_receive(:find_by_path).with(project.path)
  16 + PostReceive.perform(project.path, 'sha-old', 'sha-new', 'refs/heads/master')
  17 + end
  18 +
  19 + it "asks the project to execute web hooks" do
  20 + Project.stub(find_by_path: project)
  21 + project.should_receive(:execute_web_hooks).with('sha-old', 'sha-new', 'refs/heads/master')
  22 +
  23 + PostReceive.perform(project.path, 'sha-old', 'sha-new', 'refs/heads/master')
  24 + end
  25 + end
  26 +end