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