joshuapinter-cijoe 0.9.3

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.
@@ -0,0 +1,55 @@
1
+ class CIJoe
2
+ # An in memory queue used for maintaining an order list of requested
3
+ # builds.
4
+ class Queue
5
+ # enabled - determines whether builds should be queued or not.
6
+ def initialize(enabled, verbose=false)
7
+ @enabled = enabled
8
+ @verbose = verbose
9
+ @queue = []
10
+
11
+ log("Build queueing enabled") if enabled
12
+ end
13
+
14
+ # Public: Appends a branch to be built, unless it already exists
15
+ # within the queue.
16
+ #
17
+ # branch - the name of the branch to build or nil if the default
18
+ # should be built.
19
+ #
20
+ # Returns nothing
21
+ def append_unless_already_exists(branch)
22
+ return unless enabled?
23
+ unless @queue.include? branch
24
+ @queue << branch
25
+ log "#{Time.now.to_i}: Queueing #{branch}"
26
+ end
27
+ end
28
+
29
+ # Returns a String of the next branch to build
30
+ def next_branch_to_build
31
+ branch = @queue.shift
32
+ log "#{Time.now.to_i}: De-queueing #{branch}"
33
+ branch
34
+ end
35
+
36
+ # Returns true if there are requested builds waiting and false
37
+ # otherwise.
38
+ def waiting?
39
+ if enabled?
40
+ not @queue.empty?
41
+ else
42
+ false
43
+ end
44
+ end
45
+
46
+ protected
47
+ def log(msg)
48
+ puts msg if @verbose
49
+ end
50
+
51
+ def enabled?
52
+ @enabled
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,114 @@
1
+ require 'sinatra/base'
2
+ require 'erb'
3
+ require 'json'
4
+
5
+ class CIJoe
6
+ class Server < Sinatra::Base
7
+ attr_reader :joe
8
+
9
+ dir = File.dirname(File.expand_path(__FILE__))
10
+
11
+ set :views, "#{dir}/views"
12
+ set :public_folder, "#{dir}/public"
13
+ set :static, true
14
+ set :lock, true
15
+
16
+ before { joe.restore }
17
+
18
+ get '/ping' do
19
+ if joe.building? || !joe.last_build || !joe.last_build.worked?
20
+ halt 412, (joe.building? || joe.last_build.nil?) ? "building" : joe.last_build.sha
21
+ end
22
+
23
+ joe.last_build.sha
24
+ end
25
+
26
+ get '/?' do
27
+ erb(:template, {}, :joe => joe)
28
+ end
29
+
30
+ post '/?' do
31
+ unless params[:rebuild]
32
+ payload = JSON.parse(params[:payload])
33
+ pushed_branch = payload["ref"].split('/').last
34
+ end
35
+
36
+ # Only build if we were given an explicit branch via `?branch=blah`
37
+ # or the payload exists and the "ref" property matches our
38
+ # specified build branch.
39
+ if params[:branch] || params[:rebuild] || pushed_branch == joe.git_branch
40
+ joe.build(params[:branch])
41
+ end
42
+
43
+ redirect request.path
44
+ end
45
+
46
+ get '/api/json' do
47
+ response = [200, {'Content-Type' => 'application/json'}]
48
+ response_json = erb(:json, {}, :joe => joe)
49
+ if params[:jsonp]
50
+ response << params[:jsonp] + '(' + response_json + ')'
51
+ else
52
+ response << response_json
53
+ end
54
+ response
55
+ end
56
+
57
+
58
+ helpers do
59
+ include Rack::Utils
60
+ alias_method :h, :escape_html
61
+
62
+ # thanks integrity!
63
+ def ansi_color_codes(string)
64
+ string.gsub("\e[0m", '</span>').
65
+ gsub(/\e\[(\d+)m/, "<span class=\"color\\1\">")
66
+ end
67
+
68
+ def pretty_time(time)
69
+ time.strftime("%Y-%m-%d %H:%M")
70
+ end
71
+
72
+ def cijoe_root
73
+ root = request.path
74
+ root = "" if root == "/"
75
+ root
76
+ end
77
+ end
78
+
79
+ def initialize(*args)
80
+ super
81
+ check_project
82
+ @joe = CIJoe.new(options.project_path)
83
+ end
84
+
85
+ def self.start(host, port, project_path)
86
+ set :project_path, project_path
87
+ CIJoe::Server.run! :host => host, :port => port
88
+ end
89
+
90
+ def self.rack_start(project_path)
91
+ set :project_path, project_path
92
+ self.new
93
+ end
94
+
95
+ def self.project_path=(project_path)
96
+ user, pass = Config.cijoe(project_path).user.to_s, Config.cijoe(project_path).pass.to_s
97
+ if user != '' && pass != ''
98
+ use Rack::Auth::Basic do |username, password|
99
+ [ username, password ] == [ user, pass ]
100
+ end
101
+ puts "Using HTTP basic auth"
102
+ end
103
+ set :project_path, Proc.new{project_path}, true
104
+ end
105
+
106
+ def check_project
107
+ if options.project_path.nil? || !File.exists?(File.expand_path(options.project_path))
108
+ puts "Whoops! I need the path to a Git repo."
109
+ puts " $ git clone git@github.com:username/project.git project"
110
+ abort " $ cijoe project"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,3 @@
1
+ class CIJoe
2
+ Version = VERSION = "0.9.3"
3
+ end
@@ -0,0 +1,16 @@
1
+ { "jobs": [
2
+ <% if joe.last_build %>
3
+ {"name":"<%= joe.project %>",
4
+ "url":"<%= joe.url %>",
5
+ "color":"<%= joe.last_build.status.to_s == "failed" ? 'red' : 'blue' %>",
6
+ "status":"<%= joe.last_build.status %>",
7
+ "started_at":"<%= pretty_time(joe.last_build.started_at) %>",
8
+ "finished_at":"<%= pretty_time(joe.last_build.finished_at) %>",
9
+ "duration":"<%= joe.last_build.duration if joe.last_build.duration %>",
10
+ "sha":"<%= joe.last_build.sha %>",
11
+ "short_sha":"<%= joe.last_build.short_sha %>",
12
+ "commit_url":"<%= joe.last_build.commit.url if joe.last_build.commit %>",
13
+ "branch":"<%= joe.last_build.branch %>"
14
+ }
15
+ <% end %>
16
+ ]}
@@ -0,0 +1,74 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <link href="<%= cijoe_root %>/screen.css" media="screen" rel="stylesheet" type="text/css" />
5
+ <link rel="shortcut icon" href="<%= cijoe_root %>/favicon.ico" type="image/x-icon" />
6
+ <title><%= h(joe.project) %>: CI Joe</title>
7
+ </head>
8
+ <body>
9
+ <div class="site">
10
+ <div class="title">
11
+ <a href="<%= cijoe_root %>/">CI Joe</a>
12
+ <span class="extra">because knowing is half the battle</span>
13
+ </div>
14
+
15
+ <div id="home">
16
+ <h1><a href="<%= joe.url %>"><%= joe.project %></a></h1>
17
+ <ul class="posts">
18
+ <% if joe.current_build %>
19
+ <li>
20
+ <span class="date"><%= pretty_time(joe.current_build.started_at) if joe.current_build %></span> &raquo;
21
+ <% if joe.current_build.sha %>
22
+ Building <%= joe.current_build.branch %> at <a href="<%= joe.current_build.commit.url %>"><%= joe.current_build.short_sha %></a> <small>(pid: <%= joe.pid %>)</small>
23
+ <% else %>
24
+ Build starting...
25
+ <% end %>
26
+ </li>
27
+ <% else %>
28
+ <li><form method="POST"><input type="hidden", name="rebuild" value="true"><input type="submit" value="Build"/></form></li>
29
+ <% end %>
30
+
31
+ <% if joe.last_build %>
32
+ <li>
33
+ <span class="date"><%= pretty_time(joe.last_build.finished_at) %></span> &raquo;
34
+ <% if joe.last_build.sha %>
35
+ Built <%= joe.last_build.branch %> at <a href="<%= joe.last_build.commit.url %>"><%= joe.last_build.short_sha %></a>
36
+ <% end %>
37
+ <span class="<%= joe.last_build.status %>">(<%= joe.last_build.status %>)</span>
38
+ <% if joe.last_build.duration %>
39
+ in <span class="duration"><%= joe.last_build.duration %></span> seconds.
40
+ <% end %>
41
+ </li>
42
+ <% if joe.last_build.failed? %>
43
+ <li><pre class="terminal"><code><%=ansi_color_codes h(joe.last_build.output) %></code></pre></li>
44
+ <% end %>
45
+ <% end %>
46
+ </ul>
47
+ </div>
48
+
49
+ <div class="footer">
50
+ <div class="contact">
51
+ <p>
52
+ <a href="https://github.com/defunkt/cijoe#readme">Documentation</a><br/>
53
+ <a href="https://github.com/defunkt/cijoe">Source</a><br/>
54
+ <a href="https://github.com/defunkt/cijoe/issues">Issues</a><br/>
55
+ <a href="https://github.com/defunkt/cijoe/tree/v<%= CIJoe::VERSION %>">v<%= CIJoe::VERSION %></a>
56
+ </p>
57
+ </div>
58
+ <div class="contact">
59
+ <p>
60
+ Designed by <a href="http://tom.preston-werner.com/">Tom Preston-Werner</a><br/>
61
+ Influenced by <a href="http://integrityapp.com/">Integrity</a><br/>
62
+ Built with <a href="http://sinatrarb.com/">Sinatra</a><br/>
63
+ Keep it simple, Sam.
64
+ </p>
65
+ </div>
66
+ <div class="rss">
67
+ <a href="http://github.com/defunkt/cijoe">
68
+ <img src="<%= cijoe_root %>/octocat.png" alt="Octocat!" />
69
+ </a>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </body>
74
+ </html>
data/lib/cijoe.rb ADDED
@@ -0,0 +1,225 @@
1
+ ##
2
+ # CI Joe.
3
+ # Because knowing is half the battle.
4
+ #
5
+ # This is a stupid simple CI server. It can build one (1)
6
+ # git-based project only.
7
+ #
8
+ # It only remembers the last build.
9
+ #
10
+ # It only notifies to Campfire.
11
+ #
12
+ # It's a RAH (Real American Hero).
13
+ #
14
+ # Seriously, I'm gonna be nuts about keeping this simple.
15
+
16
+ require 'cijoe/version'
17
+ require 'cijoe/config'
18
+ require 'cijoe/commit'
19
+ require 'cijoe/build'
20
+ require 'cijoe/campfire'
21
+ require 'cijoe/server'
22
+ require 'cijoe/queue'
23
+
24
+ class CIJoe
25
+ attr_reader :user, :project, :url, :current_build, :last_build, :campfire
26
+
27
+ def initialize(project_path)
28
+ @project_path = File.expand_path(project_path)
29
+
30
+ @user, @project = git_user_and_project
31
+ @url = "http://github.com/#{@user}/#{@project}"
32
+
33
+ @campfire = CIJoe::Campfire.new(project_path)
34
+
35
+ @last_build = nil
36
+ @current_build = nil
37
+ @queue = Queue.new(!repo_config.buildqueue.to_s.empty?, true)
38
+
39
+ trap("INT") { stop }
40
+ end
41
+
42
+ # is a build running?
43
+ def building?
44
+ !!@current_build
45
+ end
46
+
47
+ # the pid of the running child process
48
+ def pid
49
+ building? and current_build.pid
50
+ end
51
+
52
+ # kill the child and exit
53
+ def stop
54
+ Process.kill(9, pid) if pid
55
+ exit!
56
+ end
57
+
58
+ # build callbacks
59
+ def build_failed(output, error)
60
+ finish_build :failed, "#{error}\n\n#{output}"
61
+ run_hook "build-failed"
62
+ end
63
+
64
+ def build_worked(output)
65
+ finish_build :worked, output
66
+ run_hook "build-worked"
67
+ end
68
+
69
+ def finish_build(status, output)
70
+ @current_build.finished_at = Time.now
71
+ @current_build.status = status
72
+ @current_build.output = output
73
+ @last_build = @current_build
74
+
75
+ @current_build = nil
76
+ write_build 'current', @current_build
77
+ write_build 'last', @last_build
78
+ @campfire.notify(@last_build) if @campfire.valid?
79
+
80
+ build(@queue.next_branch_to_build) if @queue.waiting?
81
+ end
82
+
83
+ # run the build but make sure only one is running
84
+ # at a time (if new one comes in we will park it)
85
+ def build(branch=nil)
86
+ if building?
87
+ @queue.append_unless_already_exists(branch)
88
+ # leave anyway because a current build runs
89
+ return
90
+ end
91
+ @current_build = Build.new(@project_path, @user, @project)
92
+ write_build 'current', @current_build
93
+ Thread.new { build!(branch) }
94
+ end
95
+
96
+ def open_pipe(cmd)
97
+ read, write = IO.pipe
98
+
99
+ pid = fork do
100
+ read.close
101
+ $stdout.reopen write
102
+ exec cmd
103
+ end
104
+
105
+ write.close
106
+
107
+ yield read, pid
108
+ end
109
+
110
+ # update git then run the build
111
+ def build!(branch=nil)
112
+ @git_branch = branch
113
+ build = @current_build
114
+ output = ''
115
+ git_update
116
+ build.sha = git_sha
117
+ build.branch = git_branch
118
+ write_build 'current', build
119
+
120
+ open_pipe("cd \"#{@project_path}\" && #{runner_command} 2>&1") do |pipe, pid|
121
+ puts "#{Time.now.to_i}: Building #{build.branch} at #{build.short_sha}: pid=#{pid}"
122
+
123
+ build.pid = pid
124
+ write_build 'current', build
125
+ output = pipe.read
126
+ end
127
+
128
+ Process.waitpid(build.pid, 1)
129
+ status = $?.exitstatus.to_i
130
+ @current_build = build
131
+ puts "#{Time.now.to_i}: Built #{build.short_sha}: status=#{status}"
132
+
133
+ status == 0 ? build_worked(output) : build_failed('', output)
134
+ rescue Object => e
135
+ puts "Exception building: #{e.message} (#{e.class})"
136
+ build_failed('', e.to_s)
137
+ end
138
+
139
+ # shellin' out
140
+ def runner_command
141
+ runner = repo_config.runner.to_s
142
+ runner == '' ? "rake -s test:units" : runner
143
+ end
144
+
145
+ def git_sha
146
+ `cd \"#{@project_path}\" && git rev-parse origin/#{git_branch}`.chomp
147
+ end
148
+
149
+ def git_update
150
+ `cd \"#{@project_path}\" && git fetch origin && git reset --hard origin/#{git_branch}`
151
+ run_hook "after-reset"
152
+ end
153
+
154
+ def git_user_and_project
155
+ Config.remote(@project_path).origin.url.to_s.chomp('.git').split(':')[-1].split('/')[-2, 2]
156
+ end
157
+
158
+ def git_branch
159
+ return @git_branch if @git_branch
160
+ branch = repo_config.branch.to_s
161
+ @git_branch = branch == '' ? "master" : branch
162
+ end
163
+
164
+ # massage our repo
165
+ def run_hook(hook)
166
+ if File.exists?(file=path_in_project(".git/hooks/#{hook}")) && File.executable?(file)
167
+ data =
168
+ if @last_build && @last_build.commit
169
+ {
170
+ "MESSAGE" => @last_build.commit.message,
171
+ "AUTHOR" => @last_build.commit.author,
172
+ "SHA" => @last_build.commit.sha,
173
+ "OUTPUT" => @last_build.env_output
174
+ }
175
+ else
176
+ {}
177
+ end
178
+
179
+ orig_ENV = ENV.to_hash
180
+ ENV.clear
181
+ data.each{ |k, v| ENV[k] = v }
182
+ output = `cd \"#{@project_path}\" && sh #{file}`
183
+
184
+ ENV.clear
185
+ orig_ENV.to_hash.each{ |k, v| ENV[k] = v}
186
+ output
187
+ end
188
+ end
189
+
190
+ # restore current / last build state from disk.
191
+ def restore
192
+ @last_build = read_build('last')
193
+ @current_build = read_build('current')
194
+
195
+ Process.kill(0, @current_build.pid) if @current_build && @current_build.pid
196
+ rescue Errno::ESRCH
197
+ # build pid isn't running anymore. assume previous
198
+ # server died and reset.
199
+ @current_build = nil
200
+ end
201
+
202
+ def path_in_project(path)
203
+ File.join(@project_path, path)
204
+ end
205
+
206
+ # write build info for build to file.
207
+ def write_build(name, build)
208
+ filename = path_in_project(".git/builds/#{name}")
209
+ Dir.mkdir path_in_project('.git/builds') unless File.directory?(path_in_project('.git/builds'))
210
+ if build
211
+ build.dump filename
212
+ elsif File.exist?(filename)
213
+ File.unlink filename
214
+ end
215
+ end
216
+
217
+ def repo_config
218
+ Config.cijoe(@project_path)
219
+ end
220
+
221
+ # load build info from file.
222
+ def read_build(name)
223
+ Build.load(path_in_project(".git/builds/#{name}"), @project_path)
224
+ end
225
+ end
@@ -0,0 +1,52 @@
1
+ {
2
+ "after": "416cb2f7105e7f989bc223d1a975b93a6491b276",
3
+ "before": "0ae4da1eea2d191b33ebfbd5db48e3c1f91953ad",
4
+ "commits": [
5
+ {
6
+ "added": [
7
+
8
+ ],
9
+ "author": {
10
+ "email": "joshua.owens@gmail.com",
11
+ "name": "Josh Owens",
12
+ "username": "queso"
13
+ },
14
+ "id": "09293a1703b3bdb36aba70d38abd5e44396c50a5",
15
+ "message": "Update README",
16
+ "modified": [
17
+ "README.textile"
18
+ ],
19
+ "removed": [
20
+
21
+ ],
22
+ "timestamp": "2011-02-07T15:27:35-08:00",
23
+ "url": "https:\/\/github.com\/fourbeansoup\/broth\/commit\/09293a1703b3bdb36aba70d38abd5e44396c50a5"
24
+ }
25
+ ],
26
+ "compare": "https:\/\/github.com\/fourbeansoup\/broth\/compare\/0ae4da1...416cb2f",
27
+ "forced": false,
28
+ "ref": "refs\/heads\/master",
29
+ "repository": {
30
+ "created_at": "2009\/12\/05 10:44:57 -0800",
31
+ "description": "FourBeanSoup's Broth Application, a tasty starting point for every app",
32
+ "fork": true,
33
+ "forks": 4,
34
+ "has_downloads": true,
35
+ "has_issues": true,
36
+ "has_wiki": true,
37
+ "homepage": "",
38
+ "language": "JavaScript",
39
+ "name": "broth",
40
+ "open_issues": 2,
41
+ "organization": "fourbeansoup",
42
+ "owner": {
43
+ "email": "josh+scm@fourbeansoup.com",
44
+ "name": "fourbeansoup"
45
+ },
46
+ "private": false,
47
+ "pushed_at": "2011\/02\/07 17:17:00 -0800",
48
+ "size": 604,
49
+ "url": "https:\/\/github.com\/fourbeansoup\/broth",
50
+ "watchers": 10
51
+ }
52
+ }
data/test/helper.rb ADDED
@@ -0,0 +1,47 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'mocha'
4
+
5
+ ENV['RACK_ENV'] = 'test'
6
+
7
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
8
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
9
+ require 'cijoe'
10
+
11
+ CIJoe::Server.set :project_path, "."
12
+ CIJoe::Server.set :environment, "test"
13
+
14
+ TMP_DIR = '/tmp/cijoe_test'
15
+
16
+ def tmp_dir
17
+ TMP_DIR
18
+ end
19
+
20
+ def setup_git_info(options = {})
21
+ @tmp_dirs ||= []
22
+ @tmp_dirs += [options[:tmp_dir]]
23
+ create_tmpdir!(options[:tmp_dir])
24
+ dir = options[:tmp_dir] || tmp_dir
25
+ `cd #{dir} && git init`
26
+ options[:config].each do |key, value|
27
+ `cd #{dir} && git config --add #{key} "#{value}"`
28
+ end
29
+ end
30
+
31
+ def teardown_git_info
32
+ remove_tmpdir!
33
+ @tmp_dirs.each do |dir|
34
+ remove_tmpdir!(dir)
35
+ end
36
+ end
37
+
38
+ def remove_tmpdir!(passed_dir = nil)
39
+ FileUtils.rm_rf(passed_dir || tmp_dir)
40
+ end
41
+
42
+ def create_tmpdir!(passed_dir = nil)
43
+ FileUtils.mkdir_p(passed_dir || tmp_dir)
44
+ end
45
+
46
+ class Test::Unit::TestCase
47
+ end
@@ -0,0 +1,48 @@
1
+ require "helper"
2
+ require "cijoe"
3
+ require "fakefs/safe"
4
+
5
+
6
+ class TestCampfire < Test::Unit::TestCase
7
+
8
+ class ::CIJoe
9
+ attr_writer :current_build, :last_build
10
+ end
11
+
12
+ attr_accessor :app
13
+
14
+ def setup
15
+ @app = CIJoe::Server.new
16
+ joe = @app.joe
17
+
18
+ # make Build#restore a no-op so we don't overwrite our current/last
19
+ # build attributes set from tests.
20
+ def joe.restore
21
+ end
22
+
23
+ # make CIJoe#build! and CIJoe#git_update a no-op so we don't overwrite our local changes
24
+ # or local commits nor should we run tests.
25
+ def joe.build!
26
+ end
27
+ end
28
+
29
+ def teardown
30
+ teardown_git_info
31
+ end
32
+
33
+ def test_campfire_pulls_campfire_config_from_git_config
34
+ setup_git_info(:config => {"campfire.subdomain" => "github", "remote.origin.url" => "https://github.com/defunkt/cijoe.git"})
35
+ cf = CIJoe::Campfire.new(tmp_dir)
36
+ assert_equal "github", cf.campfire_config[:subdomain]
37
+ end
38
+
39
+ def test_campfire_pulls_campfire_config_from_its_own_git_config
40
+ setup_git_info(:config => {"campfire.subdomain" => "github"})
41
+ setup_git_info(:config => {"campfire.subdomain" => "37signals"}, :tmp_dir => "/tmp/cijoe_test_37signals")
42
+ cf1 = CIJoe::Campfire.new(tmp_dir)
43
+ cf2 = CIJoe::Campfire.new("/tmp/cijoe_test_37signals")
44
+ assert_equal "github", cf1.campfire_config[:subdomain]
45
+ assert_equal "37signals", cf2.campfire_config[:subdomain]
46
+ end
47
+
48
+ end
@@ -0,0 +1,17 @@
1
+ require 'helper'
2
+
3
+ class TestCIJoe < Test::Unit::TestCase
4
+ def test_raise_error_on_invalid_command
5
+ assert_raise RuntimeError, LoadError do
6
+ CIJoe::Config.new('--invalid').to_s
7
+ end
8
+ end
9
+
10
+ def test_return_value_of_config
11
+ assert_equal `git config blame`.chomp, CIJoe::Config.new('blame').to_s
12
+ end
13
+
14
+ def test_return_empty_string_when_config_does_not_exist
15
+ assert_equal '', CIJoe::Config.new('invalid').to_s
16
+ end
17
+ end
@@ -0,0 +1,28 @@
1
+ require 'helper'
2
+
3
+ class TestCIJoeQueue < Test::Unit::TestCase
4
+ def test_a_disabled_queue
5
+ subject = CIJoe::Queue.new(false)
6
+ subject.append_unless_already_exists("test")
7
+ assert_equal false, subject.waiting?
8
+ end
9
+
10
+ def test_adding_two_items_to_a_queue
11
+ subject = CIJoe::Queue.new(true)
12
+ subject.append_unless_already_exists("test")
13
+ subject.append_unless_already_exists(nil)
14
+ assert_equal true, subject.waiting?
15
+ assert_equal "test", subject.next_branch_to_build
16
+ assert_equal nil, subject.next_branch_to_build
17
+ assert_equal false, subject.waiting?
18
+ end
19
+
20
+ def test_adding_two_duplicate_items_to_a_queue
21
+ subject = CIJoe::Queue.new(true)
22
+ subject.append_unless_already_exists("test")
23
+ subject.append_unless_already_exists("test")
24
+ assert_equal true, subject.waiting?
25
+ assert_equal "test", subject.next_branch_to_build
26
+ assert_equal false, subject.waiting?
27
+ end
28
+ end