citrus-core 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.citrus/config.rb +3 -0
- data/.gitignore +21 -0
- data/.rspec +2 -0
- data/.travis.yml +9 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +10 -0
- data/Rakefile +9 -0
- data/citrus-core.gemspec +25 -0
- data/examples/bootstrap.rb +40 -0
- data/examples/payload.json +1 -0
- data/examples/web.rb +53 -0
- data/lib/citrus/core.rb +37 -0
- data/lib/citrus/core/build.rb +16 -0
- data/lib/citrus/core/cached_code_fetcher.rb +34 -0
- data/lib/citrus/core/changeset.rb +25 -0
- data/lib/citrus/core/commit.rb +20 -0
- data/lib/citrus/core/commit_changes.rb +15 -0
- data/lib/citrus/core/configuration.rb +17 -0
- data/lib/citrus/core/configuration_loader.rb +26 -0
- data/lib/citrus/core/configuration_validator.rb +13 -0
- data/lib/citrus/core/execute_build_service.rb +30 -0
- data/lib/citrus/core/git_adapter.rb +43 -0
- data/lib/citrus/core/github_adapter.rb +21 -0
- data/lib/citrus/core/publisher.rb +23 -0
- data/lib/citrus/core/repository.rb +13 -0
- data/lib/citrus/core/test_result.rb +24 -0
- data/lib/citrus/core/test_runner.rb +32 -0
- data/lib/citrus/core/version.rb +5 -0
- data/lib/citrus/core/workspace_builder.rb +27 -0
- data/spec/build_spec.rb +13 -0
- data/spec/cached_code_fetcher_spec.rb +69 -0
- data/spec/changeset_spec.rb +15 -0
- data/spec/citrus_spec.rb +18 -0
- data/spec/cofiguration_loader_spec.rb +27 -0
- data/spec/cofiguration_spec.rb +11 -0
- data/spec/cofiguration_validator_spec.rb +28 -0
- data/spec/commit_changes_spec.rb +15 -0
- data/spec/commit_spec.rb +21 -0
- data/spec/execute_build_service_spec.rb +76 -0
- data/spec/fixtures/github_push_data.json +143 -0
- data/spec/fixtures/repo/.citrus/config.rb +3 -0
- data/spec/github_adapter_spec.rb +16 -0
- data/spec/publisher_spec.rb +26 -0
- data/spec/repository_spec.rb +11 -0
- data/spec/spec_helper.rb +11 -0
- data/spec/test_result_spec.rb +20 -0
- data/spec/test_runner_spec.rb +44 -0
- data/spec/workspace_builder_spec.rb +55 -0
- 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,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,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
|
data/spec/build_spec.rb
ADDED
@@ -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
|
data/spec/citrus_spec.rb
ADDED
@@ -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,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
|
data/spec/commit_spec.rb
ADDED
@@ -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
|