scrumninja-git-cli 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +21 -0
- data/LICENSE +20 -0
- data/README.rdoc +17 -0
- data/Rakefile +57 -0
- data/VERSION +1 -0
- data/bin/scrumninja-git-cli +3 -0
- data/lib/git_wrapper.rb +164 -0
- data/lib/scrum_ninja.rb +2 -0
- data/lib/scrum_ninja/server.rb +34 -0
- data/lib/scrum_ninja/session.rb +87 -0
- data/lib/scrum_ninja/story.rb +25 -0
- data/lib/scrum_ninja_git_cli.rb +187 -0
- data/lib/shell_cmd.rb +16 -0
- data/test/helper.rb +14 -0
- data/test/scrum_ninja/test_scrum_ninja_server.rb +42 -0
- data/test/scrum_ninja/test_scrum_ninja_session.rb +163 -0
- data/test/scrum_ninja/test_scrum_ninja_story.rb +31 -0
- data/test/test_git_wrapper.rb +375 -0
- data/test/test_scrum_ninja_git_cli.rb +224 -0
- metadata +156 -0
@@ -0,0 +1,187 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'scrum_ninja'
|
3
|
+
require 'git_wrapper'
|
4
|
+
require 'pp'
|
5
|
+
require 'forwardable'
|
6
|
+
|
7
|
+
module ScrumNinjaGitCli
|
8
|
+
module_function
|
9
|
+
|
10
|
+
HelpText = <<-EOF
|
11
|
+
|
12
|
+
The 'story' command is used to support RF's normal release workflow.
|
13
|
+
|
14
|
+
Available commands:
|
15
|
+
|
16
|
+
[Can be run from any branch]
|
17
|
+
story
|
18
|
+
story list list all stories
|
19
|
+
story info 12345 show only the one story
|
20
|
+
story xml 12345 show full XML dump (from ScrumNinja API) for one story
|
21
|
+
story own 12345[, new_owner_id] Set owner of the given story (defaults to value in .scrumninja.yml)
|
22
|
+
story git [any args] Run the git command with the specified args (doesn't complain about env var)
|
23
|
+
|
24
|
+
[Must be run from master, with clean working state]
|
25
|
+
story start 12345 Check out a story branch and change ownership [if not already set]
|
26
|
+
story join 12345 Check out a story branch without trying to change ownership
|
27
|
+
story dev-task "some dev task" Check out a dev-task branch (no ScrumNinja interaction)
|
28
|
+
|
29
|
+
[Must be run from story branch, with clean working state]
|
30
|
+
story push-branch git: pull --rebase; push origin story_branch
|
31
|
+
story merge-from-master git: checkout master; pull; checkout story_branch; merge master
|
32
|
+
story deliver squash and merge your story branch into master, then push the result.
|
33
|
+
(DOES NOT update ScrumNinja.)
|
34
|
+
|
35
|
+
Configuration:
|
36
|
+
|
37
|
+
Information required to interact with ScrumNinja will be read from a
|
38
|
+
file named .scrumninja.yml. The command will search for this file in
|
39
|
+
both the current dir and your home dir. Values defined in ./.scrumninja.yml
|
40
|
+
will override those in ~/.scrumninja.yml.
|
41
|
+
|
42
|
+
Example YAML for this file:
|
43
|
+
api_key: abc123 # generate this at: https://scrumninja.com/user/edit
|
44
|
+
project_id: 4493 # swipe this from the address bar
|
45
|
+
user_id: 4256 # edit a story card, view source, find element #story_owner_user_id
|
46
|
+
|
47
|
+
EOF
|
48
|
+
|
49
|
+
Messages = {
|
50
|
+
:git_version => 'You must be running Git 1.7 or newer.',
|
51
|
+
:clean_working_state => 'Working state must be clean.',
|
52
|
+
:must_be_on_master => "Branch 'master' must be checked out.",
|
53
|
+
:wrong_branch => "Your story branch must be checked out.",
|
54
|
+
:rake_failure => "Rake failed. Aborting.",
|
55
|
+
}
|
56
|
+
|
57
|
+
# Simple command-line argument dispatching (see bottom of file)
|
58
|
+
def run_command(*args)
|
59
|
+
cmd = args.shift.to_s.gsub('-', '_')
|
60
|
+
send(cmd, *args)
|
61
|
+
rescue ArgumentError
|
62
|
+
help
|
63
|
+
end
|
64
|
+
|
65
|
+
# Commands
|
66
|
+
def help
|
67
|
+
output HelpText
|
68
|
+
end
|
69
|
+
|
70
|
+
def git(*args)
|
71
|
+
exec("export I_AM_A_GIT_GURU=1; git #{args.join(" ")}")
|
72
|
+
end
|
73
|
+
|
74
|
+
def list
|
75
|
+
stories = session.get_stories
|
76
|
+
output stories.map(&:to_s).join("\n") # TODO: can we drop the #map?
|
77
|
+
end
|
78
|
+
|
79
|
+
def info(story_id)
|
80
|
+
output session.get_story(story_id)
|
81
|
+
end
|
82
|
+
|
83
|
+
def xml(story_id)
|
84
|
+
output session.get_story_xml(story_id)
|
85
|
+
end
|
86
|
+
|
87
|
+
def own(story_id, new_owner_id = nil, opts={})
|
88
|
+
session.update_ownership(story_id, new_owner_id, opts)
|
89
|
+
end
|
90
|
+
|
91
|
+
|
92
|
+
def join(story_id)
|
93
|
+
story = session.get_story(story_id)
|
94
|
+
start_work_on_branch story.branch_name
|
95
|
+
end
|
96
|
+
|
97
|
+
def start(story_id)
|
98
|
+
join story_id
|
99
|
+
own story_id, nil, :abort_if_already_owned => true
|
100
|
+
end
|
101
|
+
|
102
|
+
def dev_task(description)
|
103
|
+
start_work_on_branch('dev-' + GitWrapper.string_hyphenize(description))
|
104
|
+
end
|
105
|
+
|
106
|
+
|
107
|
+
def push_branch
|
108
|
+
git_wrapper.push_branch
|
109
|
+
rescue GitWrapper::WorkingFolderDirtyException
|
110
|
+
print_message :clean_working_state
|
111
|
+
end
|
112
|
+
|
113
|
+
def merge_from_master
|
114
|
+
git_wrapper.merge_from_master
|
115
|
+
rescue GitWrapper::WrongBranchException
|
116
|
+
print_message :wrong_branch
|
117
|
+
end
|
118
|
+
|
119
|
+
def deliver
|
120
|
+
if 'master' == git_wrapper.current_branch_name
|
121
|
+
output Messages[:wrong_branch] and return
|
122
|
+
end
|
123
|
+
|
124
|
+
git_wrapper.push_branch
|
125
|
+
git_wrapper.merge_from_master
|
126
|
+
return unless rake
|
127
|
+
git_wrapper.merge_branch_into_master
|
128
|
+
git_wrapper.push_master
|
129
|
+
end
|
130
|
+
|
131
|
+
def add(*args)
|
132
|
+
git_wrapper.add(args.join(" "))
|
133
|
+
end
|
134
|
+
|
135
|
+
def commit
|
136
|
+
commit_msg_parts = git_wrapper.current_branch_name \
|
137
|
+
.split('-').map(&:capitalize)
|
138
|
+
commit_msg_parts.unshift(commit_msg_parts.shift + ":") \
|
139
|
+
.unshift('Story')
|
140
|
+
commit_msg = commit_msg_parts.join(" ")
|
141
|
+
git_wrapper.commit(commit_msg)
|
142
|
+
end
|
143
|
+
|
144
|
+
|
145
|
+
# Support functions
|
146
|
+
def git_wrapper
|
147
|
+
GitWrapper
|
148
|
+
end
|
149
|
+
|
150
|
+
def session
|
151
|
+
@session ||= ScrumNinja::Session.new(:project_dir => RAILS_ROOT)
|
152
|
+
end
|
153
|
+
|
154
|
+
def print_message(message)
|
155
|
+
output Messages[message]
|
156
|
+
end
|
157
|
+
|
158
|
+
def output(*args)
|
159
|
+
puts *args
|
160
|
+
true
|
161
|
+
end
|
162
|
+
|
163
|
+
def rake
|
164
|
+
if 0 == ShellCmd.run('rake')
|
165
|
+
true
|
166
|
+
else
|
167
|
+
print_message :rake_failure
|
168
|
+
false
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def start_work_on_branch(branch_name)
|
173
|
+
unless git_wrapper.is_git_current_enough?
|
174
|
+
output Messages[:git_version] and return
|
175
|
+
end
|
176
|
+
|
177
|
+
unless git_wrapper.is_state_clean?
|
178
|
+
output Messages[:clean_working_state] and return
|
179
|
+
end
|
180
|
+
|
181
|
+
unless 'master' == git_wrapper.current_branch_name
|
182
|
+
output Messages[:must_be_on_master] and return
|
183
|
+
end
|
184
|
+
|
185
|
+
git_wrapper.checkout(branch_name)
|
186
|
+
end
|
187
|
+
end
|
data/lib/shell_cmd.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
module ShellCmd
|
2
|
+
module_function
|
3
|
+
|
4
|
+
# Run the command, displaying output as we get it,
|
5
|
+
# and return the process's result code.
|
6
|
+
# Used mostly to avoid http://xkcd.com/303/
|
7
|
+
def run(cmd)
|
8
|
+
IO.popen(cmd) do |output|
|
9
|
+
while c = output.getc do
|
10
|
+
print c.chr
|
11
|
+
STDOUT.flush
|
12
|
+
end
|
13
|
+
end
|
14
|
+
$?.to_i
|
15
|
+
end
|
16
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'test/unit'
|
3
|
+
require 'shoulda'
|
4
|
+
require 'mocha'
|
5
|
+
|
6
|
+
$LOAD_PATH.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')))
|
7
|
+
$LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__)))
|
8
|
+
$LOAD_PATH.unshift(File.expand_path(File.join(File.dirname(__FILE__), '..')))
|
9
|
+
|
10
|
+
class Test::Unit::TestCase
|
11
|
+
def deny(assertion, *args)
|
12
|
+
assert !assertion, *args
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'helper'))
|
2
|
+
require 'scrum_ninja'
|
3
|
+
|
4
|
+
|
5
|
+
class ScrumNinjaServerUnitTest < Test::Unit::TestCase
|
6
|
+
def setup
|
7
|
+
@api_key = 'abc123'
|
8
|
+
end
|
9
|
+
|
10
|
+
context "#get_story" do
|
11
|
+
should "call the API and return the response body" do
|
12
|
+
xml = "bogus XML response"
|
13
|
+
project_id, story_id = 23, 42
|
14
|
+
ScrumNinja::Server.expects(:get_page_contents) \
|
15
|
+
.with("http://scrumninja.com/projects/#{project_id}/stories/#{story_id}.xml?api_key=#{@api_key}") \
|
16
|
+
.returns(xml)
|
17
|
+
result = ScrumNinja::Server.get_story(@api_key, project_id, story_id)
|
18
|
+
assert_equal(xml, result)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
context "#get_stories" do
|
23
|
+
should "call the API and return the response body" do
|
24
|
+
xml = "bogus XML response"
|
25
|
+
ScrumNinja::Server.expects(:get_page_contents) \
|
26
|
+
.with("http://scrumninja.com/projects/42/stories.xml?api_key=#{@api_key}") \
|
27
|
+
.returns(xml)
|
28
|
+
result = ScrumNinja::Server.get_stories(@api_key, 42)
|
29
|
+
assert_equal(xml, result)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
context "#update_ownership" do
|
34
|
+
should "put XML to the API and (assuming success) shut up about it" do
|
35
|
+
project_id, story_id, xml = 23, 42, "bogus XML payload"
|
36
|
+
uri = "http://scrumninja.com/projects/#{project_id}/stories/#{story_id}.xml?api_key=#{@api_key}"
|
37
|
+
ScrumNinja::Server.expects(:post_xml).with(uri, xml).returns(true)
|
38
|
+
result = ScrumNinja::Server.update_story(@api_key, project_id, story_id, xml)
|
39
|
+
assert_equal(true, result)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'helper'))
|
2
|
+
require 'scrum_ninja'
|
3
|
+
|
4
|
+
|
5
|
+
class ScrumNinjaSessionUnitTest < Test::Unit::TestCase
|
6
|
+
ConfigFile = '.scrumninja.yml'
|
7
|
+
HomePath = File.expand_path('~')
|
8
|
+
ProjPath = '~/work/pdxrails'
|
9
|
+
|
10
|
+
# Holler if we inadvertently try to touch the filesystem
|
11
|
+
def setup
|
12
|
+
ScrumNinja::Session.read_config_from_filesystem = false
|
13
|
+
YAML.expects(:load_file).never
|
14
|
+
end
|
15
|
+
|
16
|
+
context "class" do
|
17
|
+
should "have a #read_config_from_filesystem accessor" do
|
18
|
+
klass = ScrumNinja::Session
|
19
|
+
klass.read_config_from_filesystem = true
|
20
|
+
assert_equal(true, klass.read_config_from_filesystem)
|
21
|
+
klass.read_config_from_filesystem = false
|
22
|
+
assert_equal(false, klass.read_config_from_filesystem)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
context 'initialization' do
|
27
|
+
context 'of attributes' do
|
28
|
+
should 'take an api key as a string and make it available via a getter' do
|
29
|
+
s = ScrumNinja::Session.new(:api_key => 'abc123')
|
30
|
+
assert_equal 'abc123', s.api_key
|
31
|
+
end
|
32
|
+
|
33
|
+
should 'take a project_id and make it available via a getter' do
|
34
|
+
s = ScrumNinja::Session.new(:project_id => 42, :api_key => 'abc123')
|
35
|
+
assert_equal 42, s.project_id
|
36
|
+
end
|
37
|
+
|
38
|
+
should 'take a user_id and make it available via a getter' do
|
39
|
+
s = ScrumNinja::Session.new(:user_id => 42, :api_key => 'abc123')
|
40
|
+
assert_equal 42, s.user_id
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
should "look for a config file in project dir when class's read_config_from_filesystem is truthy" do
|
45
|
+
ScrumNinja::Session.read_config_from_filesystem = true
|
46
|
+
YAML.expects(:load_file).with(File.join(HomePath, ConfigFile)).returns({})
|
47
|
+
ScrumNinja::Session.new(:api_key => 'abc123')
|
48
|
+
end
|
49
|
+
|
50
|
+
should "not look for a config file in project dir when class's read_config_from_filesystem is falsy" do
|
51
|
+
ScrumNinja::Session.read_config_from_filesystem = false
|
52
|
+
YAML.expects(:load_file).never
|
53
|
+
ScrumNinja::Session.new(:api_key => 'abc123')
|
54
|
+
end
|
55
|
+
|
56
|
+
should 'raise a nice exception if no api key can be determined' do
|
57
|
+
ScrumNinja::Session.read_config_from_filesystem = true
|
58
|
+
YAML.expects(:load_file).with(File.join(HomePath, ConfigFile)).raises(Errno::ENOENT.new)
|
59
|
+
assert_raise(ArgumentError) { ScrumNinja::Session.new }
|
60
|
+
end
|
61
|
+
|
62
|
+
should "look for configuration in opts[:project_dir] first, then the user's home dir, with the former taking precedence" do
|
63
|
+
ScrumNinja::Session.read_config_from_filesystem = true
|
64
|
+
YAML.expects(:load_file).with(File.join(HomePath, ConfigFile)).returns({
|
65
|
+
:foo => 'less specific foo',
|
66
|
+
:api_key => "Ce n'est pas un API key",
|
67
|
+
}).at_least_once #(end)
|
68
|
+
|
69
|
+
YAML.expects(:load_file).with(File.join(ProjPath, ConfigFile)).returns({
|
70
|
+
:foo => 'more specific foo',
|
71
|
+
:bar => 'more specific bar',
|
72
|
+
}).at_least_once #(end)
|
73
|
+
|
74
|
+
session = ScrumNinja::Session.new(:project_dir => ProjPath)
|
75
|
+
assert_equal('more specific foo', session.options[:foo])
|
76
|
+
assert_equal('more specific bar', session.options[:bar])
|
77
|
+
assert_equal("Ce n'est pas un API key", session.options[:api_key])
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
context "get_stories" do
|
82
|
+
should "turn XML into Story objects" do
|
83
|
+
session = ScrumNinja::Session.new(:api_key => 'abc123', :project_id => 42)
|
84
|
+
xml = <<-XML
|
85
|
+
<stories>
|
86
|
+
<story>
|
87
|
+
<id type="integer">61363</id>
|
88
|
+
<name>Make all vendor website links open in new window instead of lightview</name>
|
89
|
+
<owner-user-id type="integer">1235</owner-user-id>
|
90
|
+
</story>
|
91
|
+
</stories>
|
92
|
+
XML
|
93
|
+
ScrumNinja::Server.expects(:get_stories) \
|
94
|
+
.with(session.api_key, session.project_id) \
|
95
|
+
.returns(xml)
|
96
|
+
expected = [
|
97
|
+
ScrumNinja::Story.new('Story', 61363, 'Make all vendor website links open in new window instead of lightview', 1235)
|
98
|
+
]
|
99
|
+
assert_equal expected, session.get_stories
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
context "get_story" do
|
104
|
+
should "turn XML into a Story" do
|
105
|
+
session = ScrumNinja::Session.new(:api_key => 'abc123', :project_id => 42)
|
106
|
+
xml = <<-XML
|
107
|
+
<story>
|
108
|
+
<id type="integer">61363</id>
|
109
|
+
<name>Make all vendor website links open in new window instead of lightview</name>
|
110
|
+
<owner-user-id type="integer">1235</owner-user-id>
|
111
|
+
</story>
|
112
|
+
XML
|
113
|
+
ScrumNinja::Server.expects(:get_story).with(session.api_key, session.project_id, 61363) \
|
114
|
+
.returns(xml)
|
115
|
+
expected = ScrumNinja::Story.new('Story', 61363, 'Make all vendor website links open in new window instead of lightview', 1235)
|
116
|
+
assert_equal expected, session.get_story(61363)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
context "update_ownership" do
|
121
|
+
should "generate the xml and pass it along to the server" do
|
122
|
+
project_id, story_id, new_owner_id = 13, 42, 23
|
123
|
+
session = ScrumNinja::Session.new(:api_key => 'abc123', :project_id => project_id)
|
124
|
+
xml = <<-XML
|
125
|
+
<story>
|
126
|
+
<owner-user-id type="integer">#{new_owner_id}</owner-user-id>
|
127
|
+
</story>
|
128
|
+
XML
|
129
|
+
ScrumNinja::Server.expects(:update_story).with(session.api_key, project_id, story_id, xml).returns(true)
|
130
|
+
assert_equal true, session.update_ownership(story_id, new_owner_id)
|
131
|
+
end
|
132
|
+
|
133
|
+
should "not overwrite an existing owner if abort_if_already_owned is true" do
|
134
|
+
project_id, story_id, new_owner_id = 13, 42, 23
|
135
|
+
session = ScrumNinja::Session.new(:api_key => 'abc123', :project_id => project_id)
|
136
|
+
session.stubs(:get_story).returns(stub_everything('story', :has_owner? => true))
|
137
|
+
ScrumNinja::Server.expects(:update_story).never
|
138
|
+
session.update_ownership(story_id, new_owner_id, :abort_if_already_owned => true)
|
139
|
+
end
|
140
|
+
|
141
|
+
end
|
142
|
+
|
143
|
+
# Note that this is temporarily useless; we don't appear to actually control the status.
|
144
|
+
context 'update_status' do
|
145
|
+
should 'generate the xml and pass it along to the server' do
|
146
|
+
project_id, story_id, new_status = 13, 42, 'in process'
|
147
|
+
session = ScrumNinja::Session.new(:api_key => 'abc123', :project_id => project_id)
|
148
|
+
xml = <<-XML
|
149
|
+
<story>
|
150
|
+
<status>#{new_status}</status>
|
151
|
+
</story>
|
152
|
+
XML
|
153
|
+
ScrumNinja::Server.expects(:update_story).with(session.api_key, project_id, story_id, xml).returns(true)
|
154
|
+
assert_equal true, session.update_status(story_id, new_status)
|
155
|
+
end
|
156
|
+
|
157
|
+
should 'raise an argument error if an invalid status is passed' do
|
158
|
+
session = ScrumNinja::Session.new(:api_key => 'abc123', :project_id => 42)
|
159
|
+
ScrumNinja::Server.expects(:update_story).never
|
160
|
+
assert_raise(ArgumentError) { session.update_status(-99, 'bogus status') }
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'helper'))
|
2
|
+
require 'scrum_ninja'
|
3
|
+
|
4
|
+
|
5
|
+
class StoryUnitTest < Test::Unit::TestCase
|
6
|
+
def story(*args)
|
7
|
+
ScrumNinja::Story.new(*args)
|
8
|
+
end
|
9
|
+
|
10
|
+
context "Story#to_s" do
|
11
|
+
should "output the story number, followed by the name" do
|
12
|
+
s = story('Story', 12345, "Luggage combination")
|
13
|
+
assert_equal("Story 12345: Luggage combination", s.to_s)
|
14
|
+
|
15
|
+
s = story('Bug', 9, "It doesn't work")
|
16
|
+
assert_equal("Bug 9: It doesn't work", s.to_s)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
context "Story#branch_name" do
|
21
|
+
should "return the story_number with a hyphenized name" do
|
22
|
+
s = story('Bug', 9, "It doesn't work")
|
23
|
+
assert_equal '9-it-doesnt-work', s.branch_name
|
24
|
+
end
|
25
|
+
|
26
|
+
should "return a probably-unique branch name if the name can't be calculated for any reason" do
|
27
|
+
s = story('Bug', 9, nil)
|
28
|
+
assert_match(/-indeterminate-branch-name-/, s.branch_name)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|