scrumninja-git-cli 0.0.2
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.
- 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
|