right_develop 2.0.2 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.2
1
+ 2.1.0
@@ -20,17 +20,24 @@
20
20
  # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21
21
  # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
22
 
23
+ require 'set'
24
+ require 'uri'
25
+
23
26
  require 'right_git'
27
+ require 'right_support'
28
+
24
29
  require 'right_develop'
25
- require "action_view"
26
30
 
27
31
  module RightDevelop::Commands
28
32
  class Git
33
+ include RightSupport::Log::Mixin
34
+
29
35
  NAME_SPLIT_CHARS = /-|_|\//
30
36
  YES = /(ye?s?)/i
31
37
  NO = /(no?)/i
32
-
33
- TASKS = %w(prune)
38
+ TASKS = %w(prune tickets)
39
+ MERGE_COMMENT = /^Merge (?:remote[- ])?(?:tracking )?(?:branch|pull request #[0-9]+ from) ['"]?(.*)['"]?$/i
40
+ WORD_BOUNDARY = %r{[_ /-]+}
34
41
 
35
42
  # Parse command-line options and create a Command object
36
43
  def self.create
@@ -48,62 +55,85 @@ Where <task> is one of:
48
55
  #{task_list}
49
56
 
50
57
  And [options] are selected from:
51
- EOS
58
+ EOS
52
59
  opt :age, "Minimum age to consider",
53
- :default => "3.months"
54
- opt :only, "Limit to branches matching this prefix",
55
- :type=>:string
60
+ :default => "3.months"
61
+ opt :only, "Limit to branches matching this prefix",
62
+ :type => :string
56
63
  opt :except, "Ignore branches matching this prefix",
57
- :type=>:string,
58
- :default => "^(release|v?[0-9.]+|)"
64
+ :type => :string,
65
+ :default => "(release|ve?r?)?[0-9.]+"
59
66
  opt :local, "Limit to local branches"
60
67
  opt :remote, "Limit to remote branches"
61
- opt :merged, "Limit to branches that are fully merged into the named branch",
62
- :type=>:string,
63
- :default => "master"
64
- stop_on TASKS
68
+ opt :merged, "Limit to branches that are merged into this branch",
69
+ :type => :string,
70
+ :default => "master"
71
+ opt :since, "Base branch or tag to compare against for determining 'new' commits",
72
+ :default => "origin/master"
73
+ opt :link, "Word prefix indicating a link to an external ticketing system",
74
+ :default => "(?:[#A-Za-z]+)([0-9]+)$"
75
+ opt :link_to, "URL pattern to generate ticket links, don't forget trailing slash!",
76
+ :type => :string
77
+ opt :debug, "Enable verbose debug output",
78
+ :default => false
65
79
  end
66
80
 
67
81
  task = ARGV.shift
68
82
 
83
+ repo = ::RightGit::Git::Repository.new(
84
+ ::Dir.pwd,
85
+ ::RightDevelop::Utility::Git::DEFAULT_REPO_OPTIONS)
86
+
69
87
  case task
70
88
  when "prune"
71
- repo = ::RightGit::Git::Repository.new(
72
- ::Dir.pwd,
73
- ::RightDevelop::Utility::Git::DEFAULT_REPO_OPTIONS)
74
89
  self.new(repo, :prune, options)
90
+ when "tickets"
91
+ self.new(repo, :tickets, options)
75
92
  else
76
93
  Trollop.die "unknown task #{task}"
77
94
  end
78
95
  end
79
96
 
97
+ # @param [RightGit::Git::Repository] repo the Git repository to operate on
98
+ # @param [Symbol] task one of :prune or :tickets
80
99
  # @option options [String] :age Ignore branches newer than this time-ago-in-words e.g. "3 months"; default unit is months
81
100
  # @option options [String] :except Ignore branches matching this regular expression
82
101
  # @option options [String] :only Consider only branches matching this regular expression
83
- # @option options [true|false] :local Consider local branches
84
- # @option options [true|false] :remote Consider remote branches
102
+ # @option options [Boolean] :local Consider only local branches
103
+ # @option options [Boolean] :remote Consider only remote branches
85
104
  # @option options [String] :merged Consider only branches that are fully merged into this branch (e.g. master)
105
+ # @option options [String] :since the name of a "base branch" representing the previous release
106
+ # @option options [String] :link word prefix connoting a link to an external ticketing system
107
+ # @option options [String] :link_to URL prefix to use when generating ticket links
86
108
  def initialize(repo, task, options)
109
+ logger = Logger.new(STDERR)
110
+ logger.level = options[:debug] ? Logger::DEBUG : Logger::WARN
111
+ RightSupport::Log::Mixin.default_logger = logger
112
+
87
113
  # Post-process "age" option; transform from natural-language expression into a timestamp.
88
114
  if (age = options.delete(:age))
89
- require 'ruby-debug'
90
- debugger
91
- age = parse_age(age)
115
+ age = parse_age(age)
92
116
  options[:age] = age
93
117
  end
94
118
 
95
119
  # Post-process "except" option; transform into a Regexp.
96
120
  if (except = options.delete(:except))
97
- except = Regexp.new("^(origin/)?(#{except})")
121
+ except = Regexp.new("^(origin/)?(#{except})")
98
122
  options[:except] = except
99
123
  end
100
124
 
101
125
  # Post-process "only" option; transform into a Regexp.
102
126
  if (only = options.delete(:only))
103
- only = Regexp.new("^(origin/)?(#{only})")
127
+ only = Regexp.new("^(origin/)?(#{only})")
104
128
  options[:only] = only
105
129
  end
106
130
 
131
+ # Post-process "since" option; transform into a Regexp.
132
+ if (link = options.delete(:link))
133
+ link = Regexp.new("^#{link}")
134
+ options[:link] = link
135
+ end
136
+
107
137
  @git = repo
108
138
  @task = task
109
139
  @options = options
@@ -115,6 +145,8 @@ EOS
115
145
  case @task
116
146
  when :prune
117
147
  prune(@options)
148
+ when :tickets
149
+ tickets(@options)
118
150
  else
119
151
  raise StateError, "Invalid @task; check Git.create!"
120
152
  end
@@ -127,11 +159,16 @@ EOS
127
159
  # @option options [Time] :age Ignore branches whose HEAD commit is newer than this timestamp
128
160
  # @option options [Regexp] :except Ignore branches matching this pattern
129
161
  # @option options [Regexp] :only Consider only branches matching this pattern
130
- # @option options [true|false] :local Consider local branches
131
- # @option options [true|false] :remote Consider remote branches
162
+ # @option options [Boolean] :local Consider only local branches
163
+ # @option options [Boolean] :remote Consider only remote branches
132
164
  # @option options [String] :merged Consider only branches that are fully merged into this branch (e.g. master)
133
165
  def prune(options={})
134
- branches = @git.branches
166
+ puts describe_prune(options)
167
+
168
+ puts "Fetching latest branches and tags from remotes"
169
+ @git.fetch_all(:prune => true)
170
+
171
+ branches = @git.branches(:all => true)
135
172
 
136
173
  #Filter by name prefix
137
174
  branches = branches.select { |x| x =~ options[:only] } if options[:only]
@@ -148,12 +185,13 @@ EOS
148
185
 
149
186
  #Filter by merge status
150
187
  if options[:merged]
188
+ puts "Checking merge status of #{branches.size} branches; please be patient"
151
189
  branches = branches.merged(options[:merged])
152
190
  end
153
191
 
154
192
  old = {}
155
193
  branches.each do |branch|
156
- latest = @git.log(branch, :tail=>1).first
194
+ latest = @git.log(branch, :tail => 1).first
157
195
  timestamp = latest.timestamp
158
196
  if timestamp < options[:age] &&
159
197
  old[branch] = timestamp
@@ -161,10 +199,12 @@ EOS
161
199
  end
162
200
 
163
201
  if old.empty?
164
- STDERR.puts "No branches older than #{time_ago_in_words(options[:age])} found; do you need to specify --remote?"
202
+ STDERR.puts "No branches found; try different options"
165
203
  exit -2
166
204
  end
167
205
 
206
+ puts
207
+
168
208
  all_by_prefix = branches.group_by { |b| b.name.split(NAME_SPLIT_CHARS).first }
169
209
 
170
210
  all_by_prefix.each_pair do |prefix, branches|
@@ -185,11 +225,97 @@ EOS
185
225
 
186
226
  old.each do |branch, timestamp|
187
227
  branch.delete
228
+ puts " deleted #{branch}"
229
+ end
230
+ end
231
+
232
+ # Produce a report of all the tickets that have been merged into the named branch. This works
233
+ # by scanning merge commit comments, recognizing words that look like a ticket reference, and
234
+ # extracting a matched segment as the ticket ID. The user must specify a matching Regexp using
235
+ # the :link option.
236
+ #
237
+ # @example Match Acunote stories
238
+ # git.tickets(:link=>/acu([0-9]+)/)
239
+ #
240
+ # @option options [String] :since the name of a "base branch" representing the previous release
241
+ # @option options [Regexp] :merged the name of a branch (e.g. master) representing the next release
242
+ # @option options [Regexp] :link a word prefix that connotes links to an external ticketing system
243
+ # @option options [Boolean] :local Consider only local branches
244
+ # @option options [Boolean] :remote Consider only remote branches
245
+ def tickets(options={})
246
+ since = options[:since]
247
+ merged = options[:merged]
248
+
249
+ tickets = Set.new
250
+ link = options[:link]
251
+
252
+ @git.log("#{since}..#{merged}", :merges => true).each do |commit|
253
+ if (match = MERGE_COMMENT.match(commit.comment))
254
+ words = match[1].split(WORD_BOUNDARY)
255
+ else
256
+ words = commit.comment.split(WORD_BOUNDARY)
257
+ end
258
+
259
+ got = words.detect do |w|
260
+ if match = link.match(w)
261
+ if match[1]
262
+ tickets << match[1]
263
+ else
264
+ raise ArgumentError, "Regexp '#{link}' lacks capture groups; please use a () somewhere"
265
+ end
266
+ else
267
+ nil
268
+ end
269
+ end
270
+ unless got
271
+ logger.warn "Couldn't infer a ticket link from '#{commit.comment}'"
272
+ end
273
+ end
274
+
275
+ if (link_to = options[:link_to])
276
+ link_to = link_to + '/' unless link_to =~ %r{/$}
277
+ tickets.each { |t| puts link_to + t }
278
+ else
279
+ tickets.each { |t| puts t }
188
280
  end
189
281
  end
190
282
 
191
283
  private
192
284
 
285
+ # Build a plain-English description of a prune command based on the
286
+ # options given.
287
+ # @param [Hash] options
288
+ def describe_prune(options)
289
+ statement = ['Pruning']
290
+
291
+ if options[:remote]
292
+ statement << 'remote'
293
+ elsif options[:local]
294
+ statement << 'local'
295
+ end
296
+
297
+ statement << 'branches'
298
+
299
+ if options[:age]
300
+ statement << "older than #{time_ago_in_words(options[:age])}"
301
+ end
302
+
303
+ if options[:merged]
304
+ statement << "that are fully merged into #{options[:merged]}"
305
+ end
306
+
307
+ if options[:only]
308
+ naming = "with a name containing '#{options[:only]}'"
309
+ if options[:except]
310
+ naming << " (but not '#{options[:except]}')"
311
+ end
312
+ statement << naming
313
+ end
314
+
315
+ statement.join(' ')
316
+ end
317
+
318
+ # Ask the user a yes-or-no question
193
319
  def prompt(p, yes_no=false)
194
320
  puts #newline for newline's sake!
195
321
 
@@ -217,6 +343,16 @@ EOS
217
343
  [1, 'second'],
218
344
  ]
219
345
 
346
+ # Workalike for ActiveSupport date-helper method. Given a Time in the past, return
347
+ # a natural-language English string that describes the duration separating that time from
348
+ # the present. The duration is very approximate, and will be rounded down to the nearest
349
+ # appropriate interval (e.g. 2.5 hours becomes 2 hours).
350
+ #
351
+ # @example about three days ago
352
+ # time_ago_in_words(Time.now - 86400*3.1) # => "3 days"
353
+ #
354
+ # @param [Time] once_upon_a the long-ago time to compare to Time.now
355
+ # @return [String] an English time duration
220
356
  def time_ago_in_words(once_upon_a)
221
357
  dt = Time.now.to_f - once_upon_a.to_f
222
358
 
@@ -225,7 +361,7 @@ EOS
225
361
  TIME_INTERVALS.each do |pair|
226
362
  mag, term = pair.first, pair.last
227
363
  if dt >= mag
228
- units = dt / mag
364
+ units = Integer(dt / mag)
229
365
  words = "%d %s%s" % [units, term, units > 1 ? 's' : '']
230
366
  break
231
367
  end
@@ -238,9 +374,14 @@ EOS
238
374
  end
239
375
  end
240
376
 
377
+ # Given a natural-language English description of a time duration, return a Time in the past,
378
+ # that is the same duration from Time.now that is expressed in the string.
379
+ #
380
+ # @param [String] str an English time duration
381
+ # @return [Time] a Time object in the past, as described relative to now by str
241
382
  def parse_age(str)
242
383
  ord, word = str.split(/[. ]+/, 2)
243
- ord = Integer(ord)
384
+ ord = Integer(ord)
244
385
  word.gsub!(/s$/, '')
245
386
 
246
387
  ago = nil
@@ -21,7 +21,7 @@
21
21
  # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
22
 
23
23
  # Try to load RSpec 2.x - 1.x
24
- ['rspec', 'spec'].each do |f|
24
+ ['rspec', 'rspec/mocks', 'spec'].each do |f|
25
25
  begin
26
26
  require f
27
27
  rescue LoadError
@@ -27,7 +27,6 @@ require 'right_git'
27
27
  module RightDevelop::Utility::Git
28
28
 
29
29
  DEFAULT_REPO_OPTIONS = {
30
- :logger => ::RightDevelop::Utility::Shell.default_logger,
31
30
  :shell => ::RightDevelop::Utility::Shell
32
31
  }.freeze
33
32
 
@@ -75,9 +75,9 @@ module RightDevelop
75
75
  NullLoggerSingleton.instance
76
76
  end
77
77
 
78
- # @return [Logger] default logger for STDOUT
78
+ # @return [Logger] RightSupport::Log::Mixin.default_logger
79
79
  def default_logger
80
- @default_logger ||= ::Logger.new(STDOUT)
80
+ RightSupport::Log::Mixin.default_logger
81
81
  end
82
82
 
83
83
  # Overrides ::RightGit::Shell::Default#execute
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{right_develop}
8
- s.version = "2.0.2"
8
+ s.version = "2.1.0"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Tony Spataro"]
12
- s.date = %q{2014-01-13}
12
+ s.date = %q{2014-01-16}
13
13
  s.default_executable = %q{right_develop}
14
14
  s.description = %q{A toolkit of development tools created by RightScale.}
15
15
  s.email = %q{support@rightscale.com}
@@ -66,7 +66,7 @@ Gem::Specification.new do |s|
66
66
  s.add_runtime_dependency(%q<rspec>, ["< 3.0", ">= 1.3"])
67
67
  s.add_runtime_dependency(%q<cucumber>, ["< 1.3.3", "~> 1.0"])
68
68
  s.add_runtime_dependency(%q<trollop>, ["< 3.0", ">= 1.0"])
69
- s.add_runtime_dependency(%q<right_git>, [">= 0"])
69
+ s.add_runtime_dependency(%q<right_git>, ["~> 0.1.0"])
70
70
  s.add_runtime_dependency(%q<right_aws>, [">= 2.1.0"])
71
71
  s.add_development_dependency(%q<jeweler>, ["~> 1.8.3"])
72
72
  s.add_development_dependency(%q<rdoc>, [">= 2.4.2"])
@@ -77,7 +77,7 @@ Gem::Specification.new do |s|
77
77
  s.add_dependency(%q<rspec>, ["< 3.0", ">= 1.3"])
78
78
  s.add_dependency(%q<cucumber>, ["< 1.3.3", "~> 1.0"])
79
79
  s.add_dependency(%q<trollop>, ["< 3.0", ">= 1.0"])
80
- s.add_dependency(%q<right_git>, [">= 0"])
80
+ s.add_dependency(%q<right_git>, ["~> 0.1.0"])
81
81
  s.add_dependency(%q<right_aws>, [">= 2.1.0"])
82
82
  s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
83
83
  s.add_dependency(%q<rdoc>, [">= 2.4.2"])
@@ -89,7 +89,7 @@ Gem::Specification.new do |s|
89
89
  s.add_dependency(%q<rspec>, ["< 3.0", ">= 1.3"])
90
90
  s.add_dependency(%q<cucumber>, ["< 1.3.3", "~> 1.0"])
91
91
  s.add_dependency(%q<trollop>, ["< 3.0", ">= 1.0"])
92
- s.add_dependency(%q<right_git>, [">= 0"])
92
+ s.add_dependency(%q<right_git>, ["~> 0.1.0"])
93
93
  s.add_dependency(%q<right_aws>, [">= 2.1.0"])
94
94
  s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
95
95
  s.add_dependency(%q<rdoc>, [">= 2.4.2"])
metadata CHANGED
@@ -5,9 +5,9 @@ version: !ruby/object:Gem::Version
5
5
  prerelease:
6
6
  segments:
7
7
  - 2
8
+ - 1
8
9
  - 0
9
- - 2
10
- version: 2.0.2
10
+ version: 2.1.0
11
11
  platform: ruby
12
12
  authors:
13
13
  - Tony Spataro
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2014-01-13 00:00:00 -08:00
18
+ date: 2014-01-16 00:00:00 -08:00
19
19
  default_executable: right_develop
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
@@ -143,12 +143,14 @@ dependencies:
143
143
  version_requirements: &id007 !ruby/object:Gem::Requirement
144
144
  none: false
145
145
  requirements:
146
- - - ">="
146
+ - - ~>
147
147
  - !ruby/object:Gem::Version
148
- hash: 3
148
+ hash: 27
149
149
  segments:
150
150
  - 0
151
- version: "0"
151
+ - 1
152
+ - 0
153
+ version: 0.1.0
152
154
  prerelease: false
153
155
  requirement: *id007
154
156
  name: right_git