├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── ghi ├── ghi ├── ghi.gemspec ├── images └── example.png ├── lib ├── ghi.rb └── ghi │ ├── authorization.rb │ ├── client.rb │ ├── commands.rb │ ├── commands │ ├── assign.rb │ ├── close.rb │ ├── command.rb │ ├── comment.rb │ ├── config.rb │ ├── disable.rb │ ├── edit.rb │ ├── enable.rb │ ├── help.rb │ ├── label.rb │ ├── list.rb │ ├── lock.rb │ ├── milestone.rb │ ├── open.rb │ ├── show.rb │ ├── status.rb │ ├── unlock.rb │ └── version.rb │ ├── editor.rb │ ├── formatting.rb │ ├── formatting │ └── colors.rb │ └── web.rb └── man ├── ghi.1 ├── ghi.1.html └── ghi.1.ronn /.gitignore: -------------------------------------------------------------------------------- 1 | # RubyGems 2 | *.gem 3 | 4 | # Bundler 5 | Gemfile.lock 6 | 7 | .tags -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ## LICENSE 2 | 3 | (The MIT License) 4 | 5 | © 2009–2015 Stephen Celis (). 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | this software and associated documentation files (the "Software"), to deal in 9 | the Software without restriction, including without limitation the rights to 10 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 11 | of the Software, and to permit persons to whom the Software is furnished to do 12 | so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghi 2 | 3 | GitHub Issues on the command line. Use your `$EDITOR`, not your browser. 4 | 5 | `ghi` was originally created by [Stephen Celis](https://github.com/stephencelis), and is now maintained by [drazisil](https://github.com/drazisil)'s fork [here](https://github.com/drazisil/ghi). 6 | 7 | ## Install 8 | 9 | Via brew ([latest stable release](https://github.com/stephencelis/ghi/releases/latest)): 10 | ``` sh 11 | brew install ghi 12 | ``` 13 | 14 | Via gem ([latest stable release](https://github.com/stephencelis/ghi/releases/latest)): 15 | ``` sh 16 | gem install ghi 17 | ``` 18 | 19 | Via curl (latest bleeding-edge versions, may not be stable): 20 | ``` sh 21 | curl -sL https://raw.githubusercontent.com/stephencelis/ghi/master/ghi > ghi && \ 22 | chmod 755 ghi && \ 23 | mv ghi /usr/local/bin 24 | ``` 25 | 26 | ## Usage 27 | 28 | ``` 29 | usage: ghi [--version] [-p|--paginate|--no-pager] [--help] [] 30 | [ -- [/]] 31 | 32 | The most commonly used ghi commands are: 33 | list List your issues (or a repository's) 34 | show Show an issue's details 35 | open Open (or reopen) an issue 36 | close Close an issue 37 | lock Lock an issue's conversation, limiting it to collaborators 38 | unlock Unlock an issue's conversation, opening it to all viewers 39 | edit Modify an existing issue 40 | comment Leave a comment on an issue 41 | label Create, list, modify, or delete labels 42 | assign Assign an issue to yourself (or someone else) 43 | milestone Manage project milestones 44 | status Determine whether or not issues are enabled for this repo 45 | enable Enable issues for the current repo 46 | disable Disable issues for the current repo 47 | 48 | See 'ghi help ' for more information on a specific command. 49 | ``` 50 | 51 | ## Source Tree 52 | You may get a strange error if you use SourceTree, similar to [#275](https://github.com/stephencelis/ghi/issues/275) and [#189](https://github.com/stephencelis/ghi/issues/189). You can follow the steps [here](https://github.com/stephencelis/ghi/issues/275#issuecomment-182895962) to resolve this. 53 | 54 | ## Contributing 55 | 56 | If you're looking for a place to start, there are [issues we need help with](https://github.com/stephencelis/ghi/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)! 57 | 58 | Once you have an idea of what you want to do, there is a section in the [wiki](https://github.com/stephencelis/ghi/wiki/Contributing) to provide more detailed information but the basic steps are as follows. 59 | 60 | 1. Fork this repo 61 | 2. Do your work: 62 | 1. Make your changes 63 | 2. Run `rake build` 64 | 3. Make sure your changes work 65 | 3. Open a pull request! 66 | 67 | ## FAQ 68 | 69 | FAQs can be found in the [wiki](https://github.com/stephencelis/ghi/wiki/FAQ) 70 | 71 | ## Screenshot 72 | 73 | ![Example](images/example.png) 74 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | desc 'Build the standalone script' 2 | task :build do 3 | manifest = %w( 4 | lib/ghi/commands/version.rb 5 | lib/ghi.rb 6 | lib/ghi/formatting/colors.rb 7 | lib/ghi/formatting.rb 8 | lib/ghi/authorization.rb 9 | lib/ghi/client.rb 10 | lib/ghi/editor.rb 11 | lib/ghi/web.rb 12 | lib/ghi/commands.rb 13 | lib/ghi/commands/command.rb 14 | lib/ghi/commands/assign.rb 15 | lib/ghi/commands/close.rb 16 | lib/ghi/commands/comment.rb 17 | lib/ghi/commands/config.rb 18 | lib/ghi/commands/edit.rb 19 | lib/ghi/commands/disable.rb 20 | lib/ghi/commands/enable.rb 21 | lib/ghi/commands/help.rb 22 | lib/ghi/commands/label.rb 23 | lib/ghi/commands/list.rb 24 | lib/ghi/commands/lock.rb 25 | lib/ghi/commands/milestone.rb 26 | lib/ghi/commands/open.rb 27 | lib/ghi/commands/show.rb 28 | lib/ghi/commands/status.rb 29 | lib/ghi/commands/unlock.rb 30 | bin/ghi 31 | ) 32 | files = FileList[*manifest] 33 | File.open 'ghi', 'w' do |f| 34 | f.puts '#!/usr/bin/env ruby' 35 | f.puts '# encoding: utf-8' 36 | files.each { |file| f << File.read(file).gsub(/^\s+autoload.+$\n+/, '') } 37 | f.chmod 0755 38 | end 39 | system './ghi 1>/dev/null' 40 | puts "ghi succesfully built!" 41 | end 42 | 43 | desc 'Build the manuals' 44 | task :man do 45 | `ronn man/*.ronn --manual='GHI Manual' --organization='Stephen Celis'` 46 | end 47 | 48 | desc 'Install the standalone script' 49 | task :install => [:build, :man] do 50 | prefix = ENV['PREFIX'] || ENV['prefix'] || '/usr/local' 51 | 52 | FileUtils.mkdir_p "#{prefix}/bin" 53 | FileUtils.cp 'ghi', "#{prefix}/bin" 54 | 55 | FileUtils.mkdir_p "#{prefix}/share/man/man1" 56 | FileUtils.cp Dir["man/*.1"], "#{prefix}/share/man/man1" 57 | end 58 | -------------------------------------------------------------------------------- /bin/ghi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | autoload :GHI, 'ghi' 4 | GHI.execute ARGV 5 | -------------------------------------------------------------------------------- /ghi.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../lib', __FILE__) 2 | require 'ghi/commands/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'ghi' 6 | s.version = GHI::Commands::Version::VERSION 7 | s.license = 'MIT' 8 | s.summary = 'GitHub Issues command line interface' 9 | s.description = < [] 30 | [ -- [/]] 31 | EOF 32 | opts.on('--version') { command_name = 'version' } 33 | opts.on '-p', '--paginate', '--[no-]pager' do |paginate| 34 | GHI::Formatting.paginate = paginate 35 | end 36 | opts.on '--help' do 37 | command_args.unshift(*args) 38 | command_args.unshift command_name if command_name 39 | args.clear 40 | command_name = 'help' 41 | end 42 | opts.on '--[no-]color' do |colorize| 43 | Formatting::Colors.colorize = colorize 44 | end 45 | opts.on '-l' do 46 | if command_name 47 | raise OptionParser::InvalidOption 48 | else 49 | command_name = 'list' 50 | end 51 | end 52 | opts.on '-v' do 53 | command_name ? self.v = true : command_name = 'version' 54 | end 55 | opts.on('-V') { command_name = 'version' } 56 | end 57 | 58 | begin 59 | option_parser.parse! args 60 | rescue OptionParser::InvalidOption => e 61 | warn e.message.capitalize 62 | abort option_parser.banner 63 | end 64 | 65 | if command_name.nil? 66 | command_name = 'list' 67 | end 68 | 69 | if command_name == 'help' 70 | Commands::Help.execute command_args, option_parser.banner 71 | else 72 | command_name = fetch_alias command_name, command_args 73 | begin 74 | command = Commands.const_get command_name.capitalize 75 | rescue NameError 76 | abort "ghi: '#{command_name}' is not a ghi command. See 'ghi --help'." 77 | end 78 | 79 | # Post-command help option parsing. 80 | Commands::Help.execute [command_name] if command_args.first == '--help' 81 | 82 | begin 83 | command.execute command_args 84 | rescue OptionParser::ParseError, Commands::MissingArgument => e 85 | warn "#{e.message.capitalize}\n" 86 | abort command.new([]).options.to_s 87 | rescue Client::Error => e 88 | if e.response.is_a?(Net::HTTPNotFound) && Authorization.token.nil? 89 | raise Authorization::Required 90 | else 91 | abort e.message 92 | end 93 | rescue SocketError => e 94 | abort "Couldn't find internet." 95 | rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT => e 96 | abort "Couldn't find GitHub." 97 | end 98 | end 99 | rescue Authorization::Required => e 100 | retry if Authorization.authorize! 101 | warn e.message 102 | if Authorization.token 103 | warn <' 111 | EOF 112 | exit 1 113 | end 114 | 115 | def config key, options = {} 116 | upcase = options.fetch :upcase, true 117 | flags = options[:flags] 118 | var = key.gsub('core', 'git').gsub '.', '_' 119 | var.upcase! if upcase 120 | value = ENV[var] || `git config #{flags} #{key}` 121 | value = `#{value[1..-1]}` if value.start_with? '!' 122 | value = value.chomp 123 | value unless value.empty? 124 | end 125 | 126 | attr_accessor :v 127 | alias v? v 128 | 129 | private 130 | 131 | ALIASES = Hash.new { |_, key| 132 | [key] if /^\d+$/ === key 133 | }.update( 134 | 'claim' => %w(assign), 135 | 'create' => %w(open), 136 | 'e' => %w(edit), 137 | 'l' => %w(list), 138 | 'L' => %w(label), 139 | 'm' => %w(comment), 140 | 'M' => %w(milestone), 141 | 'new' => %w(open), 142 | 'o' => %w(open), 143 | 'reopen' => %w(open), 144 | 'rm' => %w(close), 145 | 's' => %w(show), 146 | 'st' => %w(list), 147 | 'tag' => %w(label), 148 | 'unassign' => %w(assign -d), 149 | 'update' => %w(edit) 150 | ) 151 | 152 | def fetch_alias command, args 153 | return command unless fetched = ALIASES[command] 154 | 155 | # If the is an issue number, check the options to see if an 156 | # edit or show is desired. 157 | if fetched.first =~ /^\d+$/ 158 | edit_options = Commands::Edit.new([]).options.top.list 159 | edit_options.reject! { |arg| !arg.is_a?(OptionParser::Switch) } 160 | edit_options.map! { |arg| [arg.short, arg.long] } 161 | edit_options.flatten! 162 | fetched.unshift((edit_options & args).empty? ? 'show' : 'edit') 163 | end 164 | 165 | command = fetched.shift 166 | args.unshift(*fetched) 167 | command 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /lib/ghi/authorization.rb: -------------------------------------------------------------------------------- 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 = </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 143 | -------------------------------------------------------------------------------- /lib/ghi/client.rb: -------------------------------------------------------------------------------- 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.v3+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, proxy.user, proxy.password).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, Net::HTTPTemporaryRedirect 142 | path = URI.parse(res['location']).path 143 | return request method, path, options 144 | end 145 | 146 | raise Error, res 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/ghi/commands.rb: -------------------------------------------------------------------------------- 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 :Lock, 'ghi/commands/lock' 17 | autoload :Milestone, 'ghi/commands/milestone' 18 | autoload :Reopen, 'ghi/commands/reopen' 19 | autoload :Show, 'ghi/commands/show' 20 | autoload :Status, 'ghi/commands/status' 21 | autoload :Unassign, 'ghi/commands/unassign' 22 | autoload :Unlock, 'ghi/commands/unlock' 23 | autoload :Version, 'ghi/commands/version' 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ghi/commands/assign.rb: -------------------------------------------------------------------------------- 1 | module GHI 2 | module Commands 3 | class Assign < Command 4 | def options 5 | OptionParser.new do |opts| 6 | opts.banner = <] 8 | or: ghi assign 9 | or: ghi unassign 10 | EOF 11 | opts.separator '' 12 | opts.on( 13 | '-u', '--assignee ', '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 55 | -------------------------------------------------------------------------------- /lib/ghi/commands/close.rb: -------------------------------------------------------------------------------- 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 = < 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 []', '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 52 | -------------------------------------------------------------------------------- /lib/ghi/commands/command.rb: -------------------------------------------------------------------------------- 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 <<-WARNING 61 | Current directory is not a GitHub repository. Please retry this command from a 62 | GitHub repository or by appending your command with the user/repo: 63 | 64 | #{GHI.current_command} -- [/] 65 | 66 | WARNING 67 | abort options.to_s 68 | end 69 | 70 | def require_repo_name 71 | require_repo 72 | repo_array = repo.partition "/" 73 | if repo_array.length >= 2 74 | repo_name = repo_array[2] 75 | else 76 | repo_name = nil 77 | end 78 | return repo_name 79 | end 80 | 81 | def detect_repo 82 | remote = remotes.find { |r| r[:remote] == 'upstream' } 83 | remote ||= remotes.find { |r| r[:remote] == 'origin' } 84 | remote ||= remotes.find { |r| r[:user] == Authorization.username } 85 | Command.detected_repo = true and remote[:repo] if remote 86 | end 87 | 88 | def remotes 89 | return @remotes if defined? @remotes 90 | @remotes = `git config --get-regexp remote\..+\.url`.split "\n" 91 | github_host = GHI.config('github.host') || 'github.com' 92 | @remotes.reject! { |r| !r.include? github_host} 93 | @remotes.map! { |r| 94 | remote, user, repo = r.scan( 95 | %r{remote\.([^\.]+)\.url .*?([^:/]+)/([^/\s]+?)(?:\.git)?$} 96 | ).flatten 97 | { :remote => remote, :user => user, :repo => "#{user}/#{repo}" } 98 | } 99 | @remotes 100 | end 101 | 102 | def issue 103 | return @issue if defined? @issue 104 | if index = args.index { |arg| /^\d+$/ === arg } 105 | @issue = args.delete_at index 106 | else 107 | infer_issue_from_branch_prefix 108 | end 109 | @issue 110 | end 111 | alias extract_issue issue 112 | alias milestone issue 113 | alias extract_milestone issue 114 | 115 | def infer_issue_from_branch_prefix 116 | @issue = `git symbolic-ref --short HEAD 2>/dev/null`[/^\d+/]; 117 | warn "(Inferring issue from branch prefix: ##@issue)" if @issue 118 | end 119 | 120 | def require_issue 121 | raise MissingArgument, 'Issue required.' unless issue 122 | end 123 | 124 | def require_milestone 125 | raise MissingArgument, 'Milestone required.' unless milestone 126 | end 127 | 128 | # Handles, e.g. `--[no-]milestone []`. 129 | def any_or_none_or input 130 | input ? input : { nil => '*', false => 'none' }[input] 131 | end 132 | 133 | def sort_by_creation(arr) 134 | arr.sort_by { |el| el['created_at'] } 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/ghi/commands/comment.rb: -------------------------------------------------------------------------------- 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 = < 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 []', '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 | filename << ".md" 146 | e = Editor.new filename 147 | message = e.gets format_comment_editor(i, comment) 148 | e.unlink 'No comment.' if message.nil? || message.empty? 149 | if comment && message.strip == comment['body'].strip 150 | e.unlink 'No change.' 151 | end 152 | assigns[:body] = message if message 153 | e 154 | end 155 | 156 | def paged_events(events, comments_res) 157 | if comments_res.next_page 158 | last_comment_creation = comments_res.body.last['created_at'] 159 | events_for_this_page, @events = events.partition do |event| 160 | event['created_at'] < last_comment_creation 161 | end 162 | events_for_this_page 163 | else 164 | events 165 | end 166 | end 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/ghi/commands/config.rb: -------------------------------------------------------------------------------- 1 | module GHI 2 | module Commands 3 | class Config < Command 4 | def options 5 | OptionParser.new do |opts| 6 | opts.banner = <]' 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 58 | -------------------------------------------------------------------------------- /lib/ghi/commands/disable.rb: -------------------------------------------------------------------------------- 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 36 | -------------------------------------------------------------------------------- /lib/ghi/commands/edit.rb: -------------------------------------------------------------------------------- 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 = < [options] 10 | EOF 11 | opts.separator '' 12 | opts.on( 13 | '-m', '--message []', '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 []', '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 ', %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 []', Integer, 'associate with milestone' 34 | ) do |milestone| 35 | assigns[:milestone] = milestone 36 | end 37 | opts.on( 38 | '-L', '--label ...', 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 [[:]]', 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 []', 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}.md" 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 < 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 140 | -------------------------------------------------------------------------------- /lib/ghi/commands/enable.rb: -------------------------------------------------------------------------------- 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 37 | -------------------------------------------------------------------------------- /lib/ghi/commands/help.rb: -------------------------------------------------------------------------------- 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] ' 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 <' for more information on a specific command. 45 | EOF 46 | exit 47 | end 48 | 49 | options.parse! args.empty? ? %w(-m) : args 50 | end 51 | 52 | def all 53 | raise 'TODO' 54 | end 55 | 56 | def man 57 | GHI.execute [command, '-h'] 58 | # TODO: 59 | # exec "man #{['ghi', command].compact.join '-'}" 60 | end 61 | 62 | def web 63 | raise 'TODO' 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/ghi/commands/label.rb: -------------------------------------------------------------------------------- 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 10 | # ghi label rm 11 | # ghi label ... 12 | #++ 13 | def options 14 | OptionParser.new do |opts| 15 | opts.banner = < [-c ] [-r ] 17 | or: ghi label -D 18 | or: ghi label [-a] [-d] [-f]