ghi 0.2.0

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.
@@ -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