pivotal-integration 1.6.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.
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