github-pivotal-flow 0.0.1

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,21 @@
1
+ # Utilities for dealing with the shell
2
+ module GithubPivotalFlow
3
+ class Shell
4
+
5
+ # Executes a command
6
+ #
7
+ # @param [String] command the command to execute
8
+ # @param [Boolean] abort_on_failure whether to +Kernel#abort+ with +FAIL+ as
9
+ # the message when the command's +Status#existstatus+ is not +0+
10
+ # @return [String] the result of the command
11
+ def self.exec(command, abort_on_failure = true)
12
+ result = `#{command}`
13
+ if $?.exitstatus != 0 && abort_on_failure
14
+ puts "Failed command: #{command}"
15
+ abort 'FAIL'
16
+ end
17
+
18
+ result
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,40 @@
1
+ # The class that encapsulates starting a Pivotal Tracker Story
2
+ module GithubPivotalFlow
3
+ class Start < GithubPivotalFlow::Command
4
+ def run!
5
+ filter = @options[:args]
6
+ #TODO: Validate the format of the filter argument
7
+ story = Story.select_story @project, filter
8
+ Story.pretty_print story
9
+ story.request_estimation! if story.unestimated?
10
+ story.create_branch!
11
+ @configuration.story = story # Tag the branch with story attributes
12
+ Git.add_hook 'prepare-commit-msg', File.join(File.dirname(__FILE__), 'prepare-commit-msg.sh')
13
+ unless story.release?
14
+ @ghclient = GitHubAPI.new(@configuration, :app_url => 'http://github.com/roomorama/github-pivotal-flow')
15
+ create_pull_request_for_story!(story)
16
+ end
17
+ story.mark_started!
18
+ return 0
19
+ end
20
+
21
+ private
22
+
23
+ def create_pull_request_for_story!(story)
24
+ print "Creating pull-request on Github... "
25
+ @ghclient.create_pullrequest({:project => @configuration.github_project}.merge(story.params_for_pull_request))
26
+ puts 'OK'
27
+ end
28
+
29
+ def parse_argv(*args)
30
+ OptionParser.new do |opts|
31
+ opts.banner = "Usage: git start <feature|chore|bug|story_id>"
32
+ opts.on("-t", "--api-token=", "Pivotal Tracker API key") { |k| options[:api_token] = k }
33
+ opts.on("-p", "--project-id=", "Pivotal Tracker project id") { |p| options[:project_id] = p }
34
+ opts.on("-n", "--full-name=", "Your Pivotal Tracker full name") { |n| options[:full_name] = n }
35
+
36
+ opts.on_tail("-h", "--help", "This usage guide") { put opts.to_s; exit 0 }
37
+ end.parse!(args)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,278 @@
1
+ # Utilities for dealing with +PivotalTracker::Story+s
2
+ module GithubPivotalFlow
3
+ class Story
4
+ attr_accessor :story, :branch_name, :root_branch_name
5
+
6
+ # Print a human readable version of a story. This pretty prints the title,
7
+ # description, and notes for the story.
8
+ #
9
+ # @param [PivotalTracker::Story] story the story to pretty print
10
+ # @return [void]
11
+ def self.pretty_print(story)
12
+ print_label LABEL_TITLE
13
+ print_value story.name
14
+
15
+ description = story.description
16
+ if !description.nil? && !description.empty?
17
+ print_label 'Description'
18
+ print_value description
19
+ end
20
+
21
+ PivotalTracker::Note.all(story).sort_by { |note| note.noted_at }.each_with_index do |note, index|
22
+ print_label "Note #{index + 1}"
23
+ print_value note.text
24
+ end
25
+
26
+ puts
27
+ end
28
+
29
+ # Selects a Pivotal Tracker story by doing the following steps:
30
+ #
31
+ # @param [PivotalTracker::Project] project the project to select stories from
32
+ # @param [String, nil] filter a filter for selecting the story to start. This
33
+ # filter can be either:
34
+ # * a story id: selects the story represented by the id
35
+ # * a story type (feature, bug, chore): offers the user a selection of stories of the given type
36
+ # * +nil+: offers the user a selection of stories of all types
37
+ # @param [Fixnum] limit The number maximum number of stories the user can choose from
38
+ # @return [PivotalTracker::Story] The Pivotal Tracker story selected by the user
39
+ def self.select_story(project, filter = nil, limit = 5)
40
+ if filter =~ /[[:digit:]]/
41
+ story = project.stories.find filter.to_i
42
+ else
43
+ story = find_story project, filter, limit
44
+ end
45
+ self.new(story)
46
+ end
47
+
48
+ # @param [PivotalTracker::Story] story the story to wrap
49
+ def initialize(story, options = {})
50
+ raise "Invalid PivotalTracker::Story" if story.nil?
51
+ @story = story
52
+ @branch_name = options.delete(:branch_name)
53
+ @branch_suffix = @branch_name.split('-').last if @branch_name
54
+ @branch_suffix ||= ''
55
+ end
56
+
57
+ def release?
58
+ story.story_type == 'release'
59
+ end
60
+
61
+ def unestimated?
62
+ estimate == -1
63
+ end
64
+
65
+ def request_estimation!
66
+ self.story.update(
67
+ :estimate => ask('Story is not yet estimated. Please estimate difficulty: ')
68
+ )
69
+ end
70
+
71
+ def mark_started!
72
+ print 'Starting story on Pivotal Tracker... '
73
+ self.story.update(
74
+ :current_state => 'started',
75
+ :owned_by => Git.get_config('user.name', :inherited)
76
+ )
77
+ puts 'OK'
78
+ end
79
+
80
+ def create_branch!(commit_message = nil)
81
+ commit_message ||= "Starting [#{story.story_type} ##{story.id}]: #{story.name}"
82
+ set_branch_suffix
83
+ print "Creating branch for story with branch name #{branch_name} from #{root_branch_name}... "
84
+ Git.checkout(root_branch_name)
85
+ root_origin = Git.get_remote
86
+ Git.pull_remote
87
+ Git.create_branch(branch_name, root_branch_name)
88
+ Git.checkout(branch_name)
89
+ Git.set_config('root-branch', root_branch_name, :branch)
90
+ Git.set_config('root-remote', root_origin, :branch)
91
+ Git.commit(commit_message: commit_message, allow_empty: true)
92
+ Git.publish(branch_name)
93
+ end
94
+
95
+ def merge_to_root!(commit_message = nil, options = {})
96
+ commit_message ||= "Merge #{branch_name} to #{root_branch_name}"
97
+ commit_message << "\n\n[#{options[:no_complete] ? '' : 'Completes '}##{story.id}] "
98
+ print "Merging #{branch_name} to #{root_branch_name}... "
99
+ Git.checkout(root_branch_name)
100
+ Git.pull_remote(root_branch_name)
101
+ Git.merge(branch_name, commit_message: commit_message, no_ff: true)
102
+ self.delete_branch!
103
+ Git.publish(root_branch_name)
104
+ self.cleanup!
105
+ end
106
+
107
+ def merge_release!(commit_message = nil, options = {})
108
+ commit_message ||= "Release #{story.name}"
109
+ commit_message << "\n\n[#{options[:no_complete] ? '' : 'Completes '}##{story.id}] "
110
+ print "Merging #{branch_name} to #{master_branch_name}... "
111
+ Git.checkout(master_branch_name)
112
+ Git.pull_remote(master_branch_name)
113
+ Git.merge(master_branch_name, commit_message: commit_message, no_ff: true)
114
+ Git.tag(story.name)
115
+ print "Merging #{branch_name} to #{root_branch_name}... "
116
+ Git checkout(root_branch_name)
117
+ Git.pull_remote(root_branch_name)
118
+ Git.merge(branch_name, commit_message: commit_message, no_ff: true)
119
+ Git.checkout(master_branch_name)
120
+ self.delete_branch!
121
+ Git.publish(master_branch_name)
122
+ Git.publish(development_branch_name)
123
+ Git.push_tags
124
+ self.cleanup!
125
+ end
126
+
127
+ def delete_branch!
128
+ print "Deleting #{branch_name}... "
129
+ Git.delete_branch(branch_name)
130
+ puts 'OK'
131
+ end
132
+
133
+ def cleanup!
134
+ Git.delete_remote_branch(branch_name)
135
+ end
136
+
137
+ #def create_pull_request!
138
+ # Shell.exec("hub pull-request -m \"#{self.name}\n\n#{self.description}\" -b #{root_branch_name} -h #{branch_name}")
139
+ #end
140
+
141
+ def set_branch_suffix
142
+ @branch_suffix = ask("Enter branch name (#{branch_name_from(branch_prefix, story.id, "<branch-name>")}): ")
143
+ end
144
+
145
+ def branch_name_from(branch_prefix, story_id, branch_name)
146
+ if story_type == 'release'
147
+ # For release branches the format is release/5.0
148
+ "#{Git.get_config('gitflow.prefix.release', :inherited)}/#{branch_name}"
149
+ else
150
+ n = "#{branch_prefix}/#{story_id}"
151
+ n << "-#{branch_name}" unless branch_name.blank?
152
+ n
153
+ end
154
+ end
155
+
156
+ def branch_name
157
+ @branch_name ||= branch_name_from(branch_prefix, story.id, @branch_suffix)
158
+ end
159
+
160
+ def root_branch_name
161
+ case story_type
162
+ when 'chore'
163
+ master_branch_name
164
+ when 'bug'
165
+ self.labels.include?('hotfix') ? master_branch_name : development_branch_name
166
+ else
167
+ development_branch_name
168
+ end
169
+ end
170
+
171
+ def master_branch_name
172
+ Git.get_config('gitflow.branch.master', :inherited)
173
+ end
174
+
175
+ def development_branch_name
176
+ Git.get_config('gitflow.branch.develop', :inherited)
177
+ end
178
+
179
+ def labels
180
+ return [] if story.labels.blank?
181
+ story.labels.split(',').collect(&:strip)
182
+ end
183
+
184
+ def params_for_pull_request
185
+ {
186
+ :base => root_branch_name,
187
+ :head => branch_name,
188
+ :title => name,
189
+ :body => description,
190
+ }
191
+ end
192
+
193
+ def method_missing(m, *args, &block)
194
+ return @story.send(m, *args, &block)
195
+ end
196
+
197
+ def can_merge?
198
+ print "Checking for trivial merge from #{branch_name} to #{root_branch_name}... "
199
+ Git.pull_remote(root_branch_name)
200
+ root_tip = Shell.exec "git rev-parse #{root_branch_name}"
201
+ common_ancestor = Shell.exec "git merge-base #{root_branch_name} #{branch_name}"
202
+
203
+ if root_tip != common_ancestor
204
+ abort 'FAIL'
205
+ end
206
+
207
+ puts 'OK'
208
+ end
209
+
210
+ private
211
+ CANDIDATE_STATES = %w(rejected unstarted unscheduled).freeze
212
+ LABEL_DESCRIPTION = 'Description'.freeze
213
+ LABEL_TITLE = 'Title'.freeze
214
+ LABEL_WIDTH = (LABEL_DESCRIPTION.length + 2).freeze
215
+ CONTENT_WIDTH = (HighLine.new.output_cols - LABEL_WIDTH).freeze
216
+
217
+ def self.print_label(label)
218
+ print "%#{LABEL_WIDTH}s" % ["#{label}: "]
219
+ end
220
+
221
+ def self.print_value(value)
222
+ if value.nil? || value.empty?
223
+ puts ''
224
+ else
225
+ value.scan(/\S.{0,#{CONTENT_WIDTH - 2}}\S(?=\s|$)|\S+/).each_with_index do |line, index|
226
+ if index == 0
227
+ puts line
228
+ else
229
+ puts "%#{LABEL_WIDTH}s%s" % ['', line]
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ def self.find_story(project, type, limit)
236
+ criteria = {
237
+ :current_state => CANDIDATE_STATES,
238
+ :limit => limit
239
+ }
240
+ if type
241
+ criteria[:story_type] = type
242
+ end
243
+
244
+ candidates = project.stories.all criteria
245
+ if candidates.length == 1
246
+ story = candidates[0]
247
+ else
248
+ story = choose do |menu|
249
+ menu.prompt = 'Choose story to start: '
250
+
251
+ candidates.each do |story|
252
+ name = type ? story.name : '%-7s %s' % [story.story_type.upcase, story.name]
253
+ menu.choice(name) { story }
254
+ end
255
+ end
256
+
257
+ puts
258
+ end
259
+
260
+ story
261
+ end
262
+
263
+ def branch_prefix
264
+ case self.story_type
265
+ when 'feature'
266
+ 'feature'
267
+ when 'bug'
268
+ self.labels.include?('hotfix') ? 'hotfix' : 'feature'
269
+ when 'release'
270
+ 'release'
271
+ when 'chore'
272
+ 'chore'
273
+ else
274
+ 'misc'
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,70 @@
1
+ require 'spec_helper'
2
+
3
+ module GithubPivotalFlow
4
+ describe Configuration do
5
+
6
+ before do
7
+ $stdout = StringIO.new
8
+ $stderr = StringIO.new
9
+ @configuration = Configuration.new
10
+ end
11
+
12
+ it 'does not prompt the user for the API token if it is already configured' do
13
+ Git.should_receive(:get_config).with('pivotal.api-token', :inherited).and_return('test_api_token')
14
+
15
+ api_token = @configuration.api_token
16
+
17
+ expect(api_token).to eq('test_api_token')
18
+ end
19
+
20
+ it 'prompts the user for the API token if it is not configured' do
21
+ Git.should_receive(:get_config).with('pivotal.api-token', :inherited).and_return('')
22
+ @configuration.should_receive(:ask).and_return('test_api_token')
23
+ Git.should_receive(:set_config).with('pivotal.api-token', 'test_api_token', :global)
24
+ api_token = @configuration.api_token
25
+ expect(api_token).to eq('test_api_token')
26
+ end
27
+
28
+ it 'does not prompt the user for the project id if it is already configured' do
29
+ Git.should_receive(:get_config).with('pivotal.project-id', :inherited).and_return('test_project_id')
30
+ project_id = @configuration.project_id
31
+ expect(project_id).to eq('test_project_id')
32
+ end
33
+
34
+ it 'prompts the user for the API token if it is not configured' do
35
+ Git.should_receive(:get_config).with('pivotal.project-id', :inherited).and_return('')
36
+ menu = double('menu')
37
+ menu.should_receive(:prompt=)
38
+ PivotalTracker::Project.should_receive(:all).and_return([
39
+ PivotalTracker::Project.new(:id => 'id-2', :name => 'name-2'),
40
+ PivotalTracker::Project.new(:id => 'id-1', :name => 'name-1')])
41
+ menu.should_receive(:choice).with('name-1')
42
+ menu.should_receive(:choice).with('name-2')
43
+ @configuration.should_receive(:choose) { |&arg| arg.call menu }.and_return('test_project_id')
44
+ Git.should_receive(:set_config).with('pivotal.project-id', 'test_project_id', :local)
45
+
46
+ project_id = @configuration.project_id
47
+
48
+ expect(project_id).to eq('test_project_id')
49
+ end
50
+
51
+ it 'persists the story when requested' do
52
+ Git.should_receive(:set_config).with('pivotal-story-id', 12345678, :branch)
53
+
54
+ @configuration.story = Story.new(PivotalTracker::Story.new(:id => 12345678))
55
+ end
56
+
57
+ it 'return a story when requested' do
58
+ project = double('project')
59
+ stories = double('stories')
60
+ story = double('story')
61
+ Git.should_receive(:get_config).with('pivotal-story-id', :branch).and_return('12345678')
62
+ project.should_receive(:stories).and_return(stories)
63
+ stories.should_receive(:find).with(12345678).and_return(story)
64
+
65
+ result = @configuration.story project
66
+
67
+ expect(result).to be_a(Story)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,37 @@
1
+ require 'spec_helper'
2
+
3
+ module GithubPivotalFlow
4
+ describe Finish do
5
+
6
+ before do
7
+ $stdout = StringIO.new
8
+ $stderr = StringIO.new
9
+
10
+ @project = double('project')
11
+ @story = double('story')
12
+ Git.should_receive(:repository_root)
13
+ Configuration.any_instance.should_receive(:api_token)
14
+ Configuration.any_instance.should_receive(:project_id)
15
+ PivotalTracker::Project.should_receive(:find).and_return(@project)
16
+ @finish = Finish.new
17
+ end
18
+
19
+ it 'merges the branch back to its root by default' do
20
+ Configuration.any_instance.should_receive(:story).and_return(@story)
21
+ @story.should_receive(:release?).and_return(false)
22
+ @story.should_receive(:can_merge?).and_return(true)
23
+ @story.should_receive(:merge_to_root!)
24
+
25
+ @finish.run!
26
+ end
27
+
28
+ it 'merges as a release instead if it is a release branch' do
29
+ Configuration.any_instance.should_receive(:story).and_return(@story)
30
+ @story.should_receive(:release?).and_return(true)
31
+ @story.should_receive(:can_merge?).and_return(true)
32
+ @story.should_receive(:merge_release!)
33
+
34
+ @finish.run!
35
+ end
36
+ end
37
+ end