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,168 @@
1
+ module GHI
2
+ module Commands
3
+ class Comment < Command
4
+ attr_accessor :comment
5
+ attr_accessor :verbose
6
+ attr_accessor :web
7
+
8
+ def options
9
+ OptionParser.new do |opts|
10
+ opts.banner = <<EOF
11
+ usage: ghi comment [options] <issueno>
12
+ EOF
13
+ opts.separator ''
14
+ opts.on '-l', '--list', 'list comments' do
15
+ self.action = 'list'
16
+ end
17
+ opts.on('-w', '--web') { self.web = true }
18
+ # opts.on '-v', '--verbose', 'list events, too'
19
+ opts.separator ''
20
+ opts.separator 'Comment modification options'
21
+ opts.on '-m', '--message [<text>]', 'comment body' do |text|
22
+ assigns[:body] = text
23
+ end
24
+ opts.on '--amend', 'amend previous comment' do
25
+ self.action = 'update'
26
+ end
27
+ opts.on '-D', '--delete', 'delete previous comment' do
28
+ self.action = 'destroy'
29
+ end
30
+ opts.on '--close', 'close associated issue' do
31
+ self.action = 'close'
32
+ end
33
+ opts.on '-v', '--verbose' do
34
+ self.verbose = true
35
+ end
36
+ opts.separator ''
37
+ end
38
+ end
39
+
40
+ def execute
41
+ require_issue
42
+ require_repo
43
+ self.action ||= 'create'
44
+ options.parse! args
45
+
46
+ case action
47
+ when 'list'
48
+ get_requests(:index, :events)
49
+ res = index
50
+ page do
51
+ elements = sort_by_creation(res.body + paged_events(events, res))
52
+ puts format_comments_and_events(elements)
53
+ break unless res.next_page
54
+ res = throb { api.get res.next_page }
55
+ end
56
+ when 'create'
57
+ if web
58
+ Web.new(repo).open "issues/#{issue}#issue_comment_form"
59
+ else
60
+ create
61
+ end
62
+ when 'update', 'destroy'
63
+ res = index
64
+ res = throb { api.get res.last_page } if res.last_page
65
+ self.comment = res.body.reverse.find { |c|
66
+ c['user']['login'] == Authorization.username
67
+ }
68
+ if comment
69
+ send action
70
+ else
71
+ abort 'No recent comment found.'
72
+ end
73
+ when 'close'
74
+ Close.execute [issue, '-m', assigns[:body], '--', repo].compact
75
+ end
76
+ end
77
+
78
+ protected
79
+
80
+ def index
81
+ @index ||= throb { api.get uri, :per_page => 100 }
82
+ end
83
+
84
+ def create message = 'Commented.'
85
+ e = require_body
86
+ c = throb { api.post uri, assigns }.body
87
+ puts format_comment(c)
88
+ puts message
89
+ e.unlink if e
90
+ end
91
+
92
+ def update
93
+ create 'Comment updated.'
94
+ end
95
+
96
+ def destroy
97
+ throb { api.delete uri }
98
+ puts 'Comment deleted.'
99
+ end
100
+
101
+ def events
102
+ @events ||= begin
103
+ events = []
104
+ res = api.get(event_uri, :per_page => 100)
105
+ loop do
106
+ events += res.body
107
+ break unless res.next_page
108
+ res = api.get res.next_page
109
+ end
110
+ events
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def get_requests(*methods)
117
+ threads = methods.map do |method|
118
+ Thread.new { send(method) }
119
+ end
120
+ threads.each { |t| t.join }
121
+ end
122
+
123
+ def uri
124
+ if comment
125
+ comment['url']
126
+ else
127
+ "/repos/#{repo}/issues/#{issue}/comments"
128
+ end
129
+ end
130
+
131
+ def event_uri
132
+ "/repos/#{repo}/issues/#{issue}/events"
133
+ end
134
+
135
+ def require_body
136
+ assigns[:body] = args.join ' ' unless args.empty?
137
+ return if assigns[:body]
138
+ if issue && verbose
139
+ i = throb { api.get "/repos/#{repo}/issues/#{issue}" }.body
140
+ else
141
+ i = {'number'=>issue}
142
+ end
143
+ filename = "GHI_COMMENT_#{issue}"
144
+ filename << "_#{comment['id']}" if comment
145
+ e = Editor.new filename
146
+ message = e.gets format_comment_editor(i, comment)
147
+ e.unlink 'No comment.' if message.nil? || message.empty?
148
+ if comment && message.strip == comment['body'].strip
149
+ e.unlink 'No change.'
150
+ end
151
+ assigns[:body] = message if message
152
+ e
153
+ end
154
+
155
+ def paged_events(events, comments_res)
156
+ if comments_res.next_page
157
+ last_comment_creation = comments_res.body.last['created_at']
158
+ events_for_this_page, @events = events.partition do |event|
159
+ event['created_at'] < last_comment_creation
160
+ end
161
+ events_for_this_page
162
+ else
163
+ events
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,57 @@
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>]' do |username|
14
+ self.action = 'auth'
15
+ assigns[:username] = username || Authorization.username
16
+ end
17
+ opts.separator ''
18
+ end
19
+ end
20
+
21
+ def execute
22
+ # TODO: Investigate whether or not this variable is needed
23
+ global = true
24
+
25
+ options.parse! args.empty? ? %w(-h) : args
26
+
27
+ if action == 'auth'
28
+ assigns[:password] = Authorization.password || get_password
29
+ Authorization.authorize!(
30
+ assigns[:username], assigns[:password], assigns[:local]
31
+ )
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def get_password
38
+ print "Enter #{assigns[:username]}'s GitHub password (never stored): "
39
+ current_tty = `stty -g`
40
+ system 'stty raw -echo -icanon isig' if $?.success?
41
+ input = ''
42
+ while char = $stdin.getbyte and not (char == 13 or char == 10)
43
+ if char == 127 or char == 8
44
+ input[-1, 1] = '' unless input.empty?
45
+ else
46
+ input << char.chr
47
+ end
48
+ end
49
+ input
50
+ rescue Interrupt
51
+ print '^C'
52
+ ensure
53
+ system "stty #{current_tty}" unless current_tty.empty?
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,35 @@
1
+ module GHI
2
+ module Commands
3
+ class Disable < Command
4
+
5
+ def options
6
+ OptionParser.new do |opts|
7
+ opts.banner = 'usage: ghi disable'
8
+ end
9
+ end
10
+
11
+ def execute
12
+ begin
13
+ options.parse! args
14
+ @repo ||= ARGV[0] if ARGV.one?
15
+ rescue OptionParser::InvalidOption => e
16
+ fallback.parse! e.args
17
+ retry
18
+ end
19
+ repo_name = require_repo_name
20
+ unless repo_name.nil?
21
+ patch_data = {}
22
+ patch_data[:name] = repo_name
23
+ patch_data[:has_issues] = false
24
+ res = throb { api.patch "/repos/#{repo}", patch_data }.body
25
+ if !res['has_issues']
26
+ puts "Issues are now disabled for this repo"
27
+ else
28
+ puts "Something went wrong disabling issues for this repo"
29
+ end
30
+ end
31
+ end
32
+
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,139 @@
1
+ module GHI
2
+ module Commands
3
+ class Edit < Command
4
+ attr_accessor :editor
5
+
6
+ def options
7
+ OptionParser.new do |opts|
8
+ opts.banner = <<EOF
9
+ usage: ghi edit <issueno> [options]
10
+ EOF
11
+ opts.separator ''
12
+ opts.on(
13
+ '-m', '--message [<text>]', 'change issue description'
14
+ ) do |text|
15
+ next self.editor = true if text.nil?
16
+ assigns[:title], assigns[:body] = text.split(/\n+/, 2)
17
+ end
18
+ opts.on(
19
+ '-u', '--[no-]assign [<user>]', 'assign to specified user'
20
+ ) do |assignee|
21
+ assigns[:assignee] = assignee || nil
22
+ end
23
+ opts.on '--claim', 'assign to yourself' do
24
+ assigns[:assignee] = Authorization.username
25
+ end
26
+ opts.on(
27
+ '-s', '--state <in>', %w(open closed),
28
+ {'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'"
29
+ ) do |state|
30
+ assigns[:state] = state
31
+ end
32
+ opts.on(
33
+ '-M', '--[no-]milestone [<n>]', Integer, 'associate with milestone'
34
+ ) do |milestone|
35
+ assigns[:milestone] = milestone
36
+ end
37
+ opts.on(
38
+ '-L', '--label <labelname>...', Array, 'associate with label(s)'
39
+ ) do |labels|
40
+ (assigns[:labels] ||= []).concat labels
41
+ end
42
+ opts.separator ''
43
+ opts.separator 'Pull request options'
44
+ opts.on(
45
+ '-H', '--head [[<user>:]<branch>]',
46
+ 'branch where your changes are implemented',
47
+ '(defaults to current branch)'
48
+ ) do |head|
49
+ self.action = 'pull'
50
+ assigns[:head] = head
51
+ end
52
+ opts.on(
53
+ '-b', '--base [<branch>]',
54
+ 'branch you want your changes pulled into', '(defaults to master)'
55
+ ) do |base|
56
+ self.action = 'pull'
57
+ assigns[:base] = base
58
+ end
59
+ opts.separator ''
60
+ end
61
+ end
62
+
63
+ def execute
64
+ self.action = 'edit'
65
+ require_repo
66
+ require_issue
67
+ options.parse! args
68
+ case action
69
+ when 'edit'
70
+ begin
71
+ if editor || assigns.empty?
72
+ i = throb { api.get "/repos/#{repo}/issues/#{issue}" }.body
73
+ e = Editor.new "GHI_ISSUE_#{issue}"
74
+ message = e.gets format_editor(i)
75
+ e.unlink "There's no issue." if message.nil? || message.empty?
76
+ assigns[:title], assigns[:body] = message.split(/\n+/, 2)
77
+ end
78
+ if i && assigns.keys.map { |k| k.to_s }.sort == %w[body title]
79
+ titles_match = assigns[:title].strip == i['title'].strip
80
+ if assigns[:body]
81
+ bodies_match = assigns[:body].to_s.strip == i['body'].to_s.strip
82
+ end
83
+ if titles_match && bodies_match
84
+ e.unlink if e
85
+ abort 'No change.' if assigns.dup.delete_if { |k, v|
86
+ [:title, :body].include? k
87
+ }
88
+ end
89
+ end
90
+ unless assigns.empty?
91
+ i = throb {
92
+ api.patch "/repos/#{repo}/issues/#{issue}", assigns
93
+ }.body
94
+ puts format_issue(i)
95
+ puts 'Updated.'
96
+ end
97
+ e.unlink if e
98
+ rescue Client::Error => e
99
+ raise unless error = e.errors.first
100
+ abort "%s %s %s %s." % [
101
+ error['resource'],
102
+ error['field'],
103
+ [*error['value']].join(', '),
104
+ error['code']
105
+ ]
106
+ end
107
+ when 'pull'
108
+ begin
109
+ assigns[:issue] = issue
110
+ assigns[:base] ||= 'master'
111
+ head = begin
112
+ if ref = %x{
113
+ git rev-parse --abbrev-ref HEAD@{upstream} 2>/dev/null
114
+ }.chomp!
115
+ ref.split('/', 2).last if $? == 0
116
+ end
117
+ end
118
+ assigns[:head] ||= head
119
+ if assigns[:head]
120
+ assigns[:head].sub!(/:$/, ":#{head}")
121
+ else
122
+ abort <<EOF.chomp
123
+ fatal: HEAD can't be null. (Is your current branch being tracked upstream?)
124
+ EOF
125
+ end
126
+ throb { api.post "/repos/#{repo}/pulls", assigns }
127
+ base = [repo.split('/').first, assigns[:base]].join ':'
128
+ puts 'Issue #%d set up to track remote branch %s against %s.' % [
129
+ issue, assigns[:head], base
130
+ ]
131
+ rescue Client::Error => e
132
+ raise unless error = e.errors.last
133
+ abort error['message'].sub(/^base /, '')
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,36 @@
1
+ module GHI
2
+ module Commands
3
+ class Enable < Command
4
+
5
+ def options
6
+ OptionParser.new do |opts|
7
+ opts.banner = 'usage: ghi enable'
8
+ end
9
+ end
10
+
11
+ def execute
12
+ begin
13
+ options.parse! args
14
+ @repo ||= ARGV[0] if ARGV.one?
15
+ rescue OptionParser::InvalidOption => e
16
+ fallback.parse! e.args
17
+ retry
18
+ end
19
+ repo_name = require_repo_name
20
+ unless repo_name.nil?
21
+ patch_data = {}
22
+ patch_data[:name] = repo_name
23
+ patch_data[:has_issues] = true
24
+ res = throb { api.patch "/repos/#{repo}", patch_data }.body
25
+ if res['has_issues']
26
+ puts "Issues are now enabled for this repo"
27
+ else
28
+ puts "Something went wrong enabling issues for this repo"
29
+ end
30
+ end
31
+ end
32
+
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,65 @@
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
+ status Determine whether or not issues are enabled for this repo
39
+ enable Enable issues for the current repo
40
+ disable Disable issues for the current repo
41
+
42
+ See 'ghi help <command>' for more information on a specific command.
43
+ EOF
44
+ exit
45
+ end
46
+
47
+ options.parse! args.empty? ? %w(-m) : args
48
+ end
49
+
50
+ def all
51
+ raise 'TODO'
52
+ end
53
+
54
+ def man
55
+ GHI.execute [command, '-h']
56
+ # TODO:
57
+ # exec "man #{['ghi', command].compact.join '-'}"
58
+ end
59
+
60
+ def web
61
+ raise 'TODO'
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,196 @@
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(s)> [-a] [-d] [-f] <label>
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 = issues_present? ? 'add' : 'create'
45
+ end
46
+ opts.on '-d', '--delete', 'remove labels from issue' do
47
+ self.action = issues_present? ? 'remove' : 'destroy'
48
+ end
49
+ opts.on '-f', '--force', 'replace existing labels' do
50
+ self.action = issues_present? ? '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 issues_present?
62
+ self.action ||= 'add'
63
+ self.name = args.shift.to_s.split ','
64
+ self.name.concat args
65
+ multi_action(action)
66
+ else
67
+ self.action ||= 'create'
68
+ self.name ||= args.shift
69
+ send action
70
+ end
71
+ end
72
+
73
+ protected
74
+
75
+ def index
76
+ if issue
77
+ uri = "/repos/#{repo}/issues/#{issue}/labels"
78
+ else
79
+ uri = "/repos/#{repo}/labels"
80
+ end
81
+ labels = throb { api.get uri }.body
82
+ if labels.empty?
83
+ puts 'None.'
84
+ else
85
+ puts labels.map { |label|
86
+ name = label['name']
87
+ colorize? ? bg(label['color']) { " #{name} " } : name
88
+ }
89
+ end
90
+ end
91
+
92
+ def create
93
+ label = throb {
94
+ api.post "/repos/#{repo}/labels", assigns.merge(:name => name)
95
+ }.body
96
+ return update if label.nil?
97
+ puts "%s created." % bg(label['color']) { " #{label['name']} "}
98
+ rescue Client::Error => e
99
+ if e.errors.find { |error| error['code'] == 'already_exists' }
100
+ return update
101
+ end
102
+ raise
103
+ end
104
+
105
+ def update
106
+ label = throb {
107
+ api.patch "/repos/#{repo}/labels/#{name}", assigns
108
+ }.body
109
+ puts "%s updated." % bg(label['color']) { " #{label['name']} "}
110
+ end
111
+
112
+ def destroy
113
+ throb { api.delete "/repos/#{repo}/labels/#{name}" }
114
+ puts "[#{name}] deleted."
115
+ end
116
+
117
+ def add
118
+ labels = throb {
119
+ api.post "/repos/#{repo}/issues/#{issue}/labels", name
120
+ }.body
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
+ if labels.empty?
132
+ puts "Issue #%d unlabeled." % issue
133
+ else
134
+ puts "Issue #%d labeled %s." % [issue, format_labels(labels)]
135
+ end
136
+ else
137
+ labels = throb {
138
+ api.get "/repos/#{repo}/issues/#{issue}/labels"
139
+ }.body
140
+ self.name = labels.map { |l| l['name'] } - name
141
+ replace
142
+ end
143
+ end
144
+
145
+ def replace
146
+ labels = throb { api.put base_uri, name }.body
147
+ if labels.empty?
148
+ puts "Issue #%d unlabeled." % issue
149
+ else
150
+ puts "Issue #%d labeled %s." % [issue, format_labels(labels)]
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ def base_uri
157
+ "/repos/#{repo}/#{issue ? "issues/#{issue}/labels" : 'labels'}"
158
+ end
159
+
160
+ # This method is usually inherited from Command and extracts a single issue
161
+ # from args - we override it to handle multiple issues at once.
162
+ def extract_issue
163
+ @issues = []
164
+ args.delete_if do |arg|
165
+ arg.match(/^\d+$/) ? @issues << arg : break
166
+ end
167
+ infer_issue_from_branch_prefix unless @issues.any?
168
+ end
169
+
170
+ def issues_present?
171
+ @issues.any? || @issue
172
+ end
173
+
174
+ def multi_action(action)
175
+ if @issues.any?
176
+ override_issue_reader
177
+ threads = @issues.map do |issue|
178
+ Thread.new do
179
+ Thread.current[:issue] = issue
180
+ send action
181
+ end
182
+ end
183
+ threads.each(&:join)
184
+ else
185
+ send action
186
+ end
187
+ end
188
+
189
+ def override_issue_reader
190
+ def issue
191
+ Thread.current[:issue]
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end