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,204 @@
1
+ require 'date'
2
+
3
+ module GHI
4
+ module Commands
5
+ class List < Command
6
+ attr_accessor :web
7
+ attr_accessor :reverse
8
+ attr_accessor :quiet
9
+ attr_accessor :exclude_pull_requests
10
+ attr_accessor :pull_requests_only
11
+
12
+ def options
13
+ OptionParser.new do |opts|
14
+ opts.banner = 'usage: ghi list [options]'
15
+ opts.separator ''
16
+ opts.on '-g', '--global', 'all of your issues on GitHub' do
17
+ assigns[:filter] = 'all'
18
+ @repo = nil
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
+ '-L', '--label <labelname>...', Array, 'by label(s)'
28
+ ) do |labels|
29
+ (assigns[:labels] ||= []).concat labels
30
+ end
31
+ opts.on(
32
+ '-N', '--not-label <labelname>...', Array, 'exclude with label(s)'
33
+ ) do |labels|
34
+ (assigns[:exclude_labels] ||= []).concat labels
35
+ end
36
+ opts.on(
37
+ '--no-labels', 'do not print labels'
38
+ ) do
39
+ assigns[:dont_print_labels] = true
40
+ end
41
+ opts.on(
42
+ '-S', '--sort <by>', %w(created updated comments),
43
+ {'c'=>'created','u'=>'updated','m'=>'comments'},
44
+ "'created', 'updated', or 'comments'"
45
+ ) do |sort|
46
+ assigns[:sort] = sort
47
+ end
48
+ opts.on '--reverse', 'reverse (ascending) sort order' do
49
+ self.reverse = !reverse
50
+ end
51
+ opts.on('-p', '--pulls','list only pull requests') { self.pull_requests_only = true }
52
+ opts.on('-P', '--no-pulls','exclude pull requests') { self.exclude_pull_requests = true }
53
+ opts.on(
54
+ '--since <date>', 'issues more recent than',
55
+ "e.g., '2011-04-30'"
56
+ ) do |date|
57
+ begin
58
+ assigns[:since] = DateTime.parse date # TODO: Better parsing.
59
+ rescue ArgumentError => e
60
+ raise OptionParser::InvalidArgument, e.message
61
+ end
62
+ end
63
+ opts.on('-v', '--verbose') { self.verbose = true }
64
+ opts.on('-w', '--web') { self.web = true }
65
+ opts.separator ''
66
+ opts.separator 'Global options'
67
+ opts.on(
68
+ '-f', '--filter <by>',
69
+ filters = %w[all assigned created mentioned subscribed],
70
+ Hash[filters.map { |f| [f[0, 1], f] }],
71
+ filters.map { |f| "'#{f}'" }.join(', ')
72
+ ) do |filter|
73
+ assigns[:filter] = filter
74
+ end
75
+ opts.on '--mine', 'assigned to you' do
76
+ assigns[:filter] = 'assigned'
77
+ assigns[:assignee] = Authorization.username
78
+ end
79
+ opts.separator ''
80
+ opts.separator 'Project options'
81
+ opts.on(
82
+ '-M', '--[no-]milestone [<n>]', Integer,
83
+ 'with (specified) milestone'
84
+ ) do |milestone|
85
+ assigns[:milestone] = any_or_none_or milestone
86
+ end
87
+ opts.on(
88
+ '-u', '--[no-]assignee [<user>]', 'assigned to specified user'
89
+ ) do |assignee|
90
+ assignee = assignee.sub /^@/, '' if assignee
91
+ assigns[:assignee] = any_or_none_or assignee
92
+ end
93
+ opts.on '--mine', 'assigned to you' do
94
+ assigns[:filter] = 'assigned'
95
+ assigns[:assignee] = Authorization.username
96
+ end
97
+ opts.on(
98
+ '--creator [<user>]', 'created by you or specified user'
99
+ ) do |creator|
100
+ creator = creator.sub /^@/, '' if creator
101
+ assigns[:creator] = creator || Authorization.username
102
+ end
103
+ opts.on(
104
+ '-U', '--mentioned [<user>]', 'mentioning you or specified user'
105
+ ) do |mentioned|
106
+ assigns[:mentioned] = mentioned || Authorization.username
107
+ end
108
+ opts.on(
109
+ '-O', '--org <organization>', 'in repos within an organization you belong to'
110
+ ) do |org|
111
+ assigns[:org] = org
112
+ @repo = nil
113
+ end
114
+ opts.separator ''
115
+ end
116
+ end
117
+
118
+ def execute
119
+ if index = args.index { |arg| /^@/ === arg }
120
+ assigns[:assignee] = args.delete_at(index)[1..-1]
121
+ end
122
+
123
+ begin
124
+ options.parse! args
125
+ @repo ||= ARGV[0] if ARGV.one?
126
+ rescue OptionParser::InvalidOption => e
127
+ fallback.parse! e.args
128
+ retry
129
+ end
130
+ assigns[:labels] = assigns[:labels].join ',' if assigns[:labels]
131
+ if assigns[:exclude_labels]
132
+ assigns[:exclude_labels] = assigns[:exclude_labels].join ','
133
+ end
134
+ if reverse
135
+ assigns[:sort] ||= 'created'
136
+ assigns[:direction] = 'asc'
137
+ end
138
+ if web
139
+ Web.new(repo || 'dashboard').open 'issues', assigns
140
+ else
141
+ assigns[:per_page] = 100
142
+ unless quiet
143
+ print header = format_issues_header
144
+ print "\n" unless paginate?
145
+ end
146
+ res = throb(
147
+ 0, format_state(assigns[:state], quiet ? CURSOR[:up][1] : '#')
148
+ ) { api.get uri, assigns }
149
+ print "\r#{CURSOR[:up][1]}" if header && paginate?
150
+ page header do
151
+ issues = res.body
152
+
153
+ if exclude_pull_requests || pull_requests_only
154
+ prs, issues = issues.partition { |i| i['pull_request'].values.any? }
155
+ issues = prs if pull_requests_only
156
+ end
157
+ if assigns[:exclude_labels]
158
+ issues = issues.reject do |i|
159
+ i["labels"].any? do |label|
160
+ assigns[:exclude_labels].include? label["name"]
161
+ end
162
+ end
163
+ end
164
+ if verbose
165
+ puts issues.map { |i| format_issue i }
166
+ else
167
+ puts format_issues(issues, repo.nil?)
168
+ end
169
+ break unless res.next_page
170
+ res = throb { api.get res.next_page }
171
+ end
172
+ end
173
+ rescue Client::Error => e
174
+ if e.response.code == '422'
175
+ e.errors.any? { |err|
176
+ err['code'] == 'missing' && err['field'] == 'milestone'
177
+ } and abort 'No such milestone.'
178
+ end
179
+
180
+ raise
181
+ end
182
+
183
+ private
184
+
185
+ def uri
186
+ url = ''
187
+ if repo
188
+ url = "/repos/#{repo}"
189
+ end
190
+ if assigns[:org]
191
+ url = "/orgs/#{assigns[:org]}"
192
+ end
193
+ return url << '/issues'
194
+ end
195
+
196
+ def fallback
197
+ OptionParser.new do |opts|
198
+ opts.on('-c', '--closed') { assigns[:state] = 'closed' }
199
+ opts.on('-q', '--quiet') { self.quiet = true }
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,206 @@
1
+ require 'date'
2
+
3
+ module GHI
4
+ module Commands
5
+ class Milestone < Command
6
+ attr_accessor :edit
7
+ attr_accessor :reverse
8
+ attr_accessor :web
9
+
10
+ #--
11
+ # FIXME: Opt for better interface, e.g.,
12
+ #
13
+ # ghi milestone [-v | --verbose] [--[no-]closed]
14
+ # ghi milestone add <name> <description>
15
+ # ghi milestone rm <milestoneno>
16
+ #++
17
+ def options
18
+ OptionParser.new do |opts|
19
+ opts.banner = <<EOF
20
+ usage: ghi milestone [<modification options>] [<milestoneno>]
21
+ or: ghi milestone -D <milestoneno>
22
+ or: ghi milestone -l [-c] [-v]
23
+ EOF
24
+ opts.separator ''
25
+ opts.on '-l', '--list', 'list milestones' do
26
+ self.action = 'index'
27
+ end
28
+ opts.on '-c', '--[no-]closed', 'show closed milestones' do |closed|
29
+ assigns[:state] = closed ? 'closed' : 'open'
30
+ end
31
+ opts.on(
32
+ '-S', '--sort <on>', %w(due_date completeness),
33
+ {'d'=>'due_date', 'due'=>'due_date', 'c'=>'completeness'},
34
+ "'due_date' or 'completeness'"
35
+ ) do |sort|
36
+ assigns[:sort] = sort
37
+ end
38
+ opts.on '--reverse', 'reverse (ascending) sort order' do
39
+ self.reverse = !reverse
40
+ end
41
+ opts.on '-v', '--verbose', 'list milestones verbosely' do
42
+ self.verbose = true
43
+ end
44
+ opts.on('-w', '--web') { self.web = true }
45
+ opts.separator ''
46
+ opts.separator 'Milestone modification options'
47
+ opts.on(
48
+ '-m', '--message [<text>]', 'change milestone description'
49
+ ) do |text|
50
+ self.action = 'create'
51
+ self.edit = true
52
+ next unless text
53
+ assigns[:title], assigns[:description] = text.split(/\n+/, 2)
54
+ end
55
+ # FIXME: We already describe --[no-]closed; describe this, too?
56
+ opts.on(
57
+ '-s', '--state <in>', %w(open closed),
58
+ {'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'"
59
+ ) do |state|
60
+ self.action = 'create'
61
+ assigns[:state] = state
62
+ end
63
+ opts.on(
64
+ '--due <on>', 'when milestone should be complete',
65
+ "e.g., '2012-04-30'"
66
+ ) do |date|
67
+ self.action = 'create'
68
+ begin
69
+ # TODO: Better parsing.
70
+ assigns[:due_on] = DateTime.parse(date).strftime
71
+ rescue ArgumentError => e
72
+ raise OptionParser::InvalidArgument, e.message
73
+ end
74
+ end
75
+ opts.on '-D', '--delete', 'delete milestone' do
76
+ self.action = 'destroy'
77
+ end
78
+ opts.separator ''
79
+ end
80
+ end
81
+
82
+ def execute
83
+ self.action = 'index'
84
+ require_repo
85
+ extract_milestone
86
+
87
+ begin
88
+ options.parse! args
89
+ rescue OptionParser::AmbiguousOption => e
90
+ fallback.parse! e.args
91
+ end
92
+
93
+ milestone and case action
94
+ when 'create' then self.action = 'update'
95
+ when 'index' then self.action = 'show'
96
+ end
97
+
98
+ if reverse
99
+ assigns[:sort] ||= 'created'
100
+ assigns[:direction] = 'asc'
101
+ end
102
+
103
+ case action
104
+ when 'index'
105
+ if web
106
+ Web.new(repo).open 'milestones', assigns
107
+ else
108
+ assigns[:per_page] = 100
109
+ state = assigns[:state] || 'open'
110
+ print format_state state, "# #{repo} #{state} milestones"
111
+ print "\n" unless paginate?
112
+ res = throb(0, format_state(state, '#')) { api.get uri, assigns }
113
+ page do
114
+ milestones = res.body
115
+ if verbose
116
+ puts milestones.map { |m| format_milestone m }
117
+ else
118
+ puts format_milestones(milestones)
119
+ end
120
+ break unless res.next_page
121
+ res = throb { api.get res.next_page }
122
+ end
123
+ end
124
+ when 'show'
125
+ if web
126
+ List.execute %W(-w -M #{milestone} -- #{repo})
127
+ else
128
+ m = throb { api.get uri }.body
129
+ page do
130
+ puts format_milestone(m)
131
+ puts 'Issues:'
132
+ args.unshift(*%W(-q -M #{milestone} -- #{repo}))
133
+ args.unshift '-v' if verbose
134
+ List.execute args
135
+ break
136
+ end
137
+ end
138
+ when 'create'
139
+ if web
140
+ Web.new(repo).open 'issues/milestones/new'
141
+ else
142
+ if assigns[:title].nil?
143
+ e = Editor.new 'GHI_MILESTONE'
144
+ message = e.gets format_milestone_editor
145
+ e.unlink 'Empty milestone.' if message.nil? || message.empty?
146
+ assigns[:title], assigns[:description] = message.split(/\n+/, 2)
147
+ end
148
+ m = throb { api.post uri, assigns }.body
149
+ puts 'Milestone #%d created.' % m['number']
150
+ e.unlink if e
151
+ end
152
+ when 'update'
153
+ if web
154
+ Web.new(repo).open "issues/milestones/#{milestone}/edit"
155
+ else
156
+ if edit || assigns.empty?
157
+ m = throb { api.get "/repos/#{repo}/milestones/#{milestone}" }.body
158
+ e = Editor.new "GHI_MILESTONE_#{milestone}"
159
+ message = e.gets format_milestone_editor(m)
160
+ e.unlink 'Empty milestone.' if message.nil? || message.empty?
161
+ assigns[:title], assigns[:description] = message.split(/\n+/, 2)
162
+ end
163
+ if assigns[:title] && m
164
+ t_match = assigns[:title].strip == m['title'].strip
165
+ if assigns[:description]
166
+ b_match = assigns[:description].strip == m['description'].strip
167
+ end
168
+ if t_match && b_match
169
+ e.unlink if e
170
+ abort 'No change.' if assigns.dup.delete_if { |k, v|
171
+ [:title, :description].include? k
172
+ }
173
+ end
174
+ end
175
+ m = throb { api.patch uri, assigns }.body
176
+ puts format_milestone(m)
177
+ puts 'Updated.'
178
+ e.unlink if e
179
+ end
180
+ when 'destroy'
181
+ require_milestone
182
+ throb { api.delete uri }
183
+ puts 'Milestone deleted.'
184
+ end
185
+ end
186
+
187
+ private
188
+
189
+ def uri
190
+ if milestone
191
+ "/repos/#{repo}/milestones/#{milestone}"
192
+ else
193
+ "/repos/#{repo}/milestones"
194
+ end
195
+ end
196
+
197
+ def fallback
198
+ OptionParser.new do |opts|
199
+ opts.on '-d' do
200
+ self.action = 'destroy'
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,102 @@
1
+ module GHI
2
+ module Commands
3
+ class Open < Command
4
+ attr_accessor :editor
5
+ attr_accessor :web
6
+
7
+ def options
8
+ OptionParser.new do |opts|
9
+ opts.banner = <<EOF
10
+ usage: ghi open [options]
11
+ or: ghi reopen [options] <issueno>
12
+ EOF
13
+ opts.separator ''
14
+ opts.on '-l', '--list', 'list open tickets' do
15
+ self.action = 'index'
16
+ end
17
+ opts.on('-w', '--web') { self.web = true }
18
+ opts.separator ''
19
+ opts.separator 'Issue modification options'
20
+ opts.on(
21
+ '-m', '--message [<text>]', 'describe issue',
22
+ "use line breaks to separate title from description"
23
+ ) do |text|
24
+ if text
25
+ assigns[:title], assigns[:body] = text.split(/\n+/, 2)
26
+ else
27
+ self.editor = true
28
+ end
29
+ end
30
+ opts.on(
31
+ '-u', '--[no-]assign [<user>]', 'assign to specified user'
32
+ ) do |assignee|
33
+ assigns[:assignee] = assignee
34
+ end
35
+ opts.on '--claim', 'assign to yourself' do
36
+ assigns[:assignee] = Authorization.username
37
+ end
38
+ opts.on(
39
+ '-M', '--milestone <n>', 'associate with milestone'
40
+ ) do |milestone|
41
+ assigns[:milestone] = milestone
42
+ end
43
+ opts.on(
44
+ '-L', '--label <labelname>...', Array, 'associate with label(s)'
45
+ ) do |labels|
46
+ (assigns[:labels] ||= []).concat labels
47
+ end
48
+ opts.separator ''
49
+ end
50
+ end
51
+
52
+ def execute
53
+ require_repo
54
+ self.action = 'create'
55
+
56
+ options.parse! args
57
+
58
+ if GHI.config('ghi.infer-issue') != 'false' && extract_issue
59
+ Edit.execute args.push('-so', issue, '--', repo)
60
+ exit
61
+ end
62
+
63
+ case action
64
+ when 'index'
65
+ if assigns.key? :assignee
66
+ args.unshift assigns[:assignee] if assigns[:assignee]
67
+ args.unshift '-u'
68
+ end
69
+ args.unshift '-w' if web
70
+ List.execute args.push('--', repo)
71
+ when 'create'
72
+ if web
73
+ Web.new(repo).open 'issues/new'
74
+ else
75
+ unless args.empty?
76
+ assigns[:title], assigns[:body] = args.join(' '), assigns[:title]
77
+ end
78
+ assigns[:title] = args.join ' ' unless args.empty?
79
+ if assigns[:title].nil? || editor
80
+ e = Editor.new 'GHI_ISSUE'
81
+ message = e.gets format_editor(assigns)
82
+ e.unlink "There's no issue?" if message.nil? || message.empty?
83
+ assigns[:title], assigns[:body] = message.split(/\n+/, 2)
84
+ end
85
+ i = throb { api.post "/repos/#{repo}/issues", assigns }.body
86
+ e.unlink if e
87
+ puts format_issue(i)
88
+ puts "Opened on #{repo}."
89
+ end
90
+ end
91
+ rescue Client::Error => e
92
+ raise unless error = e.errors.first
93
+ abort "%s %s %s %s." % [
94
+ error['resource'],
95
+ error['field'],
96
+ [*error['value']].join(', '),
97
+ error['code']
98
+ ]
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,65 @@
1
+ module GHI
2
+ module Commands
3
+ class Show < Command
4
+ attr_accessor :patch, :web
5
+
6
+ def options
7
+ OptionParser.new do |opts|
8
+ opts.banner = 'usage: ghi show <issueno>'
9
+ opts.separator ''
10
+ opts.on('-p', '--patch') { self.patch = true }
11
+ opts.on('-w', '--web') { self.web = true }
12
+ end
13
+ end
14
+
15
+ def execute
16
+ require_issue
17
+ require_repo
18
+ options.parse! args
19
+ patch_path = "pull/#{issue}.patch" if patch # URI also in API...
20
+ if web
21
+ Web.new(repo).open patch_path || "issues/#{issue}"
22
+ else
23
+ if patch_path
24
+ i = throb { Web.new(repo).curl patch_path }
25
+ unless i.start_with? 'From'
26
+ warn 'Patch not found'
27
+ abort
28
+ end
29
+ page do
30
+ no_color { puts i }
31
+ break
32
+ end
33
+ else
34
+ i = throb { api.get "/repos/#{repo}/issues/#{issue}" }.body
35
+ determine_merge_status(i) if pull_request?(i)
36
+ page do
37
+ puts format_issue(i)
38
+ n = i['comments']
39
+ if n > 0
40
+ puts "#{n} comment#{'s' unless n == 1}:\n\n"
41
+ Comment.execute %W(-l #{issue} -- #{repo})
42
+ end
43
+ break
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def pull_request?(issue)
52
+ issue['pull_request']['html_url']
53
+ end
54
+
55
+ def determine_merge_status(pr)
56
+ pr['merged'] = true if pr['state'] == 'closed' && merged?
57
+ end
58
+
59
+ def merged?
60
+ # API returns with a Not Found error when the PR is not merged
61
+ api.get "/repos/#{repo}/pulls/#{issue}/merge" rescue false
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,32 @@
1
+ module GHI
2
+ module Commands
3
+ class Status < Command
4
+
5
+ def options
6
+ OptionParser.new do |opts|
7
+ opts.banner = 'usage: ghi status'
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
+
20
+ require_repo
21
+ res = throb { api.get "/repos/#{repo}" }.body
22
+
23
+ if res['has_issues']
24
+ puts "Issues are enabled for this repo"
25
+ else
26
+ puts "Issues are not enabled for this repo"
27
+ end
28
+
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ module GHI
2
+ module Commands
3
+ module Version
4
+ MAJOR = 1
5
+ MINOR = 2
6
+ PATCH = 0
7
+ PRE = nil
8
+
9
+ VERSION = [MAJOR, MINOR, PATCH, PRE].compact.join '.'
10
+
11
+ def self.execute args
12
+ puts "ghi version #{VERSION}"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ module GHI
2
+ module Commands
3
+ autoload :Command, 'ghi/commands/command'
4
+
5
+ autoload :List, 'ghi/commands/list'
6
+ autoload :Open, 'ghi/commands/open'
7
+ autoload :Assign, 'ghi/commands/assign'
8
+ autoload :Close, 'ghi/commands/close'
9
+ autoload :Comment, 'ghi/commands/comment'
10
+ autoload :Config, 'ghi/commands/config'
11
+ autoload :Disable, 'ghi/commands/disable'
12
+ autoload :Edit, 'ghi/commands/edit'
13
+ autoload :Enable, 'ghi/commands/enable'
14
+ autoload :Help, 'ghi/commands/help'
15
+ autoload :Label, 'ghi/commands/label'
16
+ autoload :Milestone, 'ghi/commands/milestone'
17
+ autoload :Reopen, 'ghi/commands/reopen'
18
+ autoload :Show, 'ghi/commands/show'
19
+ autoload :Status, 'ghi/commands/status'
20
+ autoload :Unassign, 'ghi/commands/unassign'
21
+ autoload :Version, 'ghi/commands/version'
22
+ end
23
+ end
data/lib/ghi/editor.rb ADDED
@@ -0,0 +1,48 @@
1
+ require 'tmpdir'
2
+
3
+ module GHI
4
+ class Editor
5
+ attr_reader :filename
6
+ def initialize filename
7
+ @filename = filename
8
+ end
9
+
10
+ def gets prefill
11
+ File.open path, 'a+' do |f|
12
+ f << prefill if File.zero? path
13
+ f.rewind
14
+ system "#{editor} #{f.path}"
15
+ return File.read(f.path).gsub(/(?:^#.*$\n?)+\s*\z/, '').strip
16
+ end
17
+ end
18
+
19
+ def unlink message = nil
20
+ File.delete path
21
+ abort message if message
22
+ end
23
+
24
+ private
25
+
26
+ def editor
27
+ editor = GHI.config 'ghi.editor'
28
+ editor ||= GHI.config 'core.editor'
29
+ editor ||= ENV['VISUAL']
30
+ editor ||= ENV['EDITOR']
31
+ editor ||= 'vi'
32
+ end
33
+
34
+ def path
35
+ File.join dir, filename
36
+ end
37
+
38
+ def dir
39
+ @dir ||= git_dir || Dir.tmpdir
40
+ end
41
+
42
+ def git_dir
43
+ return unless Commands::Command.detected_repo
44
+ dir = `git rev-parse --git-dir 2>/dev/null`.chomp
45
+ dir unless dir.empty?
46
+ end
47
+ end
48
+ end