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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: cf60dfd0d186793f1f58e67aaf6e02c15099eb6a
4
+ data.tar.gz: f159c9cb960dc248c8dcdfef7efe827089257275
5
+ SHA512:
6
+ metadata.gz: 0b0b760af55aec8b781b40932dd08d65122d2c2db8b5a73aabf83dbe82273c80a8c3a8fed1fdcbe24458f682dfa6d67ed55f08024de060d0176902117cf12c0b
7
+ data.tar.gz: 62776a3dfc2adbac46b59eff82181027e57355dd38ff9d79c6a7a732334855a415d61bc9898ebfec187b68f320f7f5525108e1e01ea770204c601439b9624864
data/bin/ghi CHANGED
@@ -1,6 +1,4 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- $: << File.expand_path(File.join(File.dirname(__FILE__), "..", "lib"))
4
- require "ghi/cli"
5
- $stdout.sync = true
6
- GHI::CLI::Executable.new.parse!(ARGV)
3
+ autoload :GHI, 'ghi'
4
+ GHI.execute ARGV
@@ -0,0 +1,142 @@
1
+ # encoding: utf-8
2
+ require 'socket'
3
+
4
+ module GHI
5
+ module Authorization
6
+ extend Formatting
7
+
8
+ class Required < RuntimeError
9
+ def message() 'Authorization required.' end
10
+ end
11
+
12
+ class << self
13
+ def token
14
+ return @token if defined? @token
15
+ @token = GHI.config 'ghi.token'
16
+ end
17
+
18
+ def authorize! user = username, pass = password, local = true
19
+ return false unless user && pass
20
+ code ||= nil # 2fa
21
+ args = code ? [] : [54, "✔\r"]
22
+ note = %w[ghi]
23
+ note << "(#{GHI.repo})" if local
24
+ note << "on #{Socket.gethostname}"
25
+ res = throb(*args) {
26
+ headers = {}
27
+ headers['X-GitHub-OTP'] = code if code
28
+ body = {
29
+ :scopes => %w(public_repo repo),
30
+ :note => note.join(' '),
31
+ :note_url => 'https://github.com/stephencelis/ghi'
32
+ }
33
+ Client.new(user, pass).post(
34
+ '/authorizations', body, :headers => headers
35
+ )
36
+ }
37
+ @token = res.body['token']
38
+
39
+ unless username
40
+ system "git config#{' --global' unless local} github.user #{user}"
41
+ end
42
+
43
+ store_token! user, token, local
44
+ rescue Client::Error => e
45
+ if e.response['X-GitHub-OTP'] =~ /required/
46
+ puts "Bad code." if code
47
+ print "Two-factor authentication code: "
48
+ trap('INT') { abort }
49
+ code = gets
50
+ code = '' and puts "\n" unless code
51
+ retry
52
+ end
53
+
54
+ if e.errors.any? { |err| err['code'] == 'already_exists' }
55
+ host = GHI.config('github.host') || 'github.com'
56
+ message = <<EOF.chomp
57
+ A ghi token already exists!
58
+
59
+ Please revoke all previously-generated ghi personal access tokens here:
60
+
61
+ https://#{host}/settings/tokens
62
+ EOF
63
+ else
64
+ message = e.message
65
+ end
66
+ abort "#{message}#{CURSOR[:column][0]}"
67
+ end
68
+
69
+ def username
70
+ return @username if defined? @username
71
+ @username = GHI.config 'github.user'
72
+ end
73
+
74
+ def password
75
+ return @password if defined? @password
76
+ @password = GHI.config 'github.password'
77
+ end
78
+
79
+ private
80
+
81
+ def store_token! username, token, local
82
+ if security
83
+ run = []
84
+
85
+ run << security('delete', username)
86
+ run << security('add', username, token)
87
+
88
+ find = security 'find', username, false
89
+ run << %(git config#{' --global' unless local} ghi.token "!#{find}")
90
+
91
+ system run.join ' ; '
92
+
93
+ puts "✔︎ Token saved to keychain."
94
+ return
95
+ end
96
+
97
+ command = "git config#{' --global' unless local} ghi.token #{token}"
98
+ system command
99
+
100
+ unless local
101
+ at_exit do
102
+ warn <<EOF
103
+ Your ~/.gitconfig has been modified by way of:
104
+
105
+ #{command}
106
+
107
+ #{bright { blink { 'Do not check this change into public source control!' } }}
108
+
109
+ You can increase security by storing the token in a secure place that can be
110
+ fetched from the command line. E.g., on OS X:
111
+
112
+ git config --global ghi.token \\
113
+ "!security -a #{username} -s github.com -l 'ghi token' -w"
114
+
115
+ Alternatively, set the following env var in a private dotfile:
116
+
117
+ export GHI_TOKEN="#{token}"
118
+ EOF
119
+ end
120
+ end
121
+ end
122
+
123
+ def security command = nil, username = nil, password = nil
124
+ if command.nil? && username.nil? && password.nil?
125
+ return system 'which security >/dev/null'
126
+ end
127
+
128
+ run = [
129
+ 'security',
130
+ "#{command}-internet-password",
131
+ "-a #{username}",
132
+ '-s github.com',
133
+ "-l 'ghi token'"
134
+ ]
135
+ run << %(-w#{" #{password}" if password}) unless password.nil?
136
+ run << '>/dev/null 2>&1' unless command == 'find'
137
+
138
+ run.join ' '
139
+ end
140
+ end
141
+ end
142
+ end
data/lib/ghi/client.rb ADDED
@@ -0,0 +1,148 @@
1
+ require 'cgi'
2
+ require 'net/https'
3
+ require 'json'
4
+
5
+ unless defined? Net::HTTP::Patch
6
+ # PATCH support for 1.8.7.
7
+ Net::HTTP::Patch = Class.new(Net::HTTP::Post) { METHOD = 'PATCH' }
8
+ end
9
+
10
+ module GHI
11
+ class Client
12
+
13
+ class Error < RuntimeError
14
+ attr_reader :response
15
+ def initialize response
16
+ @response, @json = response, JSON.parse(response.body)
17
+ end
18
+
19
+ def body() @json end
20
+ def message() body['message'] end
21
+ def errors() [*body['errors']] end
22
+ end
23
+
24
+ class Response
25
+ def initialize response
26
+ @response = response
27
+ end
28
+
29
+ def body
30
+ @body ||= JSON.parse @response.body
31
+ end
32
+
33
+ def next_page() links['next'] end
34
+ def last_page() links['last'] end
35
+
36
+ private
37
+
38
+ def links
39
+ return @links if defined? @links
40
+ @links = {}
41
+ if links = @response['Link']
42
+ links.scan(/<([^>]+)>; rel="([^"]+)"/).each { |l, r| @links[r] = l }
43
+ end
44
+ @links
45
+ end
46
+ end
47
+
48
+ CONTENT_TYPE = 'application/vnd.github.beta+json'
49
+ USER_AGENT = 'ghi/%s (%s; +%s)' % [
50
+ GHI::Commands::Version::VERSION,
51
+ RUBY_DESCRIPTION,
52
+ 'https://github.com/stephencelis/ghi'
53
+ ]
54
+ METHODS = {
55
+ :head => Net::HTTP::Head,
56
+ :get => Net::HTTP::Get,
57
+ :post => Net::HTTP::Post,
58
+ :put => Net::HTTP::Put,
59
+ :patch => Net::HTTP::Patch,
60
+ :delete => Net::HTTP::Delete
61
+ }
62
+ DEFAULT_HOST = 'api.github.com'
63
+ HOST = GHI.config('github.host') || DEFAULT_HOST
64
+ PORT = 443
65
+
66
+ attr_reader :username, :password
67
+ def initialize username = nil, password = nil
68
+ @username, @password = username, password
69
+ end
70
+
71
+ def head path, options = {}
72
+ request :head, path, options
73
+ end
74
+
75
+ def get path, params = {}, options = {}
76
+ request :get, path, options.merge(:params => params)
77
+ end
78
+
79
+ def post path, body = nil, options = {}
80
+ request :post, path, options.merge(:body => body)
81
+ end
82
+
83
+ def put path, body = nil, options = {}
84
+ request :put, path, options.merge(:body => body)
85
+ end
86
+
87
+ def patch path, body = nil, options = {}
88
+ request :patch, path, options.merge(:body => body)
89
+ end
90
+
91
+ def delete path, options = {}
92
+ request :delete, path, options
93
+ end
94
+
95
+ private
96
+
97
+ def request method, path, options
98
+ path = "/api/v3#{path}" if HOST != DEFAULT_HOST
99
+
100
+ path = URI.escape path
101
+ if params = options[:params] and !params.empty?
102
+ q = params.map { |k, v| "#{CGI.escape k.to_s}=#{CGI.escape v.to_s}" }
103
+ path += "?#{q.join '&'}"
104
+ end
105
+
106
+ headers = options.fetch :headers, {}
107
+ headers.update 'Accept' => CONTENT_TYPE, 'User-Agent' => USER_AGENT
108
+ req = METHODS[method].new path, headers
109
+ if GHI::Authorization.token
110
+ req['Authorization'] = "token #{GHI::Authorization.token}"
111
+ end
112
+ if options.key? :body
113
+ req['Content-Type'] = CONTENT_TYPE
114
+ req.body = options[:body] ? JSON.dump(options[:body]) : ''
115
+ end
116
+ req.basic_auth username, password if username && password
117
+
118
+ proxy = GHI.config 'https.proxy', :upcase => false
119
+ proxy ||= GHI.config 'http.proxy', :upcase => false
120
+ if proxy
121
+ proxy = URI.parse proxy
122
+ http = Net::HTTP::Proxy(proxy.host, proxy.port).new HOST, PORT
123
+ else
124
+ http = Net::HTTP.new HOST, PORT
125
+ end
126
+
127
+ http.use_ssl = true
128
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE # FIXME 1.8.7
129
+
130
+ GHI.v? and puts "\r===> #{method.to_s.upcase} #{path} #{req.body}"
131
+ res = http.start { http.request req }
132
+ GHI.v? and puts "\r<=== #{res.code}: #{res.body}"
133
+
134
+ case res
135
+ when Net::HTTPSuccess
136
+ return Response.new(res)
137
+ when Net::HTTPUnauthorized
138
+ if password.nil?
139
+ raise Authorization::Required, 'Authorization required'
140
+ end
141
+ when Net::HTTPMovedPermanently
142
+ return Response.new(http.get(res['location']))
143
+ end
144
+
145
+ raise Error, res
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,54 @@
1
+ module GHI
2
+ module Commands
3
+ class Assign < Command
4
+ def options
5
+ OptionParser.new do |opts|
6
+ opts.banner = <<EOF
7
+ usage: ghi assign [options] [<issueno>]
8
+ or: ghi assign <issueno> <user>
9
+ or: ghi unassign <issueno>
10
+ EOF
11
+ opts.separator ''
12
+ opts.on(
13
+ '-u', '--assignee <user>', 'assign to specified user'
14
+ ) do |assignee|
15
+ assigns[:assignee] = assignee
16
+ end
17
+ opts.on '-d', '--no-assignee', 'unassign this issue' do
18
+ assigns[:assignee] = nil
19
+ end
20
+ opts.on '-l', '--list', 'list assigned issues' do
21
+ self.action = 'list'
22
+ end
23
+ opts.separator ''
24
+ end
25
+ end
26
+
27
+ def execute
28
+ self.action = 'edit'
29
+ assigns[:args] = []
30
+
31
+ require_repo
32
+ extract_issue
33
+ options.parse! args
34
+
35
+ unless assigns.key? :assignee
36
+ assigns[:assignee] = args.pop || Authorization.username
37
+ end
38
+ if assigns.key? :assignee
39
+ assigns[:assignee].sub! /^@/, '' if assigns[:assignee]
40
+ assigns[:args].concat(
41
+ assigns[:assignee] ? %W(-u #{assigns[:assignee]}) : %w(--no-assign)
42
+ )
43
+ end
44
+ assigns[:args] << issue if issue
45
+ assigns[:args].concat %W(-- #{repo})
46
+
47
+ case action
48
+ when 'list' then List.execute assigns[:args]
49
+ when 'edit' then Edit.execute assigns[:args]
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,51 @@
1
+ module GHI
2
+ module Commands
3
+ class Close < Command
4
+ attr_accessor :web
5
+
6
+ def options
7
+ OptionParser.new do |opts|
8
+ opts.banner = <<EOF
9
+ usage: ghi close [options] <issueno>
10
+ EOF
11
+ opts.separator ''
12
+ opts.on '-l', '--list', 'list closed issues' do
13
+ assigns[:command] = List
14
+ end
15
+ opts.on('-w', '--web') { self.web = true }
16
+ opts.separator ''
17
+ opts.separator 'Issue modification options'
18
+ opts.on '-m', '--message [<text>]', 'close with message' do |text|
19
+ assigns[:comment] = text
20
+ end
21
+ opts.separator ''
22
+ end
23
+ end
24
+
25
+ def execute
26
+ options.parse! args
27
+ require_repo
28
+
29
+ if list?
30
+ args.unshift(*%W(-sc -- #{repo}))
31
+ args.unshift '-w' if web
32
+ List.execute args
33
+ else
34
+ require_issue
35
+ if assigns.key? :comment
36
+ Comment.execute [
37
+ issue, '-m', assigns[:comment], '--', repo
38
+ ].compact
39
+ end
40
+ Edit.execute %W(-sc #{issue} -- #{repo})
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def list?
47
+ assigns[:command] == List
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,133 @@
1
+ module GHI
2
+ module Commands
3
+ class MissingArgument < RuntimeError
4
+ end
5
+
6
+ class Command
7
+ include Formatting
8
+
9
+ class << self
10
+ attr_accessor :detected_repo
11
+
12
+ def execute args
13
+ command = new args
14
+ if i = args.index('--')
15
+ command.repo = args.slice!(i, args.length)[1] # Raise if too many?
16
+ end
17
+ command.execute
18
+ end
19
+ end
20
+
21
+ attr_reader :args
22
+ attr_writer :issue
23
+ attr_accessor :action
24
+ attr_accessor :verbose
25
+
26
+ def initialize args
27
+ @args = args.map! { |a| a.dup }
28
+ end
29
+
30
+ def assigns
31
+ @assigns ||= {}
32
+ end
33
+
34
+ def api
35
+ @api ||= Client.new
36
+ end
37
+
38
+ def repo
39
+ return @repo if defined? @repo
40
+ @repo = GHI.config('ghi.repo', :flags => '--local') || detect_repo
41
+ if @repo && !@repo.include?('/')
42
+ @repo = [Authorization.username, @repo].join '/'
43
+ end
44
+ @repo
45
+ end
46
+ alias extract_repo repo
47
+
48
+ def repo= repo
49
+ @repo = repo.dup
50
+ unless @repo.include? '/'
51
+ @repo.insert 0, "#{Authorization.username}/"
52
+ end
53
+ @repo
54
+ end
55
+
56
+ private
57
+
58
+ def require_repo
59
+ return true if repo
60
+ warn 'Not a GitHub repo.'
61
+ warn ''
62
+ abort options.to_s
63
+ end
64
+
65
+ def require_repo_name
66
+ require_repo
67
+ repo_array = repo.partition "/"
68
+ if repo_array.length >= 2
69
+ repo_name = repo_array[2]
70
+ else
71
+ repo_name = nil
72
+ end
73
+ return repo_name
74
+ end
75
+
76
+ def detect_repo
77
+ remote = remotes.find { |r| r[:remote] == 'upstream' }
78
+ remote ||= remotes.find { |r| r[:remote] == 'origin' }
79
+ remote ||= remotes.find { |r| r[:user] == Authorization.username }
80
+ Command.detected_repo = true and remote[:repo] if remote
81
+ end
82
+
83
+ def remotes
84
+ return @remotes if defined? @remotes
85
+ @remotes = `git config --get-regexp remote\..+\.url`.split "\n"
86
+ github_host = GHI.config('github.host') || 'github.com'
87
+ @remotes.reject! { |r| !r.include? github_host}
88
+ @remotes.map! { |r|
89
+ remote, user, repo = r.scan(
90
+ %r{remote\.([^\.]+)\.url .*?([^:/]+)/([^/\s]+?)(?:\.git)?$}
91
+ ).flatten
92
+ { :remote => remote, :user => user, :repo => "#{user}/#{repo}" }
93
+ }
94
+ @remotes
95
+ end
96
+
97
+ def issue
98
+ return @issue if defined? @issue
99
+ if index = args.index { |arg| /^\d+$/ === arg }
100
+ @issue = args.delete_at index
101
+ else
102
+ infer_issue_from_branch_prefix
103
+ end
104
+ @issue
105
+ end
106
+ alias extract_issue issue
107
+ alias milestone issue
108
+ alias extract_milestone issue
109
+
110
+ def infer_issue_from_branch_prefix
111
+ @issue = `git symbolic-ref --short HEAD 2>/dev/null`[/^\d+/];
112
+ warn "(Inferring issue from branch prefix: ##@issue)" if @issue
113
+ end
114
+
115
+ def require_issue
116
+ raise MissingArgument, 'Issue required.' unless issue
117
+ end
118
+
119
+ def require_milestone
120
+ raise MissingArgument, 'Milestone required.' unless milestone
121
+ end
122
+
123
+ # Handles, e.g. `--[no-]milestone [<n>]`.
124
+ def any_or_none_or input
125
+ input ? input : { nil => '*', false => 'none' }[input]
126
+ end
127
+
128
+ def sort_by_creation(arr)
129
+ arr.sort_by { |el| el['created_at'] }
130
+ end
131
+ end
132
+ end
133
+ end