ghi 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,608 @@
1
+ require "optparse"
2
+ require "tempfile"
3
+ require "ghi"
4
+ require "ghi/api"
5
+ require "ghi/issue"
6
+
7
+ begin
8
+ require "launchy"
9
+ rescue LoadError
10
+ # No launchy!
11
+ end
12
+
13
+ module GHI::CLI #:nodoc:
14
+ module FileHelper
15
+ def launch_editor(file)
16
+ system "#{editor} #{file.path}"
17
+ end
18
+
19
+ def gets_from_editor(issue)
20
+ if windows?
21
+ warn "Please supply the message with the -m option"
22
+ exit 1
23
+ end
24
+
25
+ if in_repo?
26
+ File.open message_path, "a+", &file_proc(issue)
27
+ else
28
+ Tempfile.open message_filename, &file_proc(issue)
29
+ end
30
+
31
+ return @message if comment?
32
+ return @message.shift.strip, @message.join.sub(/\b\n\b/, " ").strip
33
+ end
34
+
35
+ def delete_message
36
+ File.delete message_path
37
+ rescue Errno::ENOENT, TypeError
38
+ nil
39
+ end
40
+
41
+ def message_path
42
+ File.join gitdir, message_filename
43
+ end
44
+
45
+ private
46
+
47
+ def editor
48
+ ENV["GHI_EDITOR"] || ENV["VISUAL"] || ENV["EDITOR"] || "vi"
49
+ end
50
+
51
+ def gitdir
52
+ @gitdir ||= `git rev-parse --git-dir 2>/dev/null`.chomp
53
+ end
54
+
55
+ def message_filename
56
+ @message_filename ||= "GHI_#{action.to_s.upcase}#{number}_MESSAGE"
57
+ end
58
+
59
+ def file_proc(issue)
60
+ lambda do |file|
61
+ file << edit_format(issue).join("\n") if File.zero? file.path
62
+ file.rewind
63
+ launch_editor file
64
+ @message = File.readlines(file.path).find_all { |l| !l.match(/^#/) }
65
+
66
+ if message.to_s =~ /\A\s*\Z/
67
+ raise GHI::API::InvalidRequest, "can't file empty message"
68
+ end
69
+ raise GHI::API::InvalidRequest, "no change" if issue == message
70
+ end
71
+ end
72
+
73
+ def in_repo?
74
+ !gitdir.empty? && user == local_user && repo == local_repo
75
+ end
76
+ end
77
+
78
+ module FormattingHelper
79
+ def list_header(term = nil)
80
+ if term
81
+ "# #{state.to_s.capitalize} #{term.inspect} issues on #{user}/#{repo}"
82
+ else
83
+ "# #{state.to_s.capitalize} issues on #{user}/#{repo}"
84
+ end
85
+ end
86
+
87
+ def list_format(issues, verbosity = nil)
88
+ unless issues.empty?
89
+ if verbosity
90
+ issues.map { |i| ["=" * 79] + show_format(i) }
91
+ else
92
+ issues.map { |i| " #{i.number.to_s.rjust 3}: #{truncate i.title, 72}" }
93
+ end
94
+ else
95
+ "none"
96
+ end
97
+ end
98
+
99
+ def edit_format(issue)
100
+ l = []
101
+ l << issue.title if issue.title && !comment?
102
+ l << ""
103
+ l << issue.body if issue.body && !comment?
104
+ if comment?
105
+ l << "# Please enter your comment."
106
+ else
107
+ l << "# Please explain the issue. The first line will become the title."
108
+ end
109
+ l << "# Lines beginning '#' will be ignored; ghi aborts empty messages."
110
+ l << "# All line breaks will be honored in accordance with GFM:"
111
+ l << "#"
112
+ l << "# http://github.github.com/github-flavored-markdown"
113
+ l << "#"
114
+ l << "# On #{user}/#{repo}:"
115
+ l << "#"
116
+ l += show_format(issue, false).map { |line| "# #{line}" }
117
+ end
118
+
119
+ def show_format(issue, verbose = true)
120
+ l = []
121
+ l << " number: #{issue.number}" if issue.number
122
+ l << " state: #{issue.state}" if issue.state
123
+ l << " title: #{indent(issue.title, 15, 0)}" if issue.title
124
+ l << " user: #{issue.user || GHI.login}"
125
+ l << " votes: #{issue.votes}" if issue.votes
126
+ l << " created at: #{issue.created_at}" if issue.created_at
127
+ l << " updated at: #{issue.updated_at}" if issue.updated_at
128
+ return l unless verbose
129
+ l << ""
130
+ l += indent(issue.body)[0..-2]
131
+ end
132
+
133
+ def action_format(value = nil)
134
+ key = "#{action.to_s.capitalize.sub(/e?$/, "ed")} issue #{number}"
135
+ "#{key}: #{truncate value.to_s, 78 - key.length}"
136
+ end
137
+
138
+ def truncate(string, length)
139
+ result = string.scan(/.{0,#{length - 3}}(?:\s|\Z)/).first.strip
140
+ result << "..." if result != string
141
+ result
142
+ end
143
+
144
+ def indent(string, level = 4, first = level)
145
+ lines = string.scan(/.{0,#{79 - level}}(?:\s|\Z)/).map { |line|
146
+ " " * level + line
147
+ }
148
+ lines.first.sub!(/^\s+/) {} if first != level
149
+ lines
150
+ end
151
+
152
+ private
153
+
154
+ def comment?
155
+ ![:open, :edit].include?(action)
156
+ end
157
+
158
+ def puts(*args)
159
+ args = args.flatten.each { |arg|
160
+ arg.gsub!(/\B\*(.+)\*\B/) { "\e[1m#$1\e[0m" } # Bold
161
+ arg.gsub!(/\B_(.+)_\B/) { "\e[4m#$1\e[0m" } # Underline
162
+ arg.gsub!(/(state:)?(# Open.*| open)$/) { "#$1\e[32m#$2\e[0m" }
163
+ arg.gsub!(/(state:)?(# Closed.*| closed)$/) { "#$1\e[31m#$2\e[0m" }
164
+ marked = [GHI.login, search_term, tag, "(?:#|gh)-\d+"].compact * "|"
165
+ unless arg.include? "\e"
166
+ arg.gsub!(/(#{marked})/i) { "\e[1;4;33m#{$&}\e[0m" }
167
+ end
168
+ } if colorize?
169
+ rescue NoMethodError
170
+ # Do nothing.
171
+ ensure
172
+ $stdout.puts(*args)
173
+ end
174
+
175
+ def colorize?
176
+ return @colorize if defined? @colorize
177
+ @colorize = if $stdout.isatty && !windows?
178
+ !`git config --get-regexp color`.chomp.empty?
179
+ else
180
+ false
181
+ end
182
+ end
183
+
184
+ def prepare_stdout
185
+ return if @prepared || @no_pager || !$stdout.isatty || pager.nil?
186
+ colorize? # Check for colorization.
187
+ $stdout = pager
188
+ @prepared = true
189
+ end
190
+
191
+ def pager
192
+ return @pager if defined? @pager
193
+ pagers = [ENV["GHI_PAGER"], "less -EMRX", "pager", "more"].compact.uniq
194
+ pagers.each { |pager| return @pager = IO.popen(pager, "w") rescue nil }
195
+ end
196
+
197
+ def windows?
198
+ RUBY_PLATFORM.include? "mswin"
199
+ end
200
+ end
201
+
202
+ class Executable
203
+ include FileHelper, FormattingHelper
204
+
205
+ attr_reader :message, :local_user, :local_repo, :user, :repo, :api,
206
+ :action, :search_term, :number, :title, :body, :tag, :args, :verbosity
207
+
208
+ def parse!(*argv)
209
+ @args, @argv = argv, argv.dup
210
+
211
+ remotes = `git config --get-regexp remote\..+\.url`.split /\n/
212
+ repo_expression = %r{([^:/]+)/([^/\s]+)(?:\.git)$}
213
+ if remote = remotes.find { |r| r.include? "github.com" }
214
+ remote.match repo_expression
215
+ @user, @repo = $1, $2
216
+ end
217
+
218
+ option_parser.parse!(*args)
219
+
220
+ if action.nil? && fallback_parsing(*args).nil?
221
+ puts option_parser
222
+ exit
223
+ end
224
+ rescue OptionParser::InvalidOption, OptionParser::InvalidArgument => e
225
+ if fallback_parsing(*e.args).nil?
226
+ warn "#{File.basename $0}: #{e.message}"
227
+ puts option_parser
228
+ exit 1
229
+ end
230
+ rescue OptionParser::MissingArgument, OptionParser::AmbiguousOption => e
231
+ warn "#{File.basename $0}: #{e.message}"
232
+ puts option_parser
233
+ exit 1
234
+ ensure
235
+ run!
236
+ $stdout.close_write
237
+ end
238
+
239
+ def run!
240
+ @api = GHI::API.new user, repo
241
+
242
+ case action
243
+ when :search then search
244
+ when :list then list
245
+ when :show then show
246
+ when :open then open
247
+ when :edit then edit
248
+ when :close then close
249
+ when :reopen then reopen
250
+ when :comment then prepare_comment && comment
251
+ when :label, :claim then prepare_label && label
252
+ when :unlabel then prepare_label && unlabel
253
+ when :url then url
254
+ end
255
+ rescue GHI::API::InvalidConnection
256
+ if action
257
+ code = 1
258
+ warn "#{File.basename $0}: not a GitHub repo"
259
+ puts option_parser if args.flatten.empty?
260
+ exit 1
261
+ end
262
+ rescue GHI::API::InvalidRequest => e
263
+ warn "#{File.basename $0}: #{e.message} (#{user}/#{repo})"
264
+ delete_message
265
+ exit 1
266
+ rescue GHI::API::ResponseError => e
267
+ warn "#{File.basename $0}: #{e.message} (#{user}/#{repo})"
268
+ exit 1
269
+ end
270
+
271
+ def commenting?
272
+ @commenting
273
+ end
274
+
275
+ def state
276
+ @state || :open
277
+ end
278
+
279
+ private
280
+
281
+ def option_parser
282
+ @option_parser ||= OptionParser.new { |opts|
283
+ opts.banner = "Usage: #{File.basename $0} [options]"
284
+
285
+ opts.on("-l", "--list", "--search", "--show [state|term|number]") do |v|
286
+ @action = :list
287
+ case v
288
+ when nil, /^o(?:pen)?$/
289
+ # Defaults.
290
+ when /^\d+$/
291
+ @action = :show
292
+ @number = v.to_i
293
+ when /^c(?:losed)?$/
294
+ @state = :closed
295
+ when /^u$/
296
+ @action = :url
297
+ when /^v$/
298
+ @verbosity = true
299
+ else
300
+ @action = :search
301
+ @search_term = v
302
+ end
303
+ end
304
+
305
+ opts.on("-v", "--verbose") do |v|
306
+ if v
307
+ @action ||= :list
308
+ @verbosity = true
309
+ end
310
+ end
311
+
312
+ opts.on("-o", "--open", "--reopen [title|number]") do |v|
313
+ @action = :open
314
+ case v
315
+ when /^\d+$/
316
+ @action = :reopen
317
+ @number = v.to_i
318
+ when /^l$/
319
+ @action = :list
320
+ when /^m$/
321
+ @title = args * " "
322
+ when /^u$/
323
+ @action = :url
324
+ else
325
+ @title = v
326
+ end
327
+ end
328
+
329
+ opts.on("-c", "--closed", "--close [number]") do |v|
330
+ case v
331
+ when /^\d+$/
332
+ @action = :close
333
+ @number = v.to_i unless v.nil?
334
+ when /^l$/
335
+ @action = :list
336
+ @state = :closed
337
+ when /^u$/
338
+ @action = :url
339
+ @state = :closed
340
+ when nil
341
+ if @action.nil? || @number
342
+ @action = :close
343
+ else
344
+ @state = :closed
345
+ end
346
+ else
347
+ raise OptionParser::InvalidArgument
348
+ end
349
+ end
350
+
351
+ opts.on("-e", "--edit [number]") do |v|
352
+ case v
353
+ when /^\d+$/
354
+ @action = :edit
355
+ @number = v.to_i
356
+ when nil
357
+ raise OptionParser::MissingArgument
358
+ else
359
+ raise OptionParser::InvalidArgument
360
+ end
361
+ end
362
+
363
+ opts.on("-r", "--repo", "--repository [name]") do |v|
364
+ case v
365
+ when nil
366
+ raise OptionParser::MissingArgument
367
+ else
368
+ repo = v.split "/"
369
+ repo.unshift GHI.login if repo.length == 1
370
+ @user, @repo = repo
371
+ end
372
+ end
373
+
374
+ opts.on("-m", "--comment [number|comment]") do |v|
375
+ case v
376
+ when /^\d+$/, nil
377
+ @action ||= :comment
378
+ @number ||= v.to_i unless v.nil?
379
+ @commenting = true
380
+ else
381
+ @body = v
382
+ end
383
+ end
384
+
385
+ opts.on("-t", "--label [number] [label]") do |v|
386
+ raise OptionParser::MissingArgument if v.nil?
387
+ @action ||= :label
388
+ @number = v.to_i
389
+ end
390
+
391
+ opts.on("--claim [number]") do |v|
392
+ raise OptionParser::MissingArgument if v.nil?
393
+ @action = :claim
394
+ @number = v.to_i
395
+ @tag = GHI.login
396
+ end
397
+
398
+ opts.on("-d", "--unlabel [number] [label]") do |v|
399
+ @action = :unlabel
400
+ case v
401
+ when /^\d+$/
402
+ @number = v.to_i
403
+ when /^\w+$/
404
+ @tag = v
405
+ end
406
+ end
407
+
408
+ opts.on("-u", "--url [state|number]") do |v|
409
+ @action = :url
410
+ case v
411
+ when /^\d+$/
412
+ @number = v.to_i
413
+ when /^c(?:losed)?$/
414
+ @state = :closed
415
+ when /^u(?:nread)?$/
416
+ @state = :unread
417
+ end
418
+ end
419
+
420
+ opts.on("--[no-]color") do |v|
421
+ @colorize = v
422
+ end
423
+
424
+ opts.on("--[no-]pager") do |v|
425
+ @no_pager = (v == false)
426
+ end
427
+
428
+ opts.on_tail("-V", "--version") do
429
+ puts "#{File.basename($0)}: v#{GHI::VERSION}"
430
+ exit
431
+ end
432
+
433
+ opts.on_tail("-h", "--help") do
434
+ puts opts
435
+ exit
436
+ end
437
+ }
438
+ end
439
+
440
+ def search
441
+ prepare_stdout
442
+ puts list_header(search_term)
443
+ issues = api.search search_term, state
444
+ puts list_format(issues, verbosity)
445
+ end
446
+
447
+ def list
448
+ prepare_stdout
449
+ puts list_header
450
+ issues = api.list(state)
451
+ puts list_format(issues, verbosity)
452
+ end
453
+
454
+ def show
455
+ prepare_stdout
456
+ issue = api.show number
457
+ puts show_format(issue)
458
+ end
459
+
460
+ def open
461
+ if title.nil?
462
+ new_title, new_body = gets_from_editor GHI::Issue.new("title" => body)
463
+ elsif @commenting && body.nil?
464
+ new_title, new_body = gets_from_editor GHI::Issue.new("title" => title)
465
+ end
466
+ new_title ||= title
467
+ new_body ||= body
468
+ issue = api.open new_title, new_body
469
+ delete_message
470
+ @number = issue.number
471
+ puts action_format(issue.title)
472
+ end
473
+
474
+ def edit
475
+ shown = api.show number
476
+ new_title, new_body = gets_from_editor(shown) if body.nil?
477
+ new_title ||= shown.title
478
+ new_body ||= body
479
+ issue = api.edit number, new_title, new_body
480
+ delete_message
481
+ puts action_format(issue.title)
482
+ end
483
+
484
+ def close
485
+ raise GHI::API::InvalidRequest, "need a number" if number.nil?
486
+ issue = api.close number
487
+ if @commenting || new_body = body
488
+ new_body ||= gets_from_editor issue
489
+ comment = api.comment number, new_body
490
+ end
491
+ puts action_format(issue.title)
492
+ puts "(comment #{comment["status"]})" if comment
493
+ end
494
+
495
+ def reopen
496
+ issue = api.reopen number
497
+ if @commenting || new_body = body
498
+ new_body ||= gets_from_editor issue
499
+ comment = api.comment number, new_body
500
+ end
501
+ puts action_format(issue.title)
502
+ puts "(comment #{comment["status"]})" if comment
503
+ end
504
+
505
+ def prepare_label
506
+ @tag ||= (body || args * " ")
507
+ raise GHI::API::InvalidRequest, "need a label" if @tag.empty?
508
+ true
509
+ end
510
+
511
+ def label
512
+ labels = api.add_label tag, number
513
+ puts action_format
514
+ puts indent(labels.join(", "))
515
+ end
516
+
517
+ def unlabel
518
+ labels = api.remove_label tag, number
519
+ puts action_format
520
+ puts indent(labels.empty? ? "no labels" : labels.join(", "))
521
+ end
522
+
523
+ def prepare_comment
524
+ @body = args.flatten.first
525
+ @commenting = false unless body.nil?
526
+ true
527
+ end
528
+
529
+ def comment
530
+ @body ||= gets_from_editor api.show(number)
531
+ comment = api.comment(number, body)
532
+ delete_message
533
+ puts "(comment #{comment["status"]})"
534
+ end
535
+
536
+ def url
537
+ url = "http://github.com/#{user}/#{repo}/issues"
538
+ if number.nil?
539
+ url << "/#{state}" unless state == :open
540
+ else
541
+ url << "#issue/#{number}"
542
+ end
543
+ defined?(Launchy) ? Launchy.open(url) : puts(url)
544
+ end
545
+
546
+ #-
547
+ # Because these are mere fallbacks, any options used earlier will muddle
548
+ # things: `ghi list` will work, `ghi list -c` will not.
549
+ #
550
+ # Argument parsing will have to better integrate with option parsing to
551
+ # overcome this.
552
+ #+
553
+ def fallback_parsing(*arguments)
554
+ arguments = arguments.flatten
555
+ case command = arguments.shift
556
+ when nil, "list"
557
+ @action = :list
558
+ if arg = arguments.shift
559
+ @state ||= arg.to_sym if %w(open closed).include? arg
560
+ @user, @repo = arg.split "/" if arg.count("/") == 1
561
+ end
562
+ when "search"
563
+ @action = :search
564
+ @search_term ||= arguments.shift
565
+ when "show", /^-?(\d+)$/
566
+ @action = :show
567
+ @number ||= ($1 || arguments.shift[/\d+/]).to_i
568
+ when "open"
569
+ @action = :open
570
+ when "edit"
571
+ @action = :edit
572
+ @number ||= arguments.shift[/\d+/].to_i
573
+ when "close"
574
+ @action = :close
575
+ @number ||= arguments.shift[/\d+/].to_i
576
+ when "reopen"
577
+ @action = :reopen
578
+ @number ||= arguments.shift[/\d+/].to_i
579
+ when "label"
580
+ @action = :label
581
+ @number ||= arguments.shift[/\d+/].to_i
582
+ @label ||= arguments.shift
583
+ when "unlabel"
584
+ @action = :unlabel
585
+ @number ||= arguments.shift[/\d+/].to_i
586
+ @label ||= arguments.shift
587
+ when "comment"
588
+ @action = :comment
589
+ @number ||= arguments.shift[/\d+/].to_i
590
+ when "claim"
591
+ @action = :claim
592
+ @number ||= arguments.shift[/\d+/].to_i
593
+ when %r{^([^/]+)/([^/]+)$}
594
+ @action = :list
595
+ @user, @repo = $1, $2
596
+ end
597
+ if @action
598
+ @args = @argv.dup
599
+ args.delete_if { |arg| arg == command }
600
+ option_parser.parse!(*args)
601
+ return true
602
+ end
603
+ unless command.start_with? "-"
604
+ warn "#{File.basename $0}: what do you mean, '#{command}'?"
605
+ end
606
+ end
607
+ end
608
+ end