pivotal-integration 1.6.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/LICENSE +202 -0
- data/NOTICE +2 -0
- data/README.md +331 -0
- data/bin/pivotal +81 -0
- data/lib/pivotal-integration/command/assign.rb +50 -0
- data/lib/pivotal-integration/command/base.rb +63 -0
- data/lib/pivotal-integration/command/command.rb +20 -0
- data/lib/pivotal-integration/command/comment.rb +31 -0
- data/lib/pivotal-integration/command/configuration.rb +129 -0
- data/lib/pivotal-integration/command/estimate.rb +53 -0
- data/lib/pivotal-integration/command/finish.rb +50 -0
- data/lib/pivotal-integration/command/info.rb +30 -0
- data/lib/pivotal-integration/command/label.rb +33 -0
- data/lib/pivotal-integration/command/mark.rb +44 -0
- data/lib/pivotal-integration/command/new.rb +52 -0
- data/lib/pivotal-integration/command/open.rb +31 -0
- data/lib/pivotal-integration/command/prepare-commit-msg.sh +26 -0
- data/lib/pivotal-integration/command/release.rb +54 -0
- data/lib/pivotal-integration/command/start.rb +82 -0
- data/lib/pivotal-integration/command/switch.rb +44 -0
- data/lib/pivotal-integration/util/git.rb +280 -0
- data/lib/pivotal-integration/util/label.rb +72 -0
- data/lib/pivotal-integration/util/shell.rb +36 -0
- data/lib/pivotal-integration/util/story.rb +170 -0
- data/lib/pivotal-integration/util/util.rb +20 -0
- data/lib/pivotal-integration/version-update/gradle.rb +64 -0
- data/lib/pivotal-integration/version-update/version_update.rb +20 -0
- data/lib/pivotal_integration.rb +18 -0
- data/spec/git-pivotal-tracker-integration/command/assign_spec.rb +55 -0
- data/spec/git-pivotal-tracker-integration/command/base_spec.rb +38 -0
- data/spec/git-pivotal-tracker-integration/command/configuration_spec.rb +119 -0
- data/spec/git-pivotal-tracker-integration/command/finish_spec.rb +45 -0
- data/spec/git-pivotal-tracker-integration/command/label_spec.rb +44 -0
- data/spec/git-pivotal-tracker-integration/command/mark_spec.rb +49 -0
- data/spec/git-pivotal-tracker-integration/command/release_spec.rb +57 -0
- data/spec/git-pivotal-tracker-integration/command/start_spec.rb +55 -0
- data/spec/git-pivotal-tracker-integration/util/git_spec.rb +235 -0
- data/spec/git-pivotal-tracker-integration/util/label_spec.rb +193 -0
- data/spec/git-pivotal-tracker-integration/util/shell_spec.rb +52 -0
- data/spec/git-pivotal-tracker-integration/util/story_spec.rb +158 -0
- data/spec/git-pivotal-tracker-integration/version-update/gradle_spec.rb +74 -0
- metadata +241 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
# Git Pivotal Tracker Integration
|
2
|
+
# Copyright (c) 2013 the original author or authors.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
require_relative 'base'
|
17
|
+
require 'pivotal-tracker'
|
18
|
+
|
19
|
+
# The class that encapsulates starting a Pivotal Tracker Story
|
20
|
+
class PivotalIntegration::Command::Start < PivotalIntegration::Command::Base
|
21
|
+
desc "Start working on a story"
|
22
|
+
|
23
|
+
# Starts a Pivotal Tracker story by doing the following steps:
|
24
|
+
# * Create a branch
|
25
|
+
# * Add default commit hook
|
26
|
+
# * Start the story on Pivotal Tracker
|
27
|
+
#
|
28
|
+
# @param [String, nil] filter a filter for selecting the story to start. This
|
29
|
+
# filter can be either:
|
30
|
+
# * a story id
|
31
|
+
# * a story type (feature, bug, chore)
|
32
|
+
# * +nil+
|
33
|
+
# @return [void]
|
34
|
+
def run(*arguments)
|
35
|
+
filter = arguments.first
|
36
|
+
use_current_branch = arguments.delete('--use-current')
|
37
|
+
|
38
|
+
if filter == 'new'
|
39
|
+
arguments.shift
|
40
|
+
story = PivotalIntegration::Util::Story.new(@project, *PivotalIntegration::Command::New.collect_type_and_name(arguments))
|
41
|
+
else
|
42
|
+
story = PivotalIntegration::Util::Story.select_story @project, filter
|
43
|
+
end
|
44
|
+
|
45
|
+
if story.estimate.nil? or story.estimate == -1
|
46
|
+
PivotalIntegration::Util::Story.estimate(story, PivotalIntegration::Command::Estimate.collect_estimation(@project))
|
47
|
+
end
|
48
|
+
|
49
|
+
PivotalIntegration::Util::Story.pretty_print story
|
50
|
+
|
51
|
+
development_branch_name = development_branch_name story
|
52
|
+
PivotalIntegration::Util::Git.switch_branch 'master' unless use_current_branch
|
53
|
+
PivotalIntegration::Util::Git.create_branch development_branch_name
|
54
|
+
@configuration.story = story
|
55
|
+
|
56
|
+
PivotalIntegration::Util::Git.add_hook 'prepare-commit-msg', File.join(File.dirname(__FILE__), 'prepare-commit-msg.sh')
|
57
|
+
|
58
|
+
start_on_tracker story
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def development_branch_name(story)
|
64
|
+
branch_name = branch_prefix(story) + ask("Enter branch name (#{branch_prefix(story)}<branch-name>): ")
|
65
|
+
puts
|
66
|
+
branch_name
|
67
|
+
end
|
68
|
+
|
69
|
+
def branch_prefix(story)
|
70
|
+
"#{story.id}-"
|
71
|
+
end
|
72
|
+
|
73
|
+
def start_on_tracker(story)
|
74
|
+
print 'Starting story on Pivotal Tracker... '
|
75
|
+
story.update(
|
76
|
+
:current_state => 'started',
|
77
|
+
:owned_by => PivotalIntegration::Util::Git.get_config('user.name')
|
78
|
+
)
|
79
|
+
puts 'OK'
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# Git Pivotal Tracker Integration
|
2
|
+
# Copyright (c) 2013 the original author or authors.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
require_relative 'base'
|
17
|
+
require 'pivotal-tracker'
|
18
|
+
|
19
|
+
# The class that encapsulates assigning current Pivotal Tracker Story to a user
|
20
|
+
class PivotalIntegration::Command::Switch < PivotalIntegration::Command::Base
|
21
|
+
desc "Switch the current story to another story"
|
22
|
+
|
23
|
+
def run(*arguments)
|
24
|
+
id = arguments.first
|
25
|
+
|
26
|
+
if id == '-'
|
27
|
+
previous_story = PivotalIntegration::Util::Git.get_config('pivotal.previous-story')
|
28
|
+
abort "No previous story ID was set." unless previous_story.present?
|
29
|
+
story = @configuration.project.stories.find(previous_story)
|
30
|
+
else
|
31
|
+
story = @configuration.project.stories.find(id)
|
32
|
+
abort "A valid story ID must be provided." unless story
|
33
|
+
end
|
34
|
+
|
35
|
+
PivotalIntegration::Util::Git.set_config('pivotal.previous-story', @configuration.story.id)
|
36
|
+
|
37
|
+
@configuration.story = story
|
38
|
+
PivotalIntegration::Util::Story.pretty_print(story)
|
39
|
+
|
40
|
+
# TODO: When switching stories, switch to the correct branch as well
|
41
|
+
# TODO: Add post-checkout hook to switch to the correct story when changing branches
|
42
|
+
# http://schacon.github.io/git/githooks.html#_post_checkout
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,280 @@
|
|
1
|
+
# Git Pivotal Tracker Integration
|
2
|
+
# Copyright (c) 2013 the original author or authors.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
require_relative 'shell'
|
17
|
+
require_relative 'util'
|
18
|
+
require 'cgi'
|
19
|
+
require 'launchy'
|
20
|
+
|
21
|
+
# Utilities for dealing with Git
|
22
|
+
class PivotalIntegration::Util::Git
|
23
|
+
KEY_REMOTE = 'remote'.freeze
|
24
|
+
KEY_ROOT_BRANCH = 'root-branch'.freeze
|
25
|
+
KEY_ROOT_REMOTE = 'root-remote'.freeze
|
26
|
+
KEY_FINISH_MODE = 'finish-mode'.freeze
|
27
|
+
RELEASE_BRANCH_NAME = 'pivotal-tracker-release'.freeze
|
28
|
+
|
29
|
+
# Adds a Git hook to the current repository
|
30
|
+
#
|
31
|
+
# @param [String] name the name of the hook to add
|
32
|
+
# @param [String] source the file to use as the source for the created hook
|
33
|
+
# @param [Boolean] overwrite whether to overwrite the hook if it already exists
|
34
|
+
# @return [void]
|
35
|
+
def self.add_hook(name, source, overwrite = false)
|
36
|
+
hooks_directory = File.join repository_root, '.git', 'hooks'
|
37
|
+
hook = File.join hooks_directory, name
|
38
|
+
|
39
|
+
if overwrite || !File.exist?(hook)
|
40
|
+
print "Creating Git hook #{name}... "
|
41
|
+
|
42
|
+
FileUtils.mkdir_p hooks_directory
|
43
|
+
File.open(source, 'r') do |input|
|
44
|
+
File.open(hook, 'w') do |output|
|
45
|
+
output.write(input.read)
|
46
|
+
output.chmod(0755)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
puts 'OK'
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns the name of the currently checked out branch
|
55
|
+
#
|
56
|
+
# @return [String] the name of the currently checked out branch
|
57
|
+
def self.branch_name
|
58
|
+
PivotalIntegration::Util::Shell.exec('git branch').scan(/\* (.*)/)[0][0]
|
59
|
+
end
|
60
|
+
|
61
|
+
# Creates a branch with a given +name+. First pulls the current branch to
|
62
|
+
# ensure that it is up to date and then creates and checks out the new
|
63
|
+
# branch. If specified, sets branch-specific properties that are passed in.
|
64
|
+
#
|
65
|
+
# @param [String] name the name of the branch to create
|
66
|
+
# @param [Boolean] print_messages whether to print messages
|
67
|
+
# @return [void]
|
68
|
+
def self.create_branch(name, print_messages = true)
|
69
|
+
root_branch = branch_name
|
70
|
+
root_remote = get_config KEY_REMOTE, :branch
|
71
|
+
|
72
|
+
if print_messages; print "Pulling #{root_branch}... " end
|
73
|
+
PivotalIntegration::Util::Shell.exec 'git pull --quiet --ff-only'
|
74
|
+
if print_messages; puts 'OK'
|
75
|
+
end
|
76
|
+
|
77
|
+
if print_messages; print "Creating and checking out #{name}... " end
|
78
|
+
PivotalIntegration::Util::Shell.exec "git checkout --quiet -B #{name}"
|
79
|
+
set_config KEY_ROOT_BRANCH, root_branch, :branch
|
80
|
+
set_config KEY_ROOT_REMOTE, root_remote, :branch
|
81
|
+
if print_messages; puts 'OK'
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def self.switch_branch(name)
|
86
|
+
PivotalIntegration::Util::Shell.exec "git checkout --quiet #{name}"
|
87
|
+
end
|
88
|
+
|
89
|
+
# Creates a commit with a given message. The commit includes all change
|
90
|
+
# files.
|
91
|
+
#
|
92
|
+
# @param [String] message The commit message, which will be appended with
|
93
|
+
# +[#<story-id]+
|
94
|
+
# @param [PivotalTracker::Story] story the story associated with the current
|
95
|
+
# commit
|
96
|
+
# @return [void]
|
97
|
+
def self.create_commit(message, story)
|
98
|
+
PivotalIntegration::Util::Shell.exec "git commit --quiet --all --allow-empty --message \"#{message}\n\n[##{story.id}]\""
|
99
|
+
end
|
100
|
+
|
101
|
+
# Creates a tag with the given name. Before creating the tag, commits all
|
102
|
+
# outstanding changes with a commit message that reflects that these changes
|
103
|
+
# are for a release.
|
104
|
+
#
|
105
|
+
# @param [String] name the name of the tag to create
|
106
|
+
# @param [PivotalTracker::Story] story the story associated with the current
|
107
|
+
# tag
|
108
|
+
# @return [void]
|
109
|
+
def self.create_release_tag(name, story)
|
110
|
+
root_branch = branch_name
|
111
|
+
|
112
|
+
print "Creating tag v#{name}... "
|
113
|
+
|
114
|
+
create_branch RELEASE_BRANCH_NAME, false
|
115
|
+
create_commit "#{name} Release", story
|
116
|
+
PivotalIntegration::Util::Shell.exec "git tag v#{name}"
|
117
|
+
PivotalIntegration::Util::Shell.exec "git checkout --quiet #{root_branch}"
|
118
|
+
PivotalIntegration::Util::Shell.exec "git branch --quiet -D #{RELEASE_BRANCH_NAME}"
|
119
|
+
|
120
|
+
puts 'OK'
|
121
|
+
end
|
122
|
+
|
123
|
+
# Returns a Git configuration value. This value is read using the +git
|
124
|
+
# config+ command. The scope of the value to read can be controlled with the
|
125
|
+
# +scope+ parameter.
|
126
|
+
#
|
127
|
+
# @param [String] key the key of the configuration to retrieve
|
128
|
+
# @param [:branch, :inherited] scope the scope to read the configuration from
|
129
|
+
# * +:branch+: equivalent to calling +git config branch.branch-name.key+
|
130
|
+
# * +:inherited+: equivalent to calling +git config key+
|
131
|
+
# @return [String] the value of the configuration
|
132
|
+
# @raise if the specified scope is not +:branch+ or +:inherited+
|
133
|
+
def self.get_config(key, scope = :inherited)
|
134
|
+
if :branch == scope
|
135
|
+
PivotalIntegration::Util::Shell.exec("git config branch.#{branch_name}.#{key}", false).strip
|
136
|
+
elsif :inherited == scope
|
137
|
+
PivotalIntegration::Util::Shell.exec("git config #{key}", false).strip
|
138
|
+
else
|
139
|
+
raise "Unable to get Git configuration for scope '#{scope}'"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def self.get_config_with_default(key, default = nil, scope = :inherited)
|
144
|
+
value = get_config(key, scope)
|
145
|
+
value.blank? ? default : value
|
146
|
+
end
|
147
|
+
|
148
|
+
def self.finish_mode
|
149
|
+
get_config_with_default('pivotal.finish-mode', :merge).to_sym
|
150
|
+
end
|
151
|
+
|
152
|
+
# Merges the current branch to its root branch and deletes the current branch
|
153
|
+
#
|
154
|
+
# @param [PivotalTracker::Story] story the story associated with the current branch
|
155
|
+
# @param [Boolean] no_complete whether to suppress the +Completes+ statement in the commit message
|
156
|
+
# @param [Boolean] no_delete whether to delete development branch
|
157
|
+
# @return [void]
|
158
|
+
def self.merge(story, no_complete, no_delete)
|
159
|
+
development_branch = branch_name
|
160
|
+
root_branch = get_config KEY_ROOT_BRANCH, :branch
|
161
|
+
|
162
|
+
print "Merging #{development_branch} to #{root_branch}... "
|
163
|
+
PivotalIntegration::Util::Shell.exec "git checkout --quiet #{root_branch}"
|
164
|
+
PivotalIntegration::Util::Shell.exec "git merge --quiet --no-ff -m \"Merge #{development_branch} to #{root_branch}\n\n[#{no_complete ? '' : 'Completes '}##{story.id}]\" #{development_branch}"
|
165
|
+
puts 'OK'
|
166
|
+
|
167
|
+
unless no_delete
|
168
|
+
print "Deleting #{development_branch}... "
|
169
|
+
PivotalIntegration::Util::Shell.exec "git branch --quiet -D #{development_branch}"
|
170
|
+
puts 'OK'
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def self.create_pull_request(story)
|
175
|
+
case get_config_with_default('pivotal.pull-request-editor', :web).to_sym
|
176
|
+
when :web
|
177
|
+
# Open the PR editor in a web browser
|
178
|
+
repo = get_config('remote.origin.url')[/(?<=git@github.com:)[a-z0-9_-]+\/[a-z0-9_-]+/]
|
179
|
+
title = CGI::escape("#{story.name} [##{story.id}]")
|
180
|
+
Launchy.open "https://github.com/#{repo}/compare/#{branch_name}?expand=1&pull_request[title]=#{title}"
|
181
|
+
|
182
|
+
else
|
183
|
+
print 'Checking for hub installation... '
|
184
|
+
if PivotalIntegration::Util::Shell.exec('which hub', false).empty?
|
185
|
+
puts "FAIL"
|
186
|
+
puts "Hub required to use this feature (brew install hub / https://github.com/github/hub)."
|
187
|
+
abort
|
188
|
+
else
|
189
|
+
puts "OK"
|
190
|
+
end
|
191
|
+
|
192
|
+
puts "Creating a pull request for #{branch_name}... "
|
193
|
+
system "hub pull-request"
|
194
|
+
puts 'OK'
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Push changes to the remote of the current branch
|
199
|
+
#
|
200
|
+
# @param [String] refs the explicit references to push
|
201
|
+
# @return [void]
|
202
|
+
def self.push(*refs)
|
203
|
+
remote = get_config KEY_REMOTE, :branch
|
204
|
+
|
205
|
+
print "Pushing to #{remote}... "
|
206
|
+
PivotalIntegration::Util::Shell.exec "git push --quiet origin #{remote} " + refs.join(' ')
|
207
|
+
puts 'OK'
|
208
|
+
end
|
209
|
+
|
210
|
+
# Returns the root path of the current Git repository. The root is
|
211
|
+
# determined by ascending the path hierarchy, starting with the current
|
212
|
+
# working directory (+Dir#pwd+), until a directory is found that contains a
|
213
|
+
# +.git/+ sub directory.
|
214
|
+
#
|
215
|
+
# @return [String] the root path of the Git repository
|
216
|
+
# @raise if the current working directory is not in a Git repository
|
217
|
+
def self.repository_root
|
218
|
+
repository_root = Dir.pwd
|
219
|
+
|
220
|
+
until Dir.entries(repository_root).any? { |child| File.directory?(child) && (child =~ /^.git$/) }
|
221
|
+
next_repository_root = File.expand_path('..', repository_root)
|
222
|
+
abort('Current working directory is not in a Git repository') unless repository_root != next_repository_root
|
223
|
+
repository_root = next_repository_root
|
224
|
+
end
|
225
|
+
|
226
|
+
repository_root
|
227
|
+
end
|
228
|
+
|
229
|
+
# Sets a Git configuration value. This value is set using the +git config+
|
230
|
+
# command. The scope of the set value can be controlled with the +scope+
|
231
|
+
# parameter.
|
232
|
+
#
|
233
|
+
# @param [String] key the key of configuration to store
|
234
|
+
# @param [String] value the value of the configuration to store
|
235
|
+
# @param [:branch, :global, :local] scope the scope to store the configuration value in.
|
236
|
+
# * +:branch+: equivalent to calling +git config --local branch.branch-name.key value+
|
237
|
+
# * +:global+: equivalent to calling +git config --global key value+
|
238
|
+
# * +:local+: equivalent to calling +git config --local key value+
|
239
|
+
# @return [void]
|
240
|
+
# @raise if the specified scope is not +:branch+, +:global+, or +:local+
|
241
|
+
def self.set_config(key, value, scope = :local)
|
242
|
+
if :branch == scope
|
243
|
+
PivotalIntegration::Util::Shell.exec "git config --local branch.#{branch_name}.#{key} #{value}"
|
244
|
+
elsif :global == scope
|
245
|
+
PivotalIntegration::Util::Shell.exec "git config --global #{key} #{value}"
|
246
|
+
elsif :local == scope
|
247
|
+
PivotalIntegration::Util::Shell.exec "git config --local #{key} #{value}"
|
248
|
+
else
|
249
|
+
raise "Unable to set Git configuration for scope '#{scope}'"
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
# Checks whether merging the current branch back to its root branch would be
|
254
|
+
# a trivial merge. A trivial merge is defined as one where the net change
|
255
|
+
# of the merge would be the same as the net change of the branch being
|
256
|
+
# merged. The easiest way to ensure that a merge is trivial is to rebase a
|
257
|
+
# development branch onto the tip of its root branch.
|
258
|
+
#
|
259
|
+
# @return [void]
|
260
|
+
def self.trivial_merge?
|
261
|
+
development_branch = branch_name
|
262
|
+
root_branch = get_config KEY_ROOT_BRANCH, :branch
|
263
|
+
|
264
|
+
print "Checking for trivial merge from #{development_branch} to #{root_branch}... "
|
265
|
+
|
266
|
+
PivotalIntegration::Util::Shell.exec "git checkout --quiet #{root_branch}"
|
267
|
+
PivotalIntegration::Util::Shell.exec 'git pull --quiet --ff-only'
|
268
|
+
PivotalIntegration::Util::Shell.exec "git checkout --quiet #{development_branch}"
|
269
|
+
|
270
|
+
root_tip = PivotalIntegration::Util::Shell.exec "git rev-parse #{root_branch}"
|
271
|
+
common_ancestor = PivotalIntegration::Util::Shell.exec "git merge-base #{root_branch} #{development_branch}"
|
272
|
+
|
273
|
+
if root_tip != common_ancestor
|
274
|
+
abort 'FAIL'
|
275
|
+
end
|
276
|
+
|
277
|
+
puts 'OK'
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# Git Pivotal Tracker Integration
|
2
|
+
# Copyright (c) 2013 the original author or authors.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
require_relative 'util'
|
17
|
+
|
18
|
+
# Utilities for dealing with the shell
|
19
|
+
class PivotalIntegration::Util::Label
|
20
|
+
|
21
|
+
# Add labels to story if they are not already appended to story.
|
22
|
+
#
|
23
|
+
# @param [PivotalTracker::Story, String] labels as Strings, one label per parameter.
|
24
|
+
# @return [boolean] Boolean defining whether story was updated or not.
|
25
|
+
def self.add(story, *labels)
|
26
|
+
current_labels = story.labels.split(',') rescue []
|
27
|
+
new_labels = current_labels | labels
|
28
|
+
if story.update(:labels => new_labels)
|
29
|
+
puts "Updated labels on #{story.name}:"
|
30
|
+
puts "#{current_labels} => #{new_labels}"
|
31
|
+
else
|
32
|
+
abort("Failed to update labels on Pivotal Tracker")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Add labels from story and remove those labels from every other story in a project.
|
37
|
+
#
|
38
|
+
# @param [PivotalTracker::Story, String] labels as Strings, one label per parameter.
|
39
|
+
# @return [boolean] Boolean defining whether story was updated or not.
|
40
|
+
def self.once(story, *labels)
|
41
|
+
PivotalTracker::Project.find(story.project_id).stories.all.each do |other_story|
|
42
|
+
self.remove(other_story, *labels) if story.name != other_story.name and
|
43
|
+
other_story.labels and
|
44
|
+
(other_story.labels.split(',') & labels).any?
|
45
|
+
end
|
46
|
+
self.add(story, *labels)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Remove labels from story.
|
50
|
+
#
|
51
|
+
# @param [PivotalTracker::Story, String] labels as Strings, one label per parameter.
|
52
|
+
# @return [boolean] Boolean defining whether story was updated or not.
|
53
|
+
def self.remove(story, *labels)
|
54
|
+
current_labels = story.labels.split(',') rescue []
|
55
|
+
new_labels = current_labels - labels
|
56
|
+
if story.update(:labels => new_labels)
|
57
|
+
puts "Updated labels on #{story.name}:"
|
58
|
+
puts "#{current_labels} => #{new_labels}"
|
59
|
+
else
|
60
|
+
abort("Failed to update labels on Pivotal Tracker")
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Print labels from story.
|
65
|
+
#
|
66
|
+
# @param [PivotalTracker::Story, String] labels as Strings, one label per parameter.
|
67
|
+
# @return [boolean] Boolean defining whether story was updated or not.
|
68
|
+
def self.list(story)
|
69
|
+
puts "Story labels:"
|
70
|
+
puts story.labels.split(',') rescue []
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# Git Pivotal Tracker Integration
|
2
|
+
# Copyright (c) 2013 the original author or authors.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
require_relative 'util'
|
17
|
+
|
18
|
+
# Utilities for dealing with the shell
|
19
|
+
class PivotalIntegration::Util::Shell
|
20
|
+
|
21
|
+
# Executes a command
|
22
|
+
#
|
23
|
+
# @param [String] command the command to execute
|
24
|
+
# @param [Boolean] abort_on_failure whether to +Kernel#abort+ with +FAIL+ as
|
25
|
+
# the message when the command's +Status#existstatus+ is not +0+
|
26
|
+
# @return [String] the result of the command
|
27
|
+
def self.exec(command, abort_on_failure = true)
|
28
|
+
result = `#{command}`
|
29
|
+
if $?.exitstatus != 0 && abort_on_failure
|
30
|
+
abort 'FAIL'
|
31
|
+
end
|
32
|
+
|
33
|
+
result
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# Git Pivotal Tracker Integration
|
2
|
+
# Copyright (c) 2013 the original author or authors.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
|
16
|
+
require_relative 'util'
|
17
|
+
require 'highline/import'
|
18
|
+
require 'pivotal-tracker'
|
19
|
+
|
20
|
+
# Utilities for dealing with +PivotalTracker::Story+s
|
21
|
+
class PivotalIntegration::Util::Story
|
22
|
+
|
23
|
+
def self.new(project, name, type)
|
24
|
+
project.stories.create(name: name, story_type: type)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Print a human readable version of a story. This pretty prints the title,
|
28
|
+
# description, and notes for the story.
|
29
|
+
#
|
30
|
+
# @param [PivotalTracker::Story] story the story to pretty print
|
31
|
+
# @return [void]
|
32
|
+
def self.pretty_print(story)
|
33
|
+
print_label 'ID'
|
34
|
+
print_value story.id
|
35
|
+
|
36
|
+
print_label 'Project'
|
37
|
+
print_value PivotalTracker::Project.find(story.project_id).account
|
38
|
+
|
39
|
+
print_label LABEL_TITLE
|
40
|
+
print_value story.name
|
41
|
+
|
42
|
+
description = story.description
|
43
|
+
if !description.nil? && !description.empty?
|
44
|
+
print_label 'Description'
|
45
|
+
print_value description
|
46
|
+
end
|
47
|
+
|
48
|
+
print_label 'Type'
|
49
|
+
print_value story.story_type.titlecase
|
50
|
+
|
51
|
+
print_label 'State'
|
52
|
+
print_value story.current_state.titlecase
|
53
|
+
|
54
|
+
print_label 'Estimate'
|
55
|
+
print_value story.estimate == -1 ? 'Unestimated' : story.estimate
|
56
|
+
|
57
|
+
PivotalTracker::Note.all(story).sort_by { |note| note.noted_at }.each_with_index do |note, index|
|
58
|
+
print_label "Note #{index + 1}"
|
59
|
+
print_value note.text
|
60
|
+
end
|
61
|
+
|
62
|
+
puts
|
63
|
+
end
|
64
|
+
|
65
|
+
# Assign story to pivotal tracker member.
|
66
|
+
#
|
67
|
+
# @param [PivotalTracker::Story] story to be assigned
|
68
|
+
# @param [PivotalTracker::Member] assigned user
|
69
|
+
# @return [void]
|
70
|
+
def self.assign(story, username)
|
71
|
+
puts "Story assigned to #{username}" if story.update(owned_by: username)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Marks Pivotal Tracker story with given state
|
75
|
+
#
|
76
|
+
# @param [PivotalTracker::Story] story to be assigned
|
77
|
+
# @param [PivotalTracker::Member] assigned user
|
78
|
+
# @return [void]
|
79
|
+
def self.mark(story, state)
|
80
|
+
puts "Changed state to #{state}" if story.update(current_state: state)
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.estimate(story, points)
|
84
|
+
story.update(estimate: points)
|
85
|
+
end
|
86
|
+
|
87
|
+
def self.add_comment(story, comment)
|
88
|
+
story.notes.create(text: comment)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Selects a Pivotal Tracker story by doing the following steps:
|
92
|
+
#
|
93
|
+
# @param [PivotalTracker::Project] project the project to select stories from
|
94
|
+
# @param [String, nil] filter a filter for selecting the story to start. This
|
95
|
+
# filter can be either:
|
96
|
+
# * a story id: selects the story represented by the id
|
97
|
+
# * a story type (feature, bug, chore): offers the user a selection of stories of the given type
|
98
|
+
# * +nil+: offers the user a selection of stories of all types
|
99
|
+
# @param [Fixnum] limit The number maximum number of stories the user can choose from
|
100
|
+
# @return [PivotalTracker::Story] The Pivotal Tracker story selected by the user
|
101
|
+
def self.select_story(project, filter = nil, limit = 5)
|
102
|
+
if filter =~ /[[:digit:]]/
|
103
|
+
story = project.stories.find filter.to_i
|
104
|
+
else
|
105
|
+
story = find_story project, filter, limit
|
106
|
+
end
|
107
|
+
|
108
|
+
story
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
CANDIDATE_STATES = %w(rejected unstarted unscheduled).freeze
|
114
|
+
|
115
|
+
LABEL_DESCRIPTION = 'Description'.freeze
|
116
|
+
|
117
|
+
LABEL_TITLE = 'Title'.freeze
|
118
|
+
|
119
|
+
LABEL_WIDTH = (LABEL_DESCRIPTION.length + 2).freeze
|
120
|
+
|
121
|
+
CONTENT_WIDTH = (HighLine.new.output_cols - LABEL_WIDTH).freeze
|
122
|
+
|
123
|
+
def self.print_label(label)
|
124
|
+
print "%#{LABEL_WIDTH}s" % ["#{label}: "]
|
125
|
+
end
|
126
|
+
|
127
|
+
def self.print_value(value)
|
128
|
+
value = value.to_s
|
129
|
+
|
130
|
+
if value.blank?
|
131
|
+
puts ''
|
132
|
+
else
|
133
|
+
value.scan(/\S.{0,#{CONTENT_WIDTH - 2}}\S(?=\s|$)|\S+/).each_with_index do |line, index|
|
134
|
+
if index == 0
|
135
|
+
puts line
|
136
|
+
else
|
137
|
+
puts "%#{LABEL_WIDTH}s%s" % ['', line]
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def self.find_story(project, type, limit)
|
144
|
+
criteria = {
|
145
|
+
:current_state => CANDIDATE_STATES
|
146
|
+
}
|
147
|
+
if type
|
148
|
+
criteria[:story_type] = type
|
149
|
+
end
|
150
|
+
|
151
|
+
candidates = project.stories.all(criteria).sort_by{ |s| s.owned_by == @user ? 1 : 0 }.slice(0..limit)
|
152
|
+
if candidates.length == 1
|
153
|
+
story = candidates[0]
|
154
|
+
else
|
155
|
+
story = choose do |menu|
|
156
|
+
menu.prompt = 'Choose story to start: '
|
157
|
+
|
158
|
+
candidates.each do |story|
|
159
|
+
name = story.owned_by ? '[%s] ' % story.owned_by : ''
|
160
|
+
name += type ? story.name : '%-7s %s' % [story.story_type.upcase, story.name]
|
161
|
+
menu.choice(name) { story }
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
puts
|
166
|
+
end
|
167
|
+
|
168
|
+
story
|
169
|
+
end
|
170
|
+
end
|