ghi 0.3.1 → 0.9.0.dev
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/ghi +2 -4
- data/lib/ghi.rb +112 -42
- data/lib/ghi/authorization.rb +71 -0
- data/lib/ghi/client.rb +126 -0
- data/lib/ghi/commands.rb +20 -0
- data/lib/ghi/commands/assign.rb +53 -0
- data/lib/ghi/commands/close.rb +45 -0
- data/lib/ghi/commands/command.rb +114 -0
- data/lib/ghi/commands/comment.rb +104 -0
- data/lib/ghi/commands/config.rb +35 -0
- data/lib/ghi/commands/edit.rb +49 -0
- data/lib/ghi/commands/help.rb +62 -0
- data/lib/ghi/commands/label.rb +153 -0
- data/lib/ghi/commands/list.rb +133 -0
- data/lib/ghi/commands/milestone.rb +150 -0
- data/lib/ghi/commands/open.rb +69 -0
- data/lib/ghi/commands/show.rb +21 -0
- data/lib/ghi/commands/version.rb +16 -0
- data/lib/ghi/formatting.rb +301 -0
- data/lib/ghi/formatting/colors.rb +295 -0
- data/lib/ghi/json.rb +1304 -0
- metadata +71 -49
- data/README.rdoc +0 -126
- data/lib/ghi/api.rb +0 -145
- data/lib/ghi/cli.rb +0 -657
- data/lib/ghi/issue.rb +0 -30
- data/spec/ghi/api_spec.rb +0 -218
- data/spec/ghi/cli_spec.rb +0 -267
- data/spec/ghi/issue_spec.rb +0 -26
- data/spec/ghi_spec.rb +0 -62
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'curses'
|
2
|
+
require 'date'
|
3
|
+
|
4
|
+
module GHI
|
5
|
+
module Commands
|
6
|
+
class List < Command
|
7
|
+
attr_accessor :reverse
|
8
|
+
attr_accessor :quiet
|
9
|
+
|
10
|
+
def options
|
11
|
+
OptionParser.new do |opts|
|
12
|
+
opts.banner = 'usage: ghi list [options]'
|
13
|
+
opts.separator ''
|
14
|
+
opts.on '-a', '--global', '--all', 'all of your issues on GitHub' do
|
15
|
+
@repo = nil
|
16
|
+
end
|
17
|
+
opts.on(
|
18
|
+
'-s', '--state <in>', %w(open closed),
|
19
|
+
{'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'"
|
20
|
+
) do |state|
|
21
|
+
assigns[:state] = state
|
22
|
+
end
|
23
|
+
opts.on(
|
24
|
+
'-L', '--label <labelname>...', Array, 'by label(s)'
|
25
|
+
) do |labels|
|
26
|
+
assigns[:labels] = labels.join ','
|
27
|
+
end
|
28
|
+
opts.on(
|
29
|
+
'-S', '--sort <by>', %w(created updated comments),
|
30
|
+
{'c'=>'created','u'=>'updated','m'=>'comments'},
|
31
|
+
"'created', 'updated', or 'comments'"
|
32
|
+
) do |sort|
|
33
|
+
assigns[:sort] = sort
|
34
|
+
end
|
35
|
+
opts.on '--reverse', 'reverse (ascending) sort order' do
|
36
|
+
self.reverse = !reverse
|
37
|
+
end
|
38
|
+
opts.on(
|
39
|
+
'--since <date>', 'issues more recent than',
|
40
|
+
"e.g., '2011-04-30'"
|
41
|
+
) do |date|
|
42
|
+
begin
|
43
|
+
assigns[:since] = DateTime.parse date # TODO: Better parsing.
|
44
|
+
rescue ArgumentError => e
|
45
|
+
raise OptionParser::InvalidArgument, e.message
|
46
|
+
end
|
47
|
+
end
|
48
|
+
opts.on '-v', '--verbose' do
|
49
|
+
self.verbose = true
|
50
|
+
end
|
51
|
+
opts.separator ''
|
52
|
+
opts.separator 'Global options'
|
53
|
+
opts.on(
|
54
|
+
'-f', '--filter <by>',
|
55
|
+
filters = %w(assigned created mentioned subscribed),
|
56
|
+
Hash[filters.map { |f| [f[0, 1], f] }],
|
57
|
+
"'assigned', 'created', 'mentioned', or", "'subscribed'"
|
58
|
+
) do |filter|
|
59
|
+
assigns[:filter] = filter
|
60
|
+
end
|
61
|
+
opts.separator ''
|
62
|
+
opts.separator 'Project options'
|
63
|
+
opts.on(
|
64
|
+
'-M', '--[no-]milestone [<n>]', Integer,
|
65
|
+
'with (specified) milestone'
|
66
|
+
) do |milestone|
|
67
|
+
assigns[:milestone] = any_or_none_or milestone
|
68
|
+
end
|
69
|
+
opts.on(
|
70
|
+
'-u', '--[no-]assignee [<user>]', 'assigned to specified user'
|
71
|
+
) do |assignee|
|
72
|
+
assigns[:assignee] = any_or_none_or assignee
|
73
|
+
end
|
74
|
+
opts.on '--mine', 'assigned to you' do
|
75
|
+
assigns[:assignee] = Authorization.username
|
76
|
+
end
|
77
|
+
opts.on(
|
78
|
+
'-U', '--mentioned [<user>]', 'mentioning you or specified user'
|
79
|
+
) do |mentioned|
|
80
|
+
assigns[:mentioned] = mentioned || Authorization.username
|
81
|
+
end
|
82
|
+
opts.separator ''
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def execute
|
87
|
+
begin
|
88
|
+
options.parse! args
|
89
|
+
rescue OptionParser::InvalidOption => e
|
90
|
+
fallback.parse! e.args
|
91
|
+
retry
|
92
|
+
end
|
93
|
+
|
94
|
+
if reverse
|
95
|
+
assigns[:sort] ||= 'created'
|
96
|
+
assigns[:direction] = 'asc'
|
97
|
+
end
|
98
|
+
|
99
|
+
unless quiet
|
100
|
+
print format_issues_header
|
101
|
+
print "\n" unless STDOUT.tty?
|
102
|
+
end
|
103
|
+
res = throb(
|
104
|
+
0, format_state(assigns[:state], quiet ? CURSOR[:up][1] : '#')
|
105
|
+
) { api.get uri, assigns }
|
106
|
+
loop do
|
107
|
+
issues = res.body
|
108
|
+
if verbose
|
109
|
+
puts issues.map { |i| format_issue i }
|
110
|
+
else
|
111
|
+
puts format_issues(issues, repo.nil?)
|
112
|
+
end
|
113
|
+
break unless res.next_page
|
114
|
+
page?
|
115
|
+
res = throb { api.get res.next_page }
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def uri
|
122
|
+
repo ? "/repos/#{repo}/issues" : '/issues'
|
123
|
+
end
|
124
|
+
|
125
|
+
def fallback
|
126
|
+
OptionParser.new do |opts|
|
127
|
+
opts.on('-c', '--closed') { assigns[:state] = 'closed' }
|
128
|
+
opts.on('-q', '--quiet') { self.quiet = true }
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module GHI
|
4
|
+
module Commands
|
5
|
+
class Milestone < Command
|
6
|
+
attr_accessor :reverse
|
7
|
+
|
8
|
+
#--
|
9
|
+
# FIXME: Opt for better interface, e.g.,
|
10
|
+
#
|
11
|
+
# ghi milestone [-v | --verbose] [--[no-]closed]
|
12
|
+
# ghi milestone add <name> <description>
|
13
|
+
# ghi milestone rm <milestoneno>
|
14
|
+
#++
|
15
|
+
def options
|
16
|
+
OptionParser.new do |opts|
|
17
|
+
opts.banner = <<EOF
|
18
|
+
usage: ghi milestone [<modification options>] [<milestoneno>]
|
19
|
+
or: ghi milestone -D <milestoneno>
|
20
|
+
or: ghi milestone -l [-c] [-v]
|
21
|
+
EOF
|
22
|
+
opts.separator ''
|
23
|
+
opts.on '-l', '--list', 'list milestones' do
|
24
|
+
self.action = 'index'
|
25
|
+
end
|
26
|
+
opts.on '-c', '--[no-]closed', 'show closed milestones' do |closed|
|
27
|
+
assigns[:state] = closed ? 'closed' : 'open'
|
28
|
+
end
|
29
|
+
opts.on(
|
30
|
+
'-S', '--sort <on>', %w(due_date completeness),
|
31
|
+
{'d'=>'due_date', 'due'=>'due_date', 'c'=>'completeness'},
|
32
|
+
"'due_date' or 'completeness'"
|
33
|
+
) do |sort|
|
34
|
+
assigns[:sort] = sort
|
35
|
+
end
|
36
|
+
opts.on '--reverse', 'reverse (ascending) sort order' do
|
37
|
+
self.reverse = !reverse
|
38
|
+
end
|
39
|
+
opts.on '-v', '--verbose', 'list milestones verbosely' do
|
40
|
+
self.verbose = true
|
41
|
+
end
|
42
|
+
opts.separator ''
|
43
|
+
opts.separator 'Milestone modification options'
|
44
|
+
opts.on(
|
45
|
+
'-m', '--message <text>', 'change milestone description'
|
46
|
+
) do |text|
|
47
|
+
self.action = 'create'
|
48
|
+
assigns[:title], assigns[:description] = text.split(/\n+/, 2)
|
49
|
+
end
|
50
|
+
# FIXME: We already describe --[no-]closed; describe this, too?
|
51
|
+
opts.on(
|
52
|
+
'-s', '--state <in>', %w(open closed),
|
53
|
+
{'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'"
|
54
|
+
) do |state|
|
55
|
+
self.action = 'create'
|
56
|
+
assigns[:state] = state
|
57
|
+
end
|
58
|
+
opts.on(
|
59
|
+
'--due <on>', 'when milestone should be complete',
|
60
|
+
"e.g., '2012-04-30'"
|
61
|
+
) do |date|
|
62
|
+
self.action = 'create'
|
63
|
+
begin
|
64
|
+
# TODO: Better parsing.
|
65
|
+
assigns[:due_on] = DateTime.parse(date).strftime
|
66
|
+
rescue ArgumentError => e
|
67
|
+
raise OptionParser::InvalidArgument, e.message
|
68
|
+
end
|
69
|
+
end
|
70
|
+
opts.on '-D', '--delete', 'delete milestone' do
|
71
|
+
self.action = 'destroy'
|
72
|
+
end
|
73
|
+
opts.separator ''
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def execute
|
78
|
+
self.action = 'index'
|
79
|
+
extract_milestone
|
80
|
+
|
81
|
+
begin
|
82
|
+
options.parse! args
|
83
|
+
rescue OptionParser::AmbiguousOption => e
|
84
|
+
fallback.parse! e.args
|
85
|
+
end
|
86
|
+
|
87
|
+
milestone and case action
|
88
|
+
when 'create' then self.action = 'update'
|
89
|
+
when 'index' then self.action = 'show'
|
90
|
+
end
|
91
|
+
|
92
|
+
if reverse
|
93
|
+
assigns[:sort] ||= 'created'
|
94
|
+
assigns[:direction] = 'asc'
|
95
|
+
end
|
96
|
+
|
97
|
+
case action
|
98
|
+
when 'index'
|
99
|
+
state = assigns[:state] || 'open'
|
100
|
+
print format_state state, "# #{repo} #{state} milestones"
|
101
|
+
print "\n" unless STDOUT.tty?
|
102
|
+
res = throb(0, format_state(state, '#')) { api.get uri }
|
103
|
+
loop do
|
104
|
+
milestones = res.body
|
105
|
+
if verbose
|
106
|
+
puts milestones.map { |m| format_milestone m }
|
107
|
+
else
|
108
|
+
puts format_milestones(milestones)
|
109
|
+
end
|
110
|
+
break unless res.next_page
|
111
|
+
page?
|
112
|
+
res = throb { api.get res.next_page }
|
113
|
+
end
|
114
|
+
when 'show'
|
115
|
+
m = throb { api.get uri }.body
|
116
|
+
print format_milestone(m)
|
117
|
+
puts 'Issues:'
|
118
|
+
List.execute %W(-q -M #{milestone} -- #{repo})
|
119
|
+
when 'create'
|
120
|
+
m = throb { api.post uri, assigns }.body
|
121
|
+
puts 'Milestone #%d created.' % m['number']
|
122
|
+
when 'update'
|
123
|
+
throb { api.patch uri, assigns }
|
124
|
+
puts 'Milestone updated.'
|
125
|
+
when 'destroy'
|
126
|
+
throb { api.delete uri }
|
127
|
+
puts 'Milestone deleted.'
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
def uri
|
134
|
+
if milestone
|
135
|
+
"/repos/#{repo}/milestones/#{milestone}"
|
136
|
+
else
|
137
|
+
"/repos/#{repo}/milestones"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def fallback
|
142
|
+
OptionParser.new do |opts|
|
143
|
+
opts.on '-d' do
|
144
|
+
self.action = 'destroy'
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
module GHI
|
2
|
+
module Commands
|
3
|
+
class Open < Command
|
4
|
+
def options
|
5
|
+
#--
|
6
|
+
# TODO: Support shortcuts, e.g,
|
7
|
+
#
|
8
|
+
# ghi open "Issue Title"
|
9
|
+
#++
|
10
|
+
OptionParser.new do |opts|
|
11
|
+
opts.banner = <<EOF
|
12
|
+
usage: ghi open [options]
|
13
|
+
or: ghi reopen [options] <issueno>
|
14
|
+
EOF
|
15
|
+
opts.separator ''
|
16
|
+
opts.on '-l', '--list', 'list open tickets' do
|
17
|
+
self.action = 'index'
|
18
|
+
end
|
19
|
+
opts.separator ''
|
20
|
+
opts.separator 'Issue modification options'
|
21
|
+
opts.on '-m', '--message <text>', 'describe issue' do |text|
|
22
|
+
assigns[:title], assigns[:body] = text.split(/\n+/, 2)
|
23
|
+
end
|
24
|
+
opts.on(
|
25
|
+
'-u', '--[no-]assign <user>', 'assign to specified user'
|
26
|
+
) do |assignee|
|
27
|
+
assigns[:assignee] = assignee
|
28
|
+
end
|
29
|
+
opts.on(
|
30
|
+
'-M', '--milestone <n>', 'associate with milestone'
|
31
|
+
) do |milestone|
|
32
|
+
assigns[:milestone] = milestone
|
33
|
+
end
|
34
|
+
opts.on(
|
35
|
+
'-L', '--label <labelname>...', Array, 'associate with label(s)'
|
36
|
+
) do |labels|
|
37
|
+
assigns[:labels] = labels
|
38
|
+
end
|
39
|
+
opts.separator ''
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def execute
|
44
|
+
self.action = 'create'
|
45
|
+
|
46
|
+
if extract_issue
|
47
|
+
Edit.new(args.unshift('-so', issue)).execute
|
48
|
+
puts 'Reopened.'
|
49
|
+
exit
|
50
|
+
end
|
51
|
+
|
52
|
+
options.parse! args
|
53
|
+
|
54
|
+
case action
|
55
|
+
when 'index'
|
56
|
+
List.new(args).execute
|
57
|
+
when 'create'
|
58
|
+
if assigns[:title].nil? # FIXME: Open $EDITOR
|
59
|
+
warn "Missing argument: -m"
|
60
|
+
abort options.to_s
|
61
|
+
end
|
62
|
+
i = throb { api.post "/repos/#{repo}/issues", assigns }.body
|
63
|
+
puts format_issue(i)
|
64
|
+
puts 'Opened.'
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module GHI
|
2
|
+
module Commands
|
3
|
+
class Show < Command
|
4
|
+
def options
|
5
|
+
OptionParser.new do |opts|
|
6
|
+
opts.banner = 'usage: ghi show <issueno> [[<user>/]<repo>]'
|
7
|
+
opts.separator ''
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def execute
|
12
|
+
require_issue
|
13
|
+
require_repo
|
14
|
+
i = throb { api.get "/repos/#{repo}/issues/#{issue}" }.body
|
15
|
+
puts format_issue(i)
|
16
|
+
page? 'Load comments?'
|
17
|
+
Comment.execute %W(-l #{issue} -- #{repo})
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,301 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'date'
|
3
|
+
require 'erb'
|
4
|
+
|
5
|
+
module GHI
|
6
|
+
module Formatting
|
7
|
+
autoload :Colors, 'ghi/formatting/colors'
|
8
|
+
include Colors
|
9
|
+
|
10
|
+
CURSOR = {
|
11
|
+
:up => lambda { |n| "\e[#{n}A" },
|
12
|
+
:column => lambda { |n| "\e[#{n}G" },
|
13
|
+
:hide => "\e[?25l",
|
14
|
+
:show => "\e[?25h"
|
15
|
+
}
|
16
|
+
|
17
|
+
THROBBERS = [
|
18
|
+
%w(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏),
|
19
|
+
%w(⠋ ⠙ ⠚ ⠞ ⠖ ⠦ ⠴ ⠲ ⠳ ⠓),
|
20
|
+
%w(⠄ ⠆ ⠇ ⠋ ⠙ ⠸ ⠰ ⠠ ⠰ ⠸ ⠙ ⠋ ⠇ ⠆ ),
|
21
|
+
%w(⠋ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋),
|
22
|
+
%w(⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠴ ⠲ ⠒ ⠂ ⠂ ⠒ ⠚ ⠙ ⠉ ⠁),
|
23
|
+
%w(⠈ ⠉ ⠋ ⠓ ⠒ ⠐ ⠐ ⠒ ⠖ ⠦ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈),
|
24
|
+
%w(⠁ ⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈ ⠈ ⠉)
|
25
|
+
]
|
26
|
+
|
27
|
+
def puts *strings
|
28
|
+
strings = strings.flatten.map { |s|
|
29
|
+
s.gsub(/@([^@\s]+)/) {
|
30
|
+
if $1 == Authorization.username
|
31
|
+
bright { fg(:yellow) { "@#$1" } }
|
32
|
+
else
|
33
|
+
bright { "@#$1" }
|
34
|
+
end
|
35
|
+
}
|
36
|
+
}
|
37
|
+
stdout, $stdout = $stdout, IO.popen('less -ErX', 'w')
|
38
|
+
super strings
|
39
|
+
rescue Errno::EPIPE
|
40
|
+
ensure
|
41
|
+
$stdout.close_write
|
42
|
+
$stdout = stdout
|
43
|
+
print CURSOR[:show]
|
44
|
+
end
|
45
|
+
|
46
|
+
def truncate string, reserved
|
47
|
+
result = string.scan(/.{0,#{columns - reserved}}(?:\s|\Z)/).first.strip
|
48
|
+
result << "..." if result != string
|
49
|
+
result
|
50
|
+
end
|
51
|
+
|
52
|
+
def indent string, level = 4
|
53
|
+
string = string.gsub(/\r/, '')
|
54
|
+
string.gsub!(/[\t ]+$/, '')
|
55
|
+
string.gsub!(/\n{3,}/, "\n\n")
|
56
|
+
width = columns - level - 1
|
57
|
+
lines = string.scan(
|
58
|
+
/.{0,#{width}}(?:\s|\Z)|[\S]{#{width},}/ # TODO: Test long lines.
|
59
|
+
).map { |line| " " * level + line.chomp }
|
60
|
+
format_markdown lines.join("\n").rstrip, level
|
61
|
+
end
|
62
|
+
|
63
|
+
def columns
|
64
|
+
dimensions[1] || 80
|
65
|
+
end
|
66
|
+
|
67
|
+
def dimensions
|
68
|
+
`stty size`.chomp.split(' ').map { |n| n.to_i }
|
69
|
+
end
|
70
|
+
|
71
|
+
#--
|
72
|
+
# Specific formatters:
|
73
|
+
#++
|
74
|
+
|
75
|
+
def format_issues_header
|
76
|
+
state = assigns[:state] || 'open'
|
77
|
+
header = "# #{repo || 'Global,'} #{state} issues"
|
78
|
+
if repo
|
79
|
+
if milestone = assigns[:milestone]
|
80
|
+
header.sub! repo, "#{repo} milestone ##{milestone}"
|
81
|
+
end
|
82
|
+
if assignee = assigns[:assignee]
|
83
|
+
header << case assignee
|
84
|
+
when '*' then ', assigned'
|
85
|
+
when 'none' then ', unassigned'
|
86
|
+
else
|
87
|
+
assignee = 'you' if Authorization.username == assignee
|
88
|
+
", assigned to #{assignee}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
if mentioned = assigns[:mentioned]
|
92
|
+
mentioned = 'you' if Authorization.username == mentioned
|
93
|
+
header << ", mentioning #{mentioned}"
|
94
|
+
end
|
95
|
+
else
|
96
|
+
header << case assigns[:filter]
|
97
|
+
when 'created' then ' you created'
|
98
|
+
when 'mentioned' then ' that mention you'
|
99
|
+
when 'subscribed' then " you're subscribed to"
|
100
|
+
else
|
101
|
+
' assigned to you'
|
102
|
+
end
|
103
|
+
end
|
104
|
+
if labels = assigns[:labels]
|
105
|
+
header << ", labeled #{assigns[:labels].gsub ',', ', '}"
|
106
|
+
end
|
107
|
+
if sort = assigns[:sort]
|
108
|
+
header << ", by #{sort} #{reverse ? 'ascending' : 'descending'}"
|
109
|
+
end
|
110
|
+
format_state assigns[:state], header
|
111
|
+
end
|
112
|
+
|
113
|
+
# TODO: Show milestones.
|
114
|
+
def format_issues issues, include_repo
|
115
|
+
return 'None.' if issues.empty?
|
116
|
+
|
117
|
+
include_repo and issues.each do |i|
|
118
|
+
%r{/repos/[^/]+/([^/]+)} === i['url'] and i['repo'] = $1
|
119
|
+
end
|
120
|
+
|
121
|
+
nmax, rmax = %w(number repo).map { |f|
|
122
|
+
issues.sort_by { |i| i[f].to_s.size }.last[f].to_s.size
|
123
|
+
}
|
124
|
+
|
125
|
+
issues.map { |i|
|
126
|
+
n, title, labels = i['number'], i['title'], i['labels']
|
127
|
+
l = 8 + nmax + rmax + no_color { format_labels labels }.to_s.length
|
128
|
+
a = i['assignee'] && i['assignee']['login'] == Authorization.username
|
129
|
+
l += 2 if a
|
130
|
+
p = i['pull_request']['html_url'] and l += 2
|
131
|
+
c = i['comments'] if i['comments'] > 0 and l += 2
|
132
|
+
[
|
133
|
+
" ",
|
134
|
+
(i['repo'].to_s.rjust(rmax) if i['repo']),
|
135
|
+
"#{bright { n.to_s.rjust nmax }}:",
|
136
|
+
truncate(title, l),
|
137
|
+
format_labels(labels),
|
138
|
+
(fg('aaaaaa') { c } if c),
|
139
|
+
(fg('aaaaaa') { '↑' } if p),
|
140
|
+
(fg(:yellow) { '@' } if a)
|
141
|
+
].compact.join ' '
|
142
|
+
}
|
143
|
+
end
|
144
|
+
|
145
|
+
# TODO: Show milestone, number of comments, pull request attached.
|
146
|
+
def format_issue i
|
147
|
+
ERB.new(<<EOF).result binding
|
148
|
+
<% p = i['pull_request'].key? 'html_url' %>\
|
149
|
+
<%= bright { \
|
150
|
+
indent '%s%s: %s' % [p ? '↑' : '#', *i.values_at('number', 'title')], 0 } %>
|
151
|
+
@<%= i['user']['login'] %> opened this <%= p ? 'pull request' : 'issue' %> \
|
152
|
+
<%= format_date DateTime.parse(i['created_at']) %>. \
|
153
|
+
<%= format_state i['state'], format_tag(i['state']), :bg %>\
|
154
|
+
<% if i['assignee'] || !i['labels'].empty? %>
|
155
|
+
<% if i['assignee'] %>@<%= i['assignee']['login'] %> is assigned. <% end %>\
|
156
|
+
<% unless i['labels'].empty? %><%= format_labels(i['labels']) %><% end %>\
|
157
|
+
<% end %>
|
158
|
+
<% if i['body'] && !i['body'].empty? %>\n<%= indent i['body'] %>
|
159
|
+
<% end %>
|
160
|
+
|
161
|
+
EOF
|
162
|
+
end
|
163
|
+
|
164
|
+
def format_comments comments
|
165
|
+
return 'None.' if comments.empty?
|
166
|
+
comments.map { |comment| format_comment comment }
|
167
|
+
end
|
168
|
+
|
169
|
+
def format_comment c
|
170
|
+
<<EOF
|
171
|
+
@#{c['user']['login']} commented \
|
172
|
+
#{format_date DateTime.parse(c['created_at'])}:
|
173
|
+
#{indent c['body']}
|
174
|
+
|
175
|
+
|
176
|
+
EOF
|
177
|
+
end
|
178
|
+
|
179
|
+
def format_milestones milestones
|
180
|
+
return 'None.' if milestones.empty?
|
181
|
+
|
182
|
+
max = milestones.sort_by { |m|
|
183
|
+
m['number'].to_s.size
|
184
|
+
}.last['number'].to_s.size
|
185
|
+
|
186
|
+
milestones.map { |m|
|
187
|
+
due_on = m['due_on'] && DateTime.parse(m['due_on'])
|
188
|
+
[
|
189
|
+
" #{bright { m['number'].to_s.rjust max }}:",
|
190
|
+
fg((:red if due_on && due_on <= DateTime.now)) {
|
191
|
+
truncate(m['title'], max + 4)
|
192
|
+
}
|
193
|
+
].compact.join ' '
|
194
|
+
}
|
195
|
+
end
|
196
|
+
|
197
|
+
def format_milestone m
|
198
|
+
ERB.new(<<EOF).result binding
|
199
|
+
<%= bright { indent '#%s: %s' % m.values_at('number', 'title'), 0 } %>
|
200
|
+
@<%= m['creator']['login'] %> created this milestone <%= m['created_at'] %>. \
|
201
|
+
<%= format_state m['state'], format_tag(m['state']), :bg %>
|
202
|
+
<% if m['due_on'] %>\
|
203
|
+
<% due_on = DateTime.parse m['due_on'] %>\
|
204
|
+
Due <%= fg((:red if due_on <= DateTime.now)) { format_date due_on } %>.
|
205
|
+
<% end %>\
|
206
|
+
<% if m['description'] && !m['description'].empty? %>
|
207
|
+
<%= indent m['description'] %>\
|
208
|
+
<% end %>
|
209
|
+
EOF
|
210
|
+
end
|
211
|
+
|
212
|
+
def format_state state, string = state, layer = :fg
|
213
|
+
send(layer, state == 'closed' ? :red : :green) { string }
|
214
|
+
end
|
215
|
+
|
216
|
+
def format_labels labels
|
217
|
+
return if labels.empty?
|
218
|
+
[*labels].map { |l| bg(l['color']) { format_tag l['name'] } }.join ' '
|
219
|
+
end
|
220
|
+
|
221
|
+
def format_tag tag
|
222
|
+
(colorize? ? ' %s ' : '[%s]') % tag
|
223
|
+
end
|
224
|
+
|
225
|
+
def format_markdown string, indent = 4
|
226
|
+
# Headers.
|
227
|
+
string.gsub!(/^( {#{indent}}\#{1,6} .+)$/, bright{'\1'})
|
228
|
+
string.gsub!(
|
229
|
+
/(^ {#{indent}}.+$\n^ {#{indent}}[-=]+$)/, bright{'\1'}
|
230
|
+
)
|
231
|
+
# Emphasis.
|
232
|
+
string.gsub!(
|
233
|
+
/(^|\s)(\*\w(?:[^*]*\w)?\*)(\s|$)/m, '\1' + underline{'\2'} + '\3'
|
234
|
+
)
|
235
|
+
string.gsub!(
|
236
|
+
/(^|\s)(_\w(?:[^_]*\w)?_)(\s|$)/m, '\1' + underline{'\2'} + '\3'
|
237
|
+
)
|
238
|
+
# Strong.
|
239
|
+
string.gsub!(
|
240
|
+
/(^|\s)(\*{2}\w(?:[^*]*\w)?\*{2})(\s|$)/m, '\1' + bright{'\2'} + '\3'
|
241
|
+
)
|
242
|
+
string.gsub!(
|
243
|
+
/(^|\s)(_{2}\w(?:[^_]*\w)?_{2})(\s|$)/m, '\1' + bright {'\2'} + '\3'
|
244
|
+
)
|
245
|
+
# Code.
|
246
|
+
string.gsub!(
|
247
|
+
/
|
248
|
+
(^\ {#{indent}}```.*?$)(.+?^\ {#{indent}}```$)|
|
249
|
+
(^|[^`])(`[^`]+`)([^`]|$)
|
250
|
+
/mx,
|
251
|
+
fg(c = '268bd2'){'\1'} + bg(c){'\2'} + '\3' + fg(c){'\4'} + '\5'
|
252
|
+
)
|
253
|
+
# URI.
|
254
|
+
string.gsub!(
|
255
|
+
%r{\b(<)?(https?://[\s]+|\w+@\w+)(>)?\b},
|
256
|
+
'\1' + underline{'\2'} + '\3'
|
257
|
+
)
|
258
|
+
string
|
259
|
+
end
|
260
|
+
|
261
|
+
def format_date date
|
262
|
+
days = (interval = DateTime.now - date).to_i.abs
|
263
|
+
string = if days.zero?
|
264
|
+
hours, minutes, seconds = DateTime.day_fraction_to_time interval.abs
|
265
|
+
if hours > 0
|
266
|
+
"#{hours} hour#{'s' unless hours == 1}"
|
267
|
+
elsif minutes > 0
|
268
|
+
"#{minutes} minute#{'s' unless minutes == 1}"
|
269
|
+
else
|
270
|
+
"#{seconds} second#{'s' unless seconds == 1}"
|
271
|
+
end
|
272
|
+
else
|
273
|
+
"#{days} day#{'s' unless days == 1}"
|
274
|
+
end
|
275
|
+
[string, interval < 0 ? 'from now' : 'ago'].join ' '
|
276
|
+
end
|
277
|
+
|
278
|
+
def throb position = 0, redraw = CURSOR[:up][1]
|
279
|
+
return yield unless STDOUT.tty?
|
280
|
+
|
281
|
+
throb = THROBBERS[rand(THROBBERS.length)]
|
282
|
+
throb.reverse! if rand > 0.5
|
283
|
+
i = rand throb.length
|
284
|
+
|
285
|
+
thread = Thread.new do
|
286
|
+
dot = lambda do
|
287
|
+
print("\r#{CURSOR[:column][position]}#{throb[i]}#{CURSOR[:hide]}")
|
288
|
+
i = (i + 1) % throb.length
|
289
|
+
sleep 0.1 and dot.call
|
290
|
+
end
|
291
|
+
dot.call
|
292
|
+
end
|
293
|
+
yield
|
294
|
+
ensure
|
295
|
+
if thread
|
296
|
+
thread.kill
|
297
|
+
puts "\r#{CURSOR[:column][position]}#{redraw}#{CURSOR[:show]}"
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
301
|
+
end
|