ghi 0.3.1 → 0.9.0.dev
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.
- 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
|