citrus-core 0.0.1

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 (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