ghi 0.2.0 → 1.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,523 @@
1
+ # encoding: utf-8
2
+ require 'date'
3
+ require 'erb'
4
+
5
+ module GHI
6
+ module Formatting
7
+ class << self
8
+ attr_accessor :paginate
9
+ end
10
+ self.paginate = true # Default.
11
+
12
+ attr_accessor :paging
13
+
14
+ autoload :Colors, 'ghi/formatting/colors'
15
+ include Colors
16
+
17
+ CURSOR = {
18
+ :up => lambda { |n| "\e[#{n}A" },
19
+ :column => lambda { |n| "\e[#{n}G" },
20
+ :hide => "\e[?25l",
21
+ :show => "\e[?25h"
22
+ }
23
+
24
+ THROBBERS = [
25
+ %w(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏),
26
+ %w(⠋ ⠙ ⠚ ⠞ ⠖ ⠦ ⠴ ⠲ ⠳ ⠓),
27
+ %w(⠄ ⠆ ⠇ ⠋ ⠙ ⠸ ⠰ ⠠ ⠰ ⠸ ⠙ ⠋ ⠇ ⠆ ),
28
+ %w(⠋ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋),
29
+ %w(⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠴ ⠲ ⠒ ⠂ ⠂ ⠒ ⠚ ⠙ ⠉ ⠁),
30
+ %w(⠈ ⠉ ⠋ ⠓ ⠒ ⠐ ⠐ ⠒ ⠖ ⠦ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈),
31
+ %w(⠁ ⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈ ⠈ ⠉)
32
+ ]
33
+
34
+ def puts *strings
35
+ strings = strings.flatten.map { |s|
36
+ s.gsub(/(^| *)@(\w+)/) {
37
+ if $2 == Authorization.username
38
+ bright { fg(:yellow) { "#$1@#$2" } }
39
+ else
40
+ bright { "#$1@#$2" }
41
+ end
42
+ }
43
+ }
44
+ super strings
45
+ end
46
+
47
+ def page header = nil, throttle = 0
48
+ if paginate?
49
+ pager = GHI.config('ghi.pager') || GHI.config('core.pager')
50
+ pager ||= ENV['PAGER']
51
+ pager ||= 'less'
52
+ pager += ' -EKRX -b1' if pager =~ /^less( -[EKRX]+)?$/
53
+
54
+ if pager && !pager.empty? && pager != 'cat'
55
+ $stdout = IO.popen pager, 'w'
56
+ end
57
+
58
+ puts header if header
59
+ self.paging = true
60
+ end
61
+
62
+ loop do
63
+ yield
64
+ sleep throttle
65
+ end
66
+ rescue Errno::EPIPE
67
+ exit
68
+ ensure
69
+ unless $stdout == STDOUT
70
+ $stdout.close_write
71
+ $stdout = STDOUT
72
+ print CURSOR[:show]
73
+ exit
74
+ end
75
+ end
76
+
77
+ def paginate?
78
+ ($stdout.tty? && $stdout == STDOUT && Formatting.paginate) || paging?
79
+ end
80
+
81
+ def paging?
82
+ !!paging
83
+ end
84
+
85
+ def truncate string, reserved
86
+ return string unless paginate?
87
+ space=columns - reserved
88
+ space=5 if space < 5
89
+ result = string.scan(/.{0,#{space}}(?:\s|\Z)/).first.strip
90
+ result << "..." if result != string
91
+ result
92
+ end
93
+
94
+ def indent string, level = 4, maxwidth = columns
95
+ string = string.gsub(/\r/, '')
96
+ string.gsub!(/[\t ]+$/, '')
97
+ string.gsub!(/\n{3,}/, "\n\n")
98
+ width = maxwidth - level - 1
99
+ lines = string.scan(
100
+ /.{0,#{width}}(?:\s|\Z)|[\S]{#{width},}/ # TODO: Test long lines.
101
+ ).map { |line| " " * level + line.chomp }
102
+ format_markdown lines.join("\n").rstrip, level
103
+ end
104
+
105
+ def columns
106
+ dimensions[1] || 80
107
+ end
108
+
109
+ def dimensions
110
+ `stty size 2>/dev/null`.chomp.split(' ').map { |n| n.to_i }
111
+ end
112
+
113
+ #--
114
+ # Specific formatters:
115
+ #++
116
+
117
+ def format_username username
118
+ username == Authorization.username ? 'you' : username
119
+ end
120
+
121
+ def format_issues_header
122
+ state = assigns[:state] ||= 'open'
123
+ org = assigns[:org] ||= nil
124
+ header = "# #{repo || org || 'Global,'} #{state} issues"
125
+ if repo
126
+ if milestone = assigns[:milestone]
127
+ case milestone
128
+ when '*' then header << ' with a milestone'
129
+ when 'none' then header << ' without a milestone'
130
+ else
131
+ header.sub! repo, "#{repo} milestone ##{milestone}"
132
+ end
133
+ end
134
+ if assignee = assigns[:assignee]
135
+ header << case assignee
136
+ when '*' then ', assigned'
137
+ when 'none' then ', unassigned'
138
+ else
139
+ ", assigned to #{format_username assignee}"
140
+ end
141
+ end
142
+ if mentioned = assigns[:mentioned]
143
+ header << ", mentioning #{format_username mentioned}"
144
+ end
145
+ else
146
+ header << case assigns[:filter]
147
+ when 'created' then ' you created'
148
+ when 'mentioned' then ' that mention you'
149
+ when 'subscribed' then " you're subscribed to"
150
+ when 'all' then ' that you can see'
151
+ else
152
+ ' assigned to you'
153
+ end
154
+ end
155
+ if creator = assigns[:creator]
156
+ header << " #{format_username creator} created"
157
+ end
158
+ if labels = assigns[:labels]
159
+ header << ", labeled #{labels.gsub ',', ', '}"
160
+ end
161
+ if excluded_labels = assigns[:exclude_labels]
162
+ header << ", excluding those labeled #{excluded_labels.gsub ',', ', '}"
163
+ end
164
+ if sort = assigns[:sort]
165
+ header << ", by #{sort} #{reverse ? 'ascending' : 'descending'}"
166
+ end
167
+ format_state assigns[:state], header
168
+ end
169
+
170
+ def format_issues issues, include_repo
171
+ return 'None.' if issues.empty?
172
+
173
+ include_repo and issues.each do |i|
174
+ %r{/repos/[^/]+/([^/]+)} === i['url'] and i['repo'] = $1
175
+ end
176
+
177
+ nmax, rmax = %w(number repo).map { |f|
178
+ issues.sort_by { |i| i[f].to_s.size }.last[f].to_s.size
179
+ }
180
+
181
+ issues.map { |i|
182
+ n, title, labels = i['number'], i['title'], i['labels']
183
+ l = 9 + nmax + rmax + no_color { format_labels labels }.to_s.length
184
+ a = i['assignee']
185
+ a_is_me = a && a['login'] == Authorization.username
186
+ l += a['login'].to_s.length + 2 if a
187
+ p = i['pull_request']['html_url'] and l += 2 if i['pull_request']
188
+ c = i['comments']
189
+ l += c.to_s.length + 1 unless c == 0
190
+ m = i['milestone']
191
+ [
192
+ " ",
193
+ (i['repo'].to_s.rjust(rmax) if i['repo']),
194
+ format_number(n.to_s.rjust(nmax)),
195
+ truncate(title, l),
196
+ (format_labels(labels) unless assigns[:dont_print_labels]),
197
+ (fg(:green) { m['title'] } if m),
198
+ (fg('aaaaaa') { c } unless c == 0),
199
+ (fg('aaaaaa') { '↑' } if p),
200
+ (fg(a_is_me ? :yellow : :gray) { "@#{a['login']}" } if a),
201
+ (fg('aaaaaa') { '‡' } if m)
202
+ ].compact.join ' '
203
+ }
204
+ end
205
+
206
+ def format_number n
207
+ colorize? ? "#{bright { n }}:" : "#{n} "
208
+ end
209
+
210
+ # TODO: Show milestone, number of comments, pull request attached.
211
+ def format_issue i, width = columns
212
+ return unless i['created_at']
213
+ ERB.new(<<EOF).result binding
214
+ <% p = i['pull_request']['html_url'] %>\
215
+ <%= bright { no_color { indent '%s%s: %s' % [p ? '↑' : '#', \
216
+ *i.values_at('number', 'title')], 0, width } } %>
217
+ @<%= i['user']['login'] %> opened this <%= p ? 'pull request' : 'issue' %> \
218
+ <%= format_date DateTime.parse(i['created_at']) %>. \
219
+ <% if i['merged'] %><%= format_state 'merged', format_tag('merged'), :bg %><% end %> \
220
+ <%= format_state i['state'], format_tag(i['state']), :bg %> \
221
+ <% unless i['comments'] == 0 %>\
222
+ <%= fg('aaaaaa'){
223
+ template = "%d comment"
224
+ template << "s" unless i['comments'] == 1
225
+ '(' << template % i['comments'] << ')'
226
+ } %>\
227
+ <% end %>\
228
+ <% if i['assignee'] || !i['labels'].empty? %>
229
+ <% if i['assignee'] %>@<%= i['assignee']['login'] %> is assigned. <% end %>\
230
+ <% unless i['labels'].empty? %><%= format_labels(i['labels']) %><% end %>\
231
+ <% end %>\
232
+ <% if i['milestone'] %>
233
+ Milestone #<%= i['milestone']['number'] %>: <%= i['milestone']['title'] %>\
234
+ <%= " \#{bright{fg(:yellow){'⚠'}}}" if past_due? i['milestone'] %>\
235
+ <% end %>
236
+ <% if i['body'] && !i['body'].empty? %>
237
+ <%= indent i['body'], 4, width %>
238
+ <% end %>
239
+
240
+ EOF
241
+ end
242
+
243
+ def format_comments_and_events elements
244
+ return 'None.' if elements.empty?
245
+ elements.map do |element|
246
+ if event = element['event']
247
+ format_event(element) unless unimportant_event?(event)
248
+ else
249
+ format_comment(element)
250
+ end
251
+ end.compact
252
+ end
253
+
254
+ def format_comment c, width = columns
255
+ <<EOF
256
+ @#{c['user']['login']} commented \
257
+ #{format_date DateTime.parse(c['created_at'])}:
258
+ #{indent c['body'], 4, width}
259
+
260
+
261
+ EOF
262
+ end
263
+
264
+ def format_event e, width = columns
265
+ reference = e['commit_id']
266
+ <<EOF
267
+ #{bright { '⁕' }} #{format_event_type(e['event'])} by @#{e['actor']['login']}\
268
+ #{" through #{underline { reference[0..6] }}" if reference} \
269
+ #{format_date DateTime.parse(e['created_at'])}
270
+
271
+ EOF
272
+ end
273
+
274
+ def format_milestones milestones
275
+ return 'None.' if milestones.empty?
276
+
277
+ max = milestones.sort_by { |m|
278
+ m['number'].to_s.size
279
+ }.last['number'].to_s.size
280
+
281
+ milestones.map { |m|
282
+ line = [" #{m['number'].to_s.rjust max }:"]
283
+ space = past_due?(m) ? 6 : 4
284
+ line << truncate(m['title'], max + space)
285
+ line << '⚠' if past_due? m
286
+ percent m, line.join(' ')
287
+ }
288
+ end
289
+
290
+ def format_milestone m, width = columns
291
+ ERB.new(<<EOF).result binding
292
+ <%= bright { no_color { \
293
+ indent '#%s: %s' % m.values_at('number', 'title'), 0, width } } %>
294
+ @<%= m['creator']['login'] %> created this milestone \
295
+ <%= format_date DateTime.parse(m['created_at']) %>. \
296
+ <%= format_state m['state'], format_tag(m['state']), :bg %>
297
+ <% if m['due_on'] %>\
298
+ <% due_on = DateTime.parse m['due_on'] %>\
299
+ <% if past_due? m %>\
300
+ <%= bright{fg(:yellow){"⚠"}} %> \
301
+ <%= bright{fg(:red){"Past due by \#{format_date due_on, false}."}} %>
302
+ <% else %>\
303
+ Due in <%= format_date due_on, false %>.
304
+ <% end %>\
305
+ <% end %>\
306
+ <%= percent m %>
307
+ <% if m['description'] && !m['description'].empty? %>
308
+ <%= indent m['description'], 4, width %>
309
+ <% end %>
310
+
311
+ EOF
312
+ end
313
+
314
+ def past_due? milestone
315
+ return false unless milestone['due_on']
316
+ DateTime.parse(milestone['due_on']) <= DateTime.now
317
+ end
318
+
319
+ def percent milestone, string = nil
320
+ open, closed = milestone.values_at('open_issues', 'closed_issues')
321
+ complete = closed.to_f / (open + closed)
322
+ complete = 0 if complete.nan?
323
+ i = (columns * complete).round
324
+ if string.nil?
325
+ string = ' %d%% (%d closed, %d open)' % [complete * 100, closed, open]
326
+ end
327
+ string = string.ljust columns
328
+ [bg('2cc200'){string[0, i]}, string[i, columns - i]].join
329
+ end
330
+
331
+ def format_state state, string = state, layer = :fg
332
+ color_codes = {
333
+ 'closed' => 'ff0000',
334
+ 'open' => '2cc200',
335
+ 'merged' => '511c7d',
336
+ }
337
+ send(layer, color_codes[state]) { string }
338
+ end
339
+
340
+ def format_labels labels
341
+ return if labels.empty?
342
+ [*labels].map { |l| bg(l['color']) { format_tag l['name'] } }.join ' '
343
+ end
344
+
345
+ def format_tag tag
346
+ (colorize? ? ' %s ' : '[%s]') % tag
347
+ end
348
+
349
+ def format_event_type(event)
350
+ color_codes = {
351
+ 'reopened' => '2cc200',
352
+ 'closed' => 'ff0000',
353
+ 'merged' => '9677b1',
354
+ 'assigned' => 'e1811d',
355
+ 'referenced' => 'aaaaaa'
356
+ }
357
+ fg(color_codes[event]) { event }
358
+ end
359
+
360
+ #--
361
+ # Helpers:
362
+ #++
363
+
364
+ #--
365
+ # TODO: DRY up editor formatters.
366
+ #++
367
+ def format_editor issue = nil
368
+ message = ERB.new(<<EOF).result binding
369
+
370
+ Please explain the issue. The first line will become the title. Trailing
371
+ lines starting with '#' (like these) will be ignored, and empty messages will
372
+ not be submitted. Issues are formatted with GitHub Flavored Markdown (GFM):
373
+
374
+ http://github.github.com/github-flavored-markdown
375
+
376
+ On <%= repo %>
377
+
378
+ <%= no_color { format_issue issue, columns - 2 if issue } %>
379
+ EOF
380
+ message.rstrip!
381
+ message.gsub!(/(?!\A)^.*$/) { |line|
382
+ "# #{line}".rstrip
383
+ }
384
+ # Adding an extra newline for formatting
385
+ message.insert 0, "\n"
386
+ message.insert 0, [
387
+ issue['title'] || issue[:title], issue['body'] || issue[:body]
388
+ ].compact.join("\n\n") if issue
389
+ message
390
+ end
391
+
392
+ def format_milestone_editor milestone = nil
393
+ message = ERB.new(<<EOF).result binding
394
+
395
+ Describe the milestone. The first line will become the title. Trailing lines
396
+ starting with '#' (like these) will be ignored, and empty messages will not be
397
+ submitted. Milestones are formatted with GitHub Flavored Markdown (GFM):
398
+
399
+ http://github.github.com/github-flavored-markdown
400
+
401
+ On <%= repo %>
402
+
403
+ <%= no_color { format_milestone milestone, columns - 2 } if milestone %>
404
+ EOF
405
+ message.rstrip!
406
+ message.gsub!(/(?!\A)^.*$/) { |line| "# #{line}".rstrip }
407
+ message.insert 0, [
408
+ milestone['title'], milestone['description']
409
+ ].join("\n\n") if milestone
410
+ message
411
+ end
412
+
413
+ def format_comment_editor issue, comment = nil
414
+ message = ERB.new(<<EOF).result binding
415
+
416
+ Leave a comment. Trailing lines starting with '#' (like these) will be ignored,
417
+ and empty messages will not be submitted. Comments are formatted with GitHub
418
+ Flavored Markdown (GFM):
419
+
420
+ http://github.github.com/github-flavored-markdown
421
+
422
+ On <%= repo %> issue #<%= issue['number'] %>
423
+
424
+ <%= no_color { format_issue issue } if verbose %>\
425
+ <%= no_color { format_comment comment, columns - 2 } if comment %>
426
+ EOF
427
+ message.rstrip!
428
+ message.gsub!(/(?!\A)^.*$/) { |line| "# #{line}".rstrip }
429
+ message.insert 0, comment['body'] if comment
430
+ message
431
+ end
432
+
433
+ def format_markdown string, indent = 4
434
+ c = '268bd2'
435
+
436
+ # Headers.
437
+ string.gsub!(/^( {#{indent}}\#{1,6} .+)$/, bright{'\1'})
438
+ string.gsub!(
439
+ /(^ {#{indent}}.+$\n^ {#{indent}}[-=]+$)/, bright{'\1'}
440
+ )
441
+ # Strong.
442
+ string.gsub!(
443
+ /(^|\s)(\*{2}\w(?:[^*]*\w)?\*{2})(\s|$)/m, '\1' + bright{'\2'} + '\3'
444
+ )
445
+ string.gsub!(
446
+ /(^|\s)(_{2}\w(?:[^_]*\w)?_{2})(\s|$)/m, '\1' + bright {'\2'} + '\3'
447
+ )
448
+ # Emphasis.
449
+ string.gsub!(
450
+ /(^|\s)(\*\w(?:[^*]*\w)?\*)(\s|$)/m, '\1' + underline{'\2'} + '\3'
451
+ )
452
+ string.gsub!(
453
+ /(^|\s)(_\w(?:[^_]*\w)?_)(\s|$)/m, '\1' + underline{'\2'} + '\3'
454
+ )
455
+ # Bullets/Blockquotes.
456
+ string.gsub!(/(^ {#{indent}}(?:[*>-]|\d+\.) )/, fg(c){'\1'})
457
+ # URIs.
458
+ string.gsub!(
459
+ %r{\b(<)?(https?://\S+|[^@\s]+@[^@\s]+)(>)?\b},
460
+ fg(c){'\1' + underline{'\2'} + '\3'}
461
+ )
462
+
463
+ # Inline code
464
+ string.gsub!(/`([^`].+?)`(?=[^`])/, inverse { ' \1 ' })
465
+
466
+ # Code blocks
467
+ string.gsub!(/(?<indent>^\ {#{indent}})(```)\s*(?<lang>\w*$)(\n)(?<code>.+?)(\n)(^\ {#{indent}}```$)/m) do |m|
468
+ highlight(Regexp.last_match)
469
+ end
470
+
471
+ string
472
+ end
473
+
474
+ def format_date date, suffix = true
475
+ days = (interval = DateTime.now - date).to_i.abs
476
+ string = if days.zero?
477
+ seconds, _ = interval.divmod Rational(1, 86400)
478
+ hours, seconds = seconds.divmod 3600
479
+ minutes, seconds = seconds.divmod 60
480
+ if hours > 0
481
+ "#{hours} hour#{'s' unless hours == 1}"
482
+ elsif minutes > 0
483
+ "#{minutes} minute#{'s' unless minutes == 1}"
484
+ else
485
+ "#{seconds} second#{'s' unless seconds == 1}"
486
+ end
487
+ else
488
+ "#{days} day#{'s' unless days == 1}"
489
+ end
490
+ ago = interval < 0 ? 'from now' : 'ago' if suffix
491
+ [string, ago].compact.join ' '
492
+ end
493
+
494
+ def throb position = 0, redraw = CURSOR[:up][1]
495
+ return yield unless paginate?
496
+
497
+ throb = THROBBERS[rand(THROBBERS.length)]
498
+ throb.reverse! if rand > 0.5
499
+ i = rand throb.length
500
+
501
+ thread = Thread.new do
502
+ dot = lambda do
503
+ print "\r#{CURSOR[:column][position]}#{throb[i]}#{CURSOR[:hide]}"
504
+ i = (i + 1) % throb.length
505
+ sleep 0.1 and dot.call
506
+ end
507
+ dot.call
508
+ end
509
+ yield
510
+ ensure
511
+ if thread
512
+ thread.kill
513
+ puts "\r#{CURSOR[:column][position]}#{redraw}#{CURSOR[:show]}"
514
+ end
515
+ end
516
+
517
+ private
518
+
519
+ def unimportant_event?(event)
520
+ %w{ subscribed unsubscribed mentioned }.include?(event)
521
+ end
522
+ end
523
+ end
data/lib/ghi/web.rb ADDED
@@ -0,0 +1,37 @@
1
+ require 'open-uri'
2
+ require 'uri'
3
+
4
+ module GHI
5
+ class Web
6
+ HOST = GHI.config('github.host') || 'github.com'
7
+ BASE_URI = "https://#{HOST}/"
8
+
9
+ attr_reader :base
10
+ def initialize base
11
+ @base = base
12
+ end
13
+
14
+ def open path = '', params = {}
15
+ path = uri_for path, params
16
+ $stdout.puts path
17
+ return unless $stdout.tty?
18
+ launcher = 'open'
19
+ launcher = 'xdg-open' if /linux/ =~ RUBY_PLATFORM
20
+ system "#{launcher} '#{path}'"
21
+ end
22
+
23
+ def curl path = '', params = {}
24
+ uri_for(path, params).open.read
25
+ end
26
+
27
+ private
28
+
29
+ def uri_for path, params
30
+ unless params.empty?
31
+ q = params.map { |k, v| "#{CGI.escape k.to_s}=#{CGI.escape v.to_s}" }
32
+ path += "?#{q.join '&'}"
33
+ end
34
+ URI(BASE_URI) + "#{base}/" + path
35
+ end
36
+ end
37
+ end