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.
- checksums.yaml +7 -0
- data/LICENSE +14 -0
- data/README.md +183 -0
- data/bin/git-finish +6 -0
- data/bin/git-start +6 -0
- data/lib/core_ext/object/blank.rb +105 -0
- data/lib/github_pivotal_flow.rb +31 -0
- data/lib/github_pivotal_flow/command.rb +57 -0
- data/lib/github_pivotal_flow/configuration.rb +251 -0
- data/lib/github_pivotal_flow/finish.rb +33 -0
- data/lib/github_pivotal_flow/git.rb +150 -0
- data/lib/github_pivotal_flow/github_api.rb +241 -0
- data/lib/github_pivotal_flow/prepare-commit-msg.sh +11 -0
- data/lib/github_pivotal_flow/project.rb +23 -0
- data/lib/github_pivotal_flow/shell.rb +21 -0
- data/lib/github_pivotal_flow/start.rb +40 -0
- data/lib/github_pivotal_flow/story.rb +278 -0
- data/spec/github_pivotal_flow/configuration_spec.rb +70 -0
- data/spec/github_pivotal_flow/finish_spec.rb +37 -0
- data/spec/github_pivotal_flow/git_spec.rb +167 -0
- data/spec/github_pivotal_flow/shell_spec.rb +36 -0
- data/spec/github_pivotal_flow/start_spec.rb +41 -0
- data/spec/github_pivotal_flow/story_spec.rb +125 -0
- metadata +186 -0
@@ -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
|