citrus-core 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.citrus/config.rb +3 -0
  3. data/.gitignore +21 -0
  4. data/.rspec +2 -0
  5. data/.travis.yml +9 -0
  6. data/Gemfile +3 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +10 -0
  9. data/Rakefile +9 -0
  10. data/citrus-core.gemspec +25 -0
  11. data/examples/bootstrap.rb +40 -0
  12. data/examples/payload.json +1 -0
  13. data/examples/web.rb +53 -0
  14. data/lib/citrus/core.rb +37 -0
  15. data/lib/citrus/core/build.rb +16 -0
  16. data/lib/citrus/core/cached_code_fetcher.rb +34 -0
  17. data/lib/citrus/core/changeset.rb +25 -0
  18. data/lib/citrus/core/commit.rb +20 -0
  19. data/lib/citrus/core/commit_changes.rb +15 -0
  20. data/lib/citrus/core/configuration.rb +17 -0
  21. data/lib/citrus/core/configuration_loader.rb +26 -0
  22. data/lib/citrus/core/configuration_validator.rb +13 -0
  23. data/lib/citrus/core/execute_build_service.rb +30 -0
  24. data/lib/citrus/core/git_adapter.rb +43 -0
  25. data/lib/citrus/core/github_adapter.rb +21 -0
  26. data/lib/citrus/core/publisher.rb +23 -0
  27. data/lib/citrus/core/repository.rb +13 -0
  28. data/lib/citrus/core/test_result.rb +24 -0
  29. data/lib/citrus/core/test_runner.rb +32 -0
  30. data/lib/citrus/core/version.rb +5 -0
  31. data/lib/citrus/core/workspace_builder.rb +27 -0
  32. data/spec/build_spec.rb +13 -0
  33. data/spec/cached_code_fetcher_spec.rb +69 -0
  34. data/spec/changeset_spec.rb +15 -0
  35. data/spec/citrus_spec.rb +18 -0
  36. data/spec/cofiguration_loader_spec.rb +27 -0
  37. data/spec/cofiguration_spec.rb +11 -0
  38. data/spec/cofiguration_validator_spec.rb +28 -0
  39. data/spec/commit_changes_spec.rb +15 -0
  40. data/spec/commit_spec.rb +21 -0
  41. data/spec/execute_build_service_spec.rb +76 -0
  42. data/spec/fixtures/github_push_data.json +143 -0
  43. data/spec/fixtures/repo/.citrus/config.rb +3 -0
  44. data/spec/github_adapter_spec.rb +16 -0
  45. data/spec/publisher_spec.rb +26 -0
  46. data/spec/repository_spec.rb +11 -0
  47. data/spec/spec_helper.rb +11 -0
  48. data/spec/test_result_spec.rb +20 -0
  49. data/spec/test_runner_spec.rb +44 -0
  50. data/spec/workspace_builder_spec.rb +55 -0
  51. metadata +167 -0
@@ -0,0 +1,43 @@
1
+ require 'childprocess'
2
+ require 'shellwords'
3
+
4
+ module Citrus
5
+ module Core
6
+ class GitAdapter
7
+ TIMEOUT = 600
8
+
9
+ def clone_repository(source, destination)
10
+ run %W(git clone #{shell_quote(source)} #{shell_quote(destination)})
11
+ end
12
+
13
+ def fetch_remote(destination, remote = 'origin')
14
+ run %W(git fetch #{shell_quote(remote)}), destination
15
+ end
16
+
17
+ def checkout(destination, revision = nil)
18
+ run %W(git checkout -b build #{shell_quote(revision)}), destination
19
+ end
20
+
21
+ def reset(destination, revision = nil)
22
+ run %W(git reset origin --hard #{shell_quote(revision)}), destination
23
+ end
24
+
25
+ protected
26
+
27
+ def run(command, directory = nil)
28
+ process = ChildProcess.build(*command)
29
+ process.cwd = directory.to_s if directory
30
+ process.start
31
+ process.poll_for_exit(TIMEOUT)
32
+ rescue ChildProcess::TimeoutError
33
+ process.stop
34
+ end
35
+
36
+ def shell_quote(string)
37
+ return "" unless string
38
+ Shellwords.escape(string.to_s)
39
+ end
40
+
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,21 @@
1
+ require 'json'
2
+ require 'time'
3
+
4
+ module Citrus
5
+ module Core
6
+ class GithubAdapter
7
+
8
+ def create_changeset_from_push_data(push_data)
9
+ data = JSON.parse(push_data)
10
+ commits = data['commits'].map do |commit|
11
+ changes = CommitChanges.new(commit['added'], commit['removed'], commit['modified'])
12
+ Commit.new(commit['id'], commit['author']['name'], commit['message'],
13
+ Time.parse(commit['timestamp']), changes, commit['url'])
14
+ end
15
+ repository = Repository.new(data['repository']['url'])
16
+ return Changeset.new(repository, commits)
17
+ end
18
+
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ module Citrus
2
+ module Core
3
+ module Publisher
4
+
5
+ def add_subscriber(subscriber)
6
+ subscribers << subscriber
7
+ end
8
+
9
+ def publish(event, *args)
10
+ subscribers.each do |subscriber|
11
+ subscriber.public_send(event, *args) if subscriber.respond_to?(event)
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def subscribers
18
+ @subscribers ||= []
19
+ end
20
+
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ module Citrus
2
+ module Core
3
+ class Repository
4
+
5
+ attr_reader :url
6
+
7
+ def initialize(url)
8
+ @url = url
9
+ end
10
+
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ require 'stringio'
2
+
3
+ module Citrus
4
+ module Core
5
+ class TestResult
6
+
7
+ attr_reader :value, :output
8
+
9
+ def initialize(value, output = StringIO.new)
10
+ @value = value.to_i
11
+ @output = output
12
+ end
13
+
14
+ def success?
15
+ value == 0
16
+ end
17
+
18
+ def failure?
19
+ !success?
20
+ end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,32 @@
1
+ require 'stringio'
2
+
3
+ module Citrus
4
+ module Core
5
+ class TestRunner
6
+ CHUNK_SIZE = 8192
7
+
8
+ include Publisher
9
+
10
+ def start(configuration, path)
11
+ process = ChildProcess.build(configuration.build_script)
12
+ process.cwd = path.to_s
13
+ r, w = IO.pipe
14
+ process.io.stdout = process.io.stderr = w
15
+ process.start
16
+ w.close
17
+ output = StringIO.new
18
+ begin
19
+ loop do
20
+ chunk = r.readpartial(CHUNK_SIZE)
21
+ output.write(chunk)
22
+ publish(:output_received, chunk)
23
+ end
24
+ rescue EOFError
25
+ end
26
+ process.wait
27
+ TestResult.new(process.exit_code, output)
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ module Citrus
2
+ module Core
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
@@ -0,0 +1,27 @@
1
+ module Citrus
2
+ module Core
3
+ class WorkspaceBuilder
4
+
5
+ attr_reader :root_path, :code_fetcher
6
+
7
+ def initialize(root_path = Citrus::Core.build_root, code_fetcher = CachedCodeFetcher.new)
8
+ @root_path = root_path
9
+ @code_fetcher = code_fetcher
10
+ end
11
+
12
+ def create_workspace(build)
13
+ path = root_path.join(partition, build.uuid)
14
+ path.mkpath
15
+ code_fetcher.fetch(build.changeset, path)
16
+ path
17
+ end
18
+
19
+ protected
20
+
21
+ def partition
22
+ Time.now.strftime('%Y/%m/%d')
23
+ end
24
+
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,13 @@
1
+ require 'spec_helper'
2
+
3
+ describe Citrus::Core::Build do
4
+
5
+ subject { described_class.new(changeset, uuid) }
6
+
7
+ let(:changeset) { fake(:changeset) }
8
+ let(:uuid) { SecureRandom.uuid }
9
+
10
+ it { should respond_to(:changeset) }
11
+ it { should respond_to(:uuid) }
12
+
13
+ end
@@ -0,0 +1,69 @@
1
+ require 'spec_helper'
2
+
3
+ describe Citrus::Core::CachedCodeFetcher do
4
+
5
+ subject { described_class.new(cache_root, vcs_adapter) }
6
+
7
+ let(:destination) { fake(:pathname) }
8
+ let(:vcs_adapter) { fake(:git_adapter) }
9
+ let(:changeset) { fake(:changeset, head: head_commit_sha, repository_url: repository_url) }
10
+ let(:repository_url) { 'git://github.com/pawelpacana/citrus-core.git' }
11
+ let(:head_commit_sha) { 'deadbeef' }
12
+
13
+ context '#fetch' do
14
+ include FakeFS::SpecHelpers
15
+
16
+ context 'with empty cache' do
17
+ let(:cache_root) { fake(:pathname) }
18
+ let(:cache_dir) { fake(:pathname) }
19
+
20
+ before do
21
+ stub(cache_root).join(any_args) { cache_dir }
22
+ stub(cache_dir).exist? { false }
23
+ subject.fetch(changeset, destination)
24
+ end
25
+
26
+ it 'should clone repository to cache dir' do
27
+ expect(vcs_adapter).to have_received.clone_repository(repository_url, cache_dir)
28
+ end
29
+
30
+ it 'should clone updated cache to destination' do
31
+ expect(vcs_adapter).to have_received.clone_repository(cache_dir, destination)
32
+ end
33
+ end
34
+
35
+ context 'with repository clone in cache' do
36
+ let(:cache_root) { fake(:pathname) }
37
+ let(:cache_dir) { fake(:pathname) }
38
+
39
+ before do
40
+ stub(cache_root).join(any_args) { cache_dir }
41
+ stub(cache_dir).exist? { true }
42
+ subject.fetch(changeset, destination)
43
+ end
44
+
45
+ it 'should fetch repository remote' do
46
+ expect(vcs_adapter).to have_received.fetch_remote(cache_dir)
47
+ end
48
+
49
+ it 'should reset repository' do
50
+ expect(vcs_adapter).to have_received.reset(cache_dir)
51
+ end
52
+ end
53
+
54
+ context do
55
+ let(:cache_root) { Pathname.new('/cache_root') }
56
+
57
+ before { subject.fetch(changeset, destination) }
58
+
59
+ it 'should create cache dir for repository' do
60
+ expect(cache_root.children).to have(1).element
61
+ end
62
+
63
+ it 'should checkout head commit from changeset' do
64
+ expect(vcs_adapter).to have_received.checkout(destination, head_commit_sha)
65
+ end
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,15 @@
1
+ require 'spec_helper'
2
+
3
+ describe Citrus::Core::Changeset do
4
+
5
+ subject { described_class.new(repository, commits) }
6
+
7
+ let(:repository) { fake(:repository, url: 'file:///repo') }
8
+ let(:commits) { [fake(:commit, sha: 'abc'), fake(:commit, sha: 'def')] }
9
+
10
+ it { should respond_to(:repository) }
11
+ it { should respond_to(:commits) }
12
+ its(:repository_url) { should == 'file:///repo' }
13
+ its(:head) { should == 'def' }
14
+
15
+ end
@@ -0,0 +1,18 @@
1
+ require 'spec_helper'
2
+
3
+ describe Citrus::Core do
4
+
5
+ subject { described_class }
6
+ before { stub(subject).root { Pathname.new('/dummy') } }
7
+
8
+ context '.cache_root' do
9
+ specify { expect(subject.cache_root.to_s).to match /cache\Z/ }
10
+ specify { expect(subject.cache_root.to_s).to match /\A#{described_class.root}/ }
11
+ end
12
+
13
+ context '.build_root' do
14
+ specify { expect(subject.build_root.to_s).to match /builds\Z/ }
15
+ specify { expect(subject.build_root.to_s).to match /\A#{described_class.root}/ }
16
+ end
17
+
18
+ end
@@ -0,0 +1,27 @@
1
+ require 'spec_helper'
2
+
3
+ describe Citrus::Core::ConfigurationLoader do
4
+
5
+ subject { described_class.new(validator) }
6
+
7
+ let(:validator) { fake(:configuration_validator) }
8
+ let(:test_root) { Pathname.new(File.dirname(__FILE__)) }
9
+ let(:repo_root) { test_root.join('fixtures/repo') }
10
+
11
+ context '#load_from_path' do
12
+ it 'should return configuration when config file found' do
13
+ stub(validator).validate(any_args) { true }
14
+ expect(subject.load_from_path(repo_root)).to be_a_kind_of(Citrus::Core::Configuration)
15
+ end
16
+
17
+ it 'should raise when no configuration can be found' do
18
+ expect { subject.load_from_path(test_root) }.to raise_error(Citrus::Core::ConfigurationFileNotFoundError)
19
+ end
20
+
21
+ it 'should raise when found configuration is not valid' do
22
+ stub(validator).validate(any_args) { false }
23
+ expect { subject.load_from_path(repo_root) }.to raise_error(Citrus::Core::ConfigurationFileInvalidError)
24
+ end
25
+ end
26
+
27
+ end
@@ -0,0 +1,11 @@
1
+ require 'spec_helper'
2
+
3
+ describe Citrus::Core::Configuration do
4
+
5
+ subject { described_class.new }
6
+
7
+ it { should be_a_kind_of(Citrus::Configuration) }
8
+ it { should respond_to(:build_script) }
9
+ it { should respond_to(:build_script=) }
10
+
11
+ end
@@ -0,0 +1,28 @@
1
+ require 'spec_helper'
2
+
3
+ describe Citrus::Core::ConfigurationValidator do
4
+
5
+ subject { described_class.new }
6
+
7
+ let(:valid_configuration) do
8
+ Citrus::Core::Configuration.describe do |config|
9
+ config.build_script = "bundle exec rspec"
10
+ end
11
+ end
12
+
13
+ context '#validate' do
14
+ it 'should validate valid example configuration' do
15
+ expect(subject.validate(valid_configuration)).to be_true
16
+ end
17
+
18
+ it 'should validate class' do
19
+ expect(subject.validate(Object.new)).to be_false
20
+ end
21
+
22
+ it 'should validate build script presence' do
23
+ valid_configuration.build_script = ""
24
+ expect(subject.validate(valid_configuration)).to be_false
25
+ end
26
+ end
27
+
28
+ end
@@ -0,0 +1,15 @@
1
+ require 'spec_helper'
2
+
3
+ describe Citrus::Core::CommitChanges do
4
+
5
+ subject { described_class.new(added, removed, modified) }
6
+
7
+ let(:added) { %w(spec/dummy_spec.rb lib/dummy.rb) }
8
+ let(:removed) { %w(README.md) }
9
+ let(:modified) { %w(Gemfile Gemfile.lock) }
10
+
11
+ it { should respond_to(:added) }
12
+ it { should respond_to(:removed) }
13
+ it { should respond_to(:modified) }
14
+
15
+ end
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+
3
+ describe Citrus::Core::Commit do
4
+
5
+ subject { described_class.new(sha, author, message, timestamp, changes, url) }
6
+
7
+ let(:author) { 'John Doe' }
8
+ let(:sha) { 'abc' }
9
+ let(:message) { 'Fixes, obviously.' }
10
+ let(:timestamp) { Time.now }
11
+ let(:url) { 'https://github.com/octokitty/testing/commit/c441029cf673f84c8b7db52d0a5944ee5c52ff89' }
12
+ let(:changes) { fake(:commit_changes) }
13
+
14
+ it { should respond_to(:sha) }
15
+ it { should respond_to(:author) }
16
+ it { should respond_to(:message) }
17
+ it { should respond_to(:timestamp) }
18
+ it { should respond_to(:changes) }
19
+ it { should respond_to(:url) }
20
+
21
+ end
@@ -0,0 +1,76 @@
1
+ require 'spec_helper'
2
+
3
+ class Subscriber
4
+ def build_started(build); end
5
+ def build_failed(build, output); end
6
+ def build_succeeded(build, output); end
7
+ def build_aborted(build, reason); end
8
+ end
9
+
10
+ describe Citrus::Core::ExecuteBuildService do
11
+
12
+ subject { described_class.new(workspace_builder, configuration_loader, test_runner) }
13
+
14
+ let(:workspace_builder) { fake(:workspace_builder, create_workspace: path) }
15
+ let(:test_runner) { fake(:test_runner, start: fake(:test_result, output: result_output)) }
16
+ let(:build) { fake(:build) }
17
+ let(:configuration_loader) { fake(:configuration_loader, load_from_path: configuration) }
18
+ let(:configuration) { fake(:configuration) }
19
+ let(:path) { fake }
20
+ let(:subscriber) { fake(:subscriber) }
21
+ let(:success_result) { Citrus::Core::TestResult.new(0) }
22
+ let(:failure_result) { Citrus::Core::TestResult.new(1) }
23
+ let(:result_output) { StringIO.new }
24
+
25
+ context '#start' do
26
+ before { subject.add_subscriber(subscriber) }
27
+
28
+ context do
29
+ before { subject.start(build) }
30
+
31
+ it 'should prepare workspace for build' do
32
+ expect(workspace_builder).to have_received.create_workspace(build)
33
+ end
34
+
35
+ it 'should read build configuration from workspace' do
36
+ expect(configuration_loader).to have_received.load_from_path(path)
37
+ end
38
+
39
+ it 'should execute build script' do
40
+ expect(test_runner).to have_received.start(configuration, path)
41
+ end
42
+
43
+ it 'should publish build_started event when starting build' do
44
+ expect(subscriber).to have_received.build_started(build)
45
+ end
46
+
47
+ it 'should publish build_succeeded event when build has succeeded' do
48
+ stub(test_runner).start(any_args) { success_result }
49
+ expect(subscriber).to have_received.build_succeeded(build, result_output)
50
+ end
51
+
52
+ it 'should publish build_failed event when build has failed' do
53
+ stub(test_runner).start(any_args) { failure_result }
54
+ expect(subscriber).to have_received.build_failed(build, result_output)
55
+ end
56
+ end
57
+
58
+ context 'invalid configuration' do
59
+ let(:reason) { Citrus::Core::ConfigurationError.new }
60
+
61
+ before do
62
+ stub(configuration_loader).load_from_path(path) { raise reason }
63
+ expect { subject.start(build) }.to raise_error(Citrus::Core::ConfigurationError)
64
+ end
65
+
66
+ it 'should publish build_aborted event when unable to start build due to invalid configuration' do
67
+ expect(subscriber).to have_received.build_aborted(build, reason)
68
+ end
69
+ end
70
+ end
71
+
72
+ it 'should allow adding subscribers' do
73
+ expect(subject).to respond_to(:add_subscriber)
74
+ end
75
+
76
+ end