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,45 @@
|
|
1
|
+
module GHI
|
2
|
+
module Commands
|
3
|
+
class Close < Command
|
4
|
+
def options
|
5
|
+
OptionParser.new do |opts|
|
6
|
+
opts.banner = <<EOF
|
7
|
+
usage: ghi close [options] <issueno>
|
8
|
+
EOF
|
9
|
+
opts.separator ''
|
10
|
+
opts.on '-l', '--list', 'list closed issues' do
|
11
|
+
assigns[:command] = List
|
12
|
+
end
|
13
|
+
opts.separator ''
|
14
|
+
opts.separator 'Issue modification options'
|
15
|
+
opts.on '-m', '--message <text>', 'close with message' do |text|
|
16
|
+
assigns[:comment] = text
|
17
|
+
end
|
18
|
+
opts.separator ''
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def execute
|
23
|
+
options.parse! args
|
24
|
+
require_repo
|
25
|
+
|
26
|
+
if list?
|
27
|
+
List.execute %W(-sc -- #{repo})
|
28
|
+
else
|
29
|
+
require_issue
|
30
|
+
Edit.execute %W(-sc #{issue} -- #{repo})
|
31
|
+
puts 'Closed.'
|
32
|
+
if assigns[:comment]
|
33
|
+
Comment.execute %W(#{issue} -m #{assigns[:comment]} -- #{repo})
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def list?
|
41
|
+
assigns[:command] == List
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module GHI
|
2
|
+
module Commands
|
3
|
+
class MissingArgument < RuntimeError
|
4
|
+
end
|
5
|
+
|
6
|
+
class Command
|
7
|
+
include Formatting
|
8
|
+
|
9
|
+
def self.execute args
|
10
|
+
command = new args
|
11
|
+
if i = args.index('--')
|
12
|
+
command.repo = args.slice!(i, args.length)[1] # Raise if too many?
|
13
|
+
end
|
14
|
+
command.execute
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :args
|
18
|
+
attr_writer :issue
|
19
|
+
attr_accessor :action
|
20
|
+
attr_accessor :verbose
|
21
|
+
|
22
|
+
def initialize args
|
23
|
+
@args = args.map! { |a| a.dup }
|
24
|
+
end
|
25
|
+
|
26
|
+
def assigns
|
27
|
+
@assigns ||= {}
|
28
|
+
end
|
29
|
+
|
30
|
+
def api
|
31
|
+
@api ||= Client.new
|
32
|
+
end
|
33
|
+
|
34
|
+
def repo
|
35
|
+
return @repo if defined? @repo
|
36
|
+
@repo = ENV['GHI_REPO'] || `git config --local ghi.repo`.chomp
|
37
|
+
@repo = detect_repo if @repo.empty?
|
38
|
+
@repo
|
39
|
+
end
|
40
|
+
alias extract_repo repo
|
41
|
+
|
42
|
+
def repo= repo
|
43
|
+
@repo = repo.dup
|
44
|
+
unless @repo.include? '/'
|
45
|
+
@repo.insert 0, "#{Authorization.username}/"
|
46
|
+
end
|
47
|
+
@repo
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def require_repo
|
53
|
+
return true if repo
|
54
|
+
warn 'Not a GitHub repo.'
|
55
|
+
warn ''
|
56
|
+
abort options.to_s
|
57
|
+
end
|
58
|
+
|
59
|
+
def detect_repo
|
60
|
+
remotes = `git config --get-regexp remote\..+\.url`.split "\n"
|
61
|
+
remotes.reject! { |r| !r.include? 'github.com'}
|
62
|
+
|
63
|
+
remotes.map! { |r|
|
64
|
+
remote, user, repo = r.scan(
|
65
|
+
%r{remote\.([^\.]+)\.url .*?([^:/]+)/([^/\s]+?)(?:\.git)?$}
|
66
|
+
).flatten
|
67
|
+
{ :remote => remote, :user => user, :repo => "#{user}/#{repo}" }
|
68
|
+
}
|
69
|
+
|
70
|
+
remote = remotes.find { |r| r[:remote] == 'upstream' }
|
71
|
+
remote ||= remotes.find { |r| r[:remote] == 'origin' }
|
72
|
+
remote ||= remotes.find { |r| r[:user] == Authorization.username }
|
73
|
+
remote[:repo] if remote
|
74
|
+
end
|
75
|
+
|
76
|
+
def issue
|
77
|
+
return @issue if defined? @issue
|
78
|
+
index = args.index { |arg| /^\d+$/ === arg }
|
79
|
+
@issue = (args.delete_at index if index)
|
80
|
+
end
|
81
|
+
alias extract_issue issue
|
82
|
+
alias milestone issue
|
83
|
+
alias extract_milestone issue
|
84
|
+
|
85
|
+
def require_issue
|
86
|
+
raise MissingArgument, 'Issue required.' unless issue
|
87
|
+
end
|
88
|
+
|
89
|
+
def require_milestone
|
90
|
+
raise MissingArgument, 'Milestone required.' unless milestone
|
91
|
+
end
|
92
|
+
|
93
|
+
# Handles, e.g. `--[no-]milestone [<n>]`.
|
94
|
+
def any_or_none_or input
|
95
|
+
input ? input : { nil => '*', false => 'none' }[input]
|
96
|
+
end
|
97
|
+
|
98
|
+
def page? message = 'Load more?'
|
99
|
+
return unless STDIN.tty?
|
100
|
+
|
101
|
+
STDOUT.print "#{message} [Yn] "
|
102
|
+
begin
|
103
|
+
system 'stty raw -echo'
|
104
|
+
# Continue on y, j, <ENTER>, <DOWN>...
|
105
|
+
exit unless [?y, ?Y, ?j, 13, 27].include? STDIN.getc
|
106
|
+
STDOUT.print "\r" + ' ' * columns
|
107
|
+
STDOUT.print "\r Loading..."
|
108
|
+
ensure
|
109
|
+
system 'stty -raw echo'
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module GHI
|
2
|
+
module Commands
|
3
|
+
class Comment < Command
|
4
|
+
attr_accessor :comment
|
5
|
+
|
6
|
+
def options
|
7
|
+
OptionParser.new do |opts|
|
8
|
+
opts.banner = <<EOF
|
9
|
+
usage: ghi comment [options] <issueno>
|
10
|
+
EOF
|
11
|
+
opts.separator ''
|
12
|
+
opts.on '-l', '--list', 'list comments' do
|
13
|
+
self.action = 'list'
|
14
|
+
end
|
15
|
+
# opts.on '-v', '--verbose', 'list events, too'
|
16
|
+
opts.separator ''
|
17
|
+
opts.separator 'Comment modification options'
|
18
|
+
opts.on '-m', '--message <text>', 'comment body' do |text|
|
19
|
+
assigns[:body] = text
|
20
|
+
end
|
21
|
+
opts.on '--amend', 'amend previous comment' do
|
22
|
+
self.action = 'update'
|
23
|
+
end
|
24
|
+
opts.on '-D', '--delete', 'delete previous comment' do
|
25
|
+
self.action = 'destroy'
|
26
|
+
end
|
27
|
+
opts.on '--close', 'close associated issue' do
|
28
|
+
self.action = 'close'
|
29
|
+
end
|
30
|
+
opts.separator ''
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def execute
|
35
|
+
require_issue
|
36
|
+
require_repo
|
37
|
+
self.action ||= 'create'
|
38
|
+
options.parse! args
|
39
|
+
|
40
|
+
case action
|
41
|
+
when 'list'
|
42
|
+
res = index
|
43
|
+
loop do
|
44
|
+
puts format_comments(res.body)
|
45
|
+
break unless res.next_page
|
46
|
+
page?
|
47
|
+
res = throb { api.get res.next_page }
|
48
|
+
end
|
49
|
+
when 'create'
|
50
|
+
create
|
51
|
+
when 'update', 'destroy'
|
52
|
+
res = index
|
53
|
+
res = throb { api.get res.last_page } if res.last_page
|
54
|
+
self.comment = res.body.reverse.find { |c|
|
55
|
+
c['user']['login'] == Authorization.username
|
56
|
+
}
|
57
|
+
if comment
|
58
|
+
send action
|
59
|
+
else
|
60
|
+
abort 'No recent comment found.'
|
61
|
+
end
|
62
|
+
when 'close'
|
63
|
+
Close.execute %W(-m #{assigns[:body]} #{issue} -- #{repo})
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
protected
|
68
|
+
|
69
|
+
def index
|
70
|
+
throb { api.get uri }
|
71
|
+
end
|
72
|
+
|
73
|
+
def create
|
74
|
+
require_body
|
75
|
+
throb { api.post uri, assigns }
|
76
|
+
puts 'Comment created.'
|
77
|
+
end
|
78
|
+
|
79
|
+
def update
|
80
|
+
require_body
|
81
|
+
throb { api.patch uri, assigns }
|
82
|
+
puts 'Comment updated.'
|
83
|
+
end
|
84
|
+
|
85
|
+
def destroy
|
86
|
+
throb { api.delete uri }
|
87
|
+
puts 'Comment deleted.'
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def uri
|
93
|
+
comment ? comment['url'] : "/repos/#{repo}/issues/#{issue}/comments"
|
94
|
+
end
|
95
|
+
|
96
|
+
def require_body
|
97
|
+
if assigns[:body].nil?
|
98
|
+
warn 'Missing argument: -m'
|
99
|
+
abort options.to_s
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module GHI
|
2
|
+
module Commands
|
3
|
+
class Config < Command
|
4
|
+
def options
|
5
|
+
OptionParser.new do |opts|
|
6
|
+
opts.banner = <<EOF
|
7
|
+
usage: ghi config [options]
|
8
|
+
EOF
|
9
|
+
opts.separator ''
|
10
|
+
opts.on '--local', 'set for local repo only' do
|
11
|
+
assigns[:local] = true
|
12
|
+
end
|
13
|
+
opts.on '--auth [<username>:<password>]' do |credentials|
|
14
|
+
self.action = 'auth'
|
15
|
+
username, password = credentials.split ':', 2 if credentials
|
16
|
+
assigns[:username] = username
|
17
|
+
assigns[:password] = password
|
18
|
+
end
|
19
|
+
opts.separator ''
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def execute
|
24
|
+
global = true
|
25
|
+
options.parse! args.empty? ? %w(-h) : args
|
26
|
+
|
27
|
+
if self.action == 'auth'
|
28
|
+
Authorization.authorize!(
|
29
|
+
assigns[:username], assigns[:password], assigns[:local]
|
30
|
+
)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module GHI
|
2
|
+
module Commands
|
3
|
+
class Edit < Command
|
4
|
+
def options
|
5
|
+
OptionParser.new do |opts|
|
6
|
+
opts.banner = <<EOF
|
7
|
+
usage: ghi edit [options] <issueno>
|
8
|
+
EOF
|
9
|
+
opts.separator ''
|
10
|
+
opts.on(
|
11
|
+
'-m', '--message <text>', 'change issue description'
|
12
|
+
) do |text|
|
13
|
+
assigns[:title], assigns[:body] = text.split(/\n+/, 2)
|
14
|
+
end
|
15
|
+
opts.on(
|
16
|
+
'-u', '--[no-]assign [<user>]', 'assign to specified user'
|
17
|
+
) do |assignee|
|
18
|
+
assigns[:assignee] = assignee
|
19
|
+
end
|
20
|
+
opts.on(
|
21
|
+
'-s', '--state <in>', %w(open closed),
|
22
|
+
{'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'"
|
23
|
+
) do |state|
|
24
|
+
assigns[:state] = state
|
25
|
+
end
|
26
|
+
opts.on(
|
27
|
+
'-M', '--[no-]milestone [<n>]', Integer, 'associate with milestone'
|
28
|
+
) do |milestone|
|
29
|
+
assigns[:milestone] = milestone
|
30
|
+
end
|
31
|
+
opts.on(
|
32
|
+
'-L', '--label <labelname>...', Array, 'associate with label(s)'
|
33
|
+
) do |labels|
|
34
|
+
assigns[:labels] = labels
|
35
|
+
end
|
36
|
+
opts.separator ''
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def execute
|
41
|
+
require_issue
|
42
|
+
require_repo
|
43
|
+
options.parse! args
|
44
|
+
i = throb { api.patch "/repos/#{repo}/issues/#{issue}", assigns }.body
|
45
|
+
puts format_issue(i)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module GHI
|
2
|
+
module Commands
|
3
|
+
class Help < Command
|
4
|
+
def self.execute args, message = nil
|
5
|
+
new(args).execute message
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_accessor :command
|
9
|
+
|
10
|
+
def options
|
11
|
+
OptionParser.new do |opts|
|
12
|
+
opts.banner = 'usage: ghi help [--all] [--man|--web] <command>'
|
13
|
+
opts.separator ''
|
14
|
+
opts.on('-a', '--all', 'print all available commands') { all }
|
15
|
+
opts.on('-m', '--man', 'show man page') { man }
|
16
|
+
opts.on('-w', '--web', 'show manual in web browser') { web }
|
17
|
+
opts.separator ''
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def execute message = nil
|
22
|
+
self.command = args.shift if args.first !~ /^-/
|
23
|
+
|
24
|
+
if command.nil? && args.empty?
|
25
|
+
puts message if message
|
26
|
+
puts <<EOF
|
27
|
+
|
28
|
+
The most commonly used ghi commands are:
|
29
|
+
list List your issues (or a repository's)
|
30
|
+
show Show an issue's details
|
31
|
+
open Open (or reopen) an issue
|
32
|
+
close Close an issue
|
33
|
+
edit Modify an existing issue
|
34
|
+
comment Leave a comment on an issue
|
35
|
+
label Create, list, modify, or delete labels
|
36
|
+
assign Assign an issue to yourself (or someone else)
|
37
|
+
milestone Manage project milestones
|
38
|
+
|
39
|
+
See 'ghi help <command>' for more information on a specific command.
|
40
|
+
EOF
|
41
|
+
exit
|
42
|
+
end
|
43
|
+
|
44
|
+
options.parse! args.empty? ? %w(-m) : args
|
45
|
+
end
|
46
|
+
|
47
|
+
def all
|
48
|
+
raise 'TODO'
|
49
|
+
end
|
50
|
+
|
51
|
+
def man
|
52
|
+
GHI.execute [command, '-h']
|
53
|
+
# TODO:
|
54
|
+
# exec "man #{['ghi', command].compact.join '-'}"
|
55
|
+
end
|
56
|
+
|
57
|
+
def web
|
58
|
+
raise 'TODO'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
module GHI
|
2
|
+
module Commands
|
3
|
+
class Label < Command
|
4
|
+
attr_accessor :name
|
5
|
+
|
6
|
+
#--
|
7
|
+
# FIXME: This does too much. Opt for a secondary command, e.g.,
|
8
|
+
#
|
9
|
+
# ghi label add <labelname>
|
10
|
+
# ghi label rm <labelname>
|
11
|
+
# ghi label <issueno> <labelname>...
|
12
|
+
#++
|
13
|
+
def options
|
14
|
+
OptionParser.new do |opts|
|
15
|
+
opts.banner = <<EOF
|
16
|
+
usage: ghi label <labelname> [-c <color>] [-r <newname>]
|
17
|
+
or: ghi label -D <labelname>
|
18
|
+
or: ghi label <issueno> [-a] [-d] [-f]
|
19
|
+
or: ghi label -l [<issueno>]
|
20
|
+
EOF
|
21
|
+
opts.separator ''
|
22
|
+
opts.on '-l', '--list [<issueno>]', 'list label names' do |n|
|
23
|
+
self.action = 'index'
|
24
|
+
@issue ||= n
|
25
|
+
end
|
26
|
+
opts.on '-D', '--delete', 'delete label' do
|
27
|
+
self.action = 'destroy'
|
28
|
+
end
|
29
|
+
opts.separator ''
|
30
|
+
opts.separator 'Label modification options'
|
31
|
+
opts.on(
|
32
|
+
'-c', '--color <color>', 'color name or 6-character hex code'
|
33
|
+
) do |color|
|
34
|
+
assigns[:color] = to_hex color
|
35
|
+
self.action ||= 'create'
|
36
|
+
end
|
37
|
+
opts.on '-r', '--rename <labelname>', 'new label name' do |name|
|
38
|
+
assigns[:name] = name
|
39
|
+
self.action = 'update'
|
40
|
+
end
|
41
|
+
opts.separator ''
|
42
|
+
opts.separator 'Issue modification options'
|
43
|
+
opts.on '-a', '--add', 'add labels to issue' do
|
44
|
+
self.action = issue ? 'add' : 'create'
|
45
|
+
end
|
46
|
+
opts.on '-d', '--delete', 'remove labels from issue' do
|
47
|
+
self.action = issue ? 'remove' : 'destroy'
|
48
|
+
end
|
49
|
+
opts.on '-f', '--force', 'replace existing labels' do
|
50
|
+
self.action = issue ? 'replace' : 'update'
|
51
|
+
end
|
52
|
+
opts.separator ''
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def execute
|
57
|
+
extract_issue
|
58
|
+
require_repo
|
59
|
+
options.parse! args.empty? ? %w(-l) : args
|
60
|
+
|
61
|
+
if issue
|
62
|
+
self.action ||= 'add'
|
63
|
+
self.name = args.shift.to_s.split ','
|
64
|
+
else
|
65
|
+
self.action ||= 'create'
|
66
|
+
self.name ||= args.shift
|
67
|
+
end
|
68
|
+
|
69
|
+
send action
|
70
|
+
end
|
71
|
+
|
72
|
+
protected
|
73
|
+
|
74
|
+
def index
|
75
|
+
if issue
|
76
|
+
uri = "/repos/#{repo}/issues/#{issue}/labels"
|
77
|
+
else
|
78
|
+
uri = "/repos/#{repo}/labels"
|
79
|
+
end
|
80
|
+
labels = throb { api.get uri }.body
|
81
|
+
if labels.empty?
|
82
|
+
puts 'None.'
|
83
|
+
else
|
84
|
+
puts labels.map { |label|
|
85
|
+
name = label['name']
|
86
|
+
colorize? ? bg(label['color']) { " #{name} " } : name
|
87
|
+
}
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def create
|
92
|
+
label = throb {
|
93
|
+
api.post "/repos/#{repo}/labels", assigns.merge(:name => name)
|
94
|
+
}.body
|
95
|
+
return update if label.nil?
|
96
|
+
puts "%s created." % bg(label['color']) { " #{label['name']} "}
|
97
|
+
rescue Client::Error => e
|
98
|
+
if e.errors.find { |error| error['code'] == 'already_exists' }
|
99
|
+
return update
|
100
|
+
end
|
101
|
+
raise
|
102
|
+
end
|
103
|
+
|
104
|
+
def update
|
105
|
+
label = throb {
|
106
|
+
api.patch "/repos/#{repo}/labels/#{name}", assigns
|
107
|
+
}.body
|
108
|
+
puts "%s updated." % bg(label['color']) { " #{label['name']} "}
|
109
|
+
end
|
110
|
+
|
111
|
+
def destroy
|
112
|
+
throb { api.delete "/repos/#{repo}/labels/#{name}" }
|
113
|
+
puts "[#{name}] deleted."
|
114
|
+
end
|
115
|
+
|
116
|
+
def add
|
117
|
+
labels = throb {
|
118
|
+
api.post "/repos/#{repo}/issues/#{issue}/labels", name
|
119
|
+
}.body
|
120
|
+
labels.delete_if { |l| !name.include?(l['name']) }
|
121
|
+
puts "Issue #%d labeled %s." % [issue, format_labels(labels)]
|
122
|
+
end
|
123
|
+
|
124
|
+
def remove
|
125
|
+
case name.length
|
126
|
+
when 0
|
127
|
+
throb { api.delete base_uri }
|
128
|
+
puts "Labels removed."
|
129
|
+
when 1
|
130
|
+
labels = throb { api.delete "#{base_uri}/#{name.join}" }.body
|
131
|
+
puts "Issue #%d labeled %s." % [issue, format_labels(labels)]
|
132
|
+
else
|
133
|
+
labels = throb {
|
134
|
+
api.get "/repos/#{repo}/issues/#{issue}/labels"
|
135
|
+
}.body
|
136
|
+
self.name = labels.map { |l| l['name'] } - name
|
137
|
+
replace
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def replace
|
142
|
+
labels = throb { api.put base_uri, name }.body
|
143
|
+
puts "Issue #%d labeled %s." % [issue, format_labels(labels)]
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
|
148
|
+
def base_uri
|
149
|
+
"/repos/#{repo}/#{issue ? "issues/#{issue}/labels" : 'labels'}"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|