pivotal-integration 1.6.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +15 -0
  2. data/LICENSE +202 -0
  3. data/NOTICE +2 -0
  4. data/README.md +331 -0
  5. data/bin/pivotal +81 -0
  6. data/lib/pivotal-integration/command/assign.rb +50 -0
  7. data/lib/pivotal-integration/command/base.rb +63 -0
  8. data/lib/pivotal-integration/command/command.rb +20 -0
  9. data/lib/pivotal-integration/command/comment.rb +31 -0
  10. data/lib/pivotal-integration/command/configuration.rb +129 -0
  11. data/lib/pivotal-integration/command/estimate.rb +53 -0
  12. data/lib/pivotal-integration/command/finish.rb +50 -0
  13. data/lib/pivotal-integration/command/info.rb +30 -0
  14. data/lib/pivotal-integration/command/label.rb +33 -0
  15. data/lib/pivotal-integration/command/mark.rb +44 -0
  16. data/lib/pivotal-integration/command/new.rb +52 -0
  17. data/lib/pivotal-integration/command/open.rb +31 -0
  18. data/lib/pivotal-integration/command/prepare-commit-msg.sh +26 -0
  19. data/lib/pivotal-integration/command/release.rb +54 -0
  20. data/lib/pivotal-integration/command/start.rb +82 -0
  21. data/lib/pivotal-integration/command/switch.rb +44 -0
  22. data/lib/pivotal-integration/util/git.rb +280 -0
  23. data/lib/pivotal-integration/util/label.rb +72 -0
  24. data/lib/pivotal-integration/util/shell.rb +36 -0
  25. data/lib/pivotal-integration/util/story.rb +170 -0
  26. data/lib/pivotal-integration/util/util.rb +20 -0
  27. data/lib/pivotal-integration/version-update/gradle.rb +64 -0
  28. data/lib/pivotal-integration/version-update/version_update.rb +20 -0
  29. data/lib/pivotal_integration.rb +18 -0
  30. data/spec/git-pivotal-tracker-integration/command/assign_spec.rb +55 -0
  31. data/spec/git-pivotal-tracker-integration/command/base_spec.rb +38 -0
  32. data/spec/git-pivotal-tracker-integration/command/configuration_spec.rb +119 -0
  33. data/spec/git-pivotal-tracker-integration/command/finish_spec.rb +45 -0
  34. data/spec/git-pivotal-tracker-integration/command/label_spec.rb +44 -0
  35. data/spec/git-pivotal-tracker-integration/command/mark_spec.rb +49 -0
  36. data/spec/git-pivotal-tracker-integration/command/release_spec.rb +57 -0
  37. data/spec/git-pivotal-tracker-integration/command/start_spec.rb +55 -0
  38. data/spec/git-pivotal-tracker-integration/util/git_spec.rb +235 -0
  39. data/spec/git-pivotal-tracker-integration/util/label_spec.rb +193 -0
  40. data/spec/git-pivotal-tracker-integration/util/shell_spec.rb +52 -0
  41. data/spec/git-pivotal-tracker-integration/util/story_spec.rb +158 -0
  42. data/spec/git-pivotal-tracker-integration/version-update/gradle_spec.rb +74 -0
  43. 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