scrumninja-git-cli 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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