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