pax_global_header00006660000000000000000000000064127056731260014523gustar00rootroot0000000000000052 comment=b3abe43a0d62a50cadc825a12bd1b2e09e8bb059 ghi-1.2.0/000077500000000000000000000000001270567312600122725ustar00rootroot00000000000000ghi-1.2.0/.gitignore000066400000000000000000000000571270567312600142640ustar00rootroot00000000000000# RubyGems *.gem # Bundler Gemfile.lock .tagsghi-1.2.0/Gemfile000066400000000000000000000000461270567312600135650ustar00rootroot00000000000000source 'https://rubygems.org' gemspec ghi-1.2.0/LICENSE000066400000000000000000000021311270567312600132740ustar00rootroot00000000000000## LICENSE (The MIT License) © 2009–2015 Stephen Celis (). Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.ghi-1.2.0/README.md000066400000000000000000000046601270567312600135570ustar00rootroot00000000000000# ghi GitHub Issues on the command line. Use your `$EDITOR`, not your browser. `ghi` was originally created by [Stephen Celis](https://github.com/stephencelis), and is now maintained by [Alex Chesters](https://github.com/alexchesters). ## Install Via brew ([latest stable release](https://github.com/stephencelis/ghi/releases/latest)): ``` sh brew install ghi ``` Via gem ([latest stable release](https://github.com/stephencelis/ghi/releases/latest)): ``` sh gem install ghi ``` Via curl (latest bleeding-edge versions, may not be stable): ``` sh curl -sL https://raw.githubusercontent.com/stephencelis/ghi/master/ghi > ghi && \ chmod 755 ghi && \ mv ghi /usr/local/bin ``` ## Usage ``` usage: ghi [--version] [-p|--paginate|--no-pager] [--help] [] [ -- [/]] The most commonly used ghi commands are: list List your issues (or a repository's) show Show an issue's details open Open (or reopen) an issue close Close an issue edit Modify an existing issue comment Leave a comment on an issue label Create, list, modify, or delete labels assign Assign an issue to yourself (or someone else) milestone Manage project milestones status Determine whether or not issues are enabled for this repo enable Enable issues for the current repo disable Disable issues for the current repo See 'ghi help ' for more information on a specific command. ``` ## Source Tree 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. ## Contributing 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)! 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. 1. Fork this repo 2. Do your work: 1. Make your changes 2. Run `rake build` 3. Make sure your changes work 3. Open a pull request! ## FAQ FAQs can be found in the [wiki](https://github.com/stephencelis/ghi/wiki/FAQ) ## Screenshot ![Example](images/example.png) ghi-1.2.0/Rakefile000066400000000000000000000027171270567312600137460ustar00rootroot00000000000000desc 'Build the standalone script' task :build do manifest = %w( lib/ghi/commands/version.rb lib/ghi.rb lib/ghi/formatting/colors.rb lib/ghi/formatting.rb lib/ghi/authorization.rb lib/ghi/client.rb lib/ghi/editor.rb lib/ghi/web.rb lib/ghi/commands.rb lib/ghi/commands/command.rb lib/ghi/commands/assign.rb lib/ghi/commands/close.rb lib/ghi/commands/comment.rb lib/ghi/commands/config.rb lib/ghi/commands/edit.rb lib/ghi/commands/disable.rb lib/ghi/commands/enable.rb lib/ghi/commands/help.rb lib/ghi/commands/label.rb lib/ghi/commands/list.rb lib/ghi/commands/milestone.rb lib/ghi/commands/open.rb lib/ghi/commands/show.rb lib/ghi/commands/status.rb bin/ghi ) files = FileList[*manifest] File.open 'ghi', 'w' do |f| f.puts '#!/usr/bin/env ruby' f.puts '# encoding: utf-8' files.each { |file| f << File.read(file).gsub(/^\s+autoload.+$\n+/, '') } f.chmod 0755 end system './ghi 1>/dev/null' puts "ghi succesfully built!" end desc 'Build the manuals' task :man do `ronn man/*.ronn --manual='GHI Manual' --organization='Stephen Celis'` end desc 'Install the standalone script' task :install => [:build, :man] do prefix = ENV['PREFIX'] || ENV['prefix'] || '/usr/local' FileUtils.mkdir_p "#{prefix}/bin" FileUtils.cp 'ghi', "#{prefix}/bin" FileUtils.mkdir_p "#{prefix}/share/man/man1" FileUtils.cp Dir["man/*.1"], "#{prefix}/share/man/man1" end ghi-1.2.0/bin/000077500000000000000000000000001270567312600130425ustar00rootroot00000000000000ghi-1.2.0/bin/ghi000077500000000000000000000000731270567312600135370ustar00rootroot00000000000000#!/usr/bin/env ruby autoload :GHI, 'ghi' GHI.execute ARGV ghi-1.2.0/ghi000077500000000000000000002512361270567312600130000ustar00rootroot00000000000000#!/usr/bin/env ruby # encoding: utf-8 module GHI module Commands module Version MAJOR = 1 MINOR = 2 PATCH = 0 PRE = nil VERSION = [MAJOR, MINOR, PATCH, PRE].compact.join '.' def self.execute args puts "ghi version #{VERSION}" end end end end require 'optparse' module GHI class << self def execute args STDOUT.sync = true double_dash = args.index { |arg| arg == '--' } if index = args.index { |arg| arg !~ /^-/ } if double_dash.nil? || index < double_dash command_name = args.delete_at index command_args = args.slice! index, args.length end end command_args ||= [] option_parser = OptionParser.new do |opts| opts.banner = < [] [ -- [/]] EOF opts.on('--version') { command_name = 'version' } opts.on '-p', '--paginate', '--[no-]pager' do |paginate| GHI::Formatting.paginate = paginate end opts.on '--help' do command_args.unshift(*args) command_args.unshift command_name if command_name args.clear command_name = 'help' end opts.on '--[no-]color' do |colorize| Formatting::Colors.colorize = colorize end opts.on '-l' do if command_name raise OptionParser::InvalidOption else command_name = 'list' end end opts.on '-v' do command_name ? self.v = true : command_name = 'version' end opts.on('-V') { command_name = 'version' } end begin option_parser.parse! args rescue OptionParser::InvalidOption => e warn e.message.capitalize abort option_parser.banner end if command_name.nil? command_name = 'list' end if command_name == 'help' Commands::Help.execute command_args, option_parser.banner else command_name = fetch_alias command_name, command_args begin command = Commands.const_get command_name.capitalize rescue NameError abort "ghi: '#{command_name}' is not a ghi command. See 'ghi --help'." end # Post-command help option parsing. Commands::Help.execute [command_name] if command_args.first == '--help' begin command.execute command_args rescue OptionParser::ParseError, Commands::MissingArgument => e warn "#{e.message.capitalize}\n" abort command.new([]).options.to_s rescue Client::Error => e if e.response.is_a?(Net::HTTPNotFound) && Authorization.token.nil? raise Authorization::Required else abort e.message end rescue SocketError => e abort "Couldn't find internet." rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT => e abort "Couldn't find GitHub." end end rescue Authorization::Required => e retry if Authorization.authorize! warn e.message if Authorization.token warn <' EOF exit 1 end def config key, options = {} upcase = options.fetch :upcase, true flags = options[:flags] var = key.gsub('core', 'git').gsub '.', '_' var.upcase! if upcase value = ENV[var] || `git config #{flags} #{key}` value = `#{value[1..-1]}` if value.start_with? '!' value = value.chomp value unless value.empty? end attr_accessor :v alias v? v private ALIASES = Hash.new { |_, key| [key] if /^\d+$/ === key }.update( 'claim' => %w(assign), 'create' => %w(open), 'e' => %w(edit), 'l' => %w(list), 'L' => %w(label), 'm' => %w(comment), 'M' => %w(milestone), 'new' => %w(open), 'o' => %w(open), 'reopen' => %w(open), 'rm' => %w(close), 's' => %w(show), 'st' => %w(list), 'tag' => %w(label), 'unassign' => %w(assign -d), 'update' => %w(edit) ) def fetch_alias command, args return command unless fetched = ALIASES[command] # If the is an issue number, check the options to see if an # edit or show is desired. if fetched.first =~ /^\d+$/ edit_options = Commands::Edit.new([]).options.top.list edit_options.reject! { |arg| !arg.is_a?(OptionParser::Switch) } edit_options.map! { |arg| [arg.short, arg.long] } edit_options.flatten! fetched.unshift((edit_options & args).empty? ? 'show' : 'edit') end command = fetched.shift args.unshift(*fetched) command end end end module GHI module Formatting module Colors class << self attr_accessor :colorize def colorize? return @colorize if defined? @colorize @colorize = STDOUT.tty? end end def colorize? Colors.colorize? end def fg color, &block escape color, 3, &block end def bg color, &block fg(offset(color)) { escape color, 4, &block } end def bright &block escape :bright, &block end def underline &block escape :underline, &block end def blink &block escape :blink, &block end def inverse &block escape :inverse, &block end def highlight(code_block) return code_block unless colorize? highlighter.highlight(code_block) end def no_color old_colorize, Colors.colorize = colorize?, false yield ensure Colors.colorize = old_colorize end def to_hex string WEB[string] || string.downcase.sub(/^(#|0x)/, ''). sub(/^([0-f])([0-f])([0-f])$/, '\1\1\2\2\3\3') end ANSI = { :bright => 1, :underline => 4, :blink => 5, :inverse => 7, :black => 0, :red => 1, :green => 2, :yellow => 3, :blue => 4, :magenta => 5, :cyan => 6, :white => 7 } WEB = { 'aliceblue' => 'f0f8ff', 'antiquewhite' => 'faebd7', 'aqua' => '00ffff', 'aquamarine' => '7fffd4', 'azure' => 'f0ffff', 'beige' => 'f5f5dc', 'bisque' => 'ffe4c4', 'black' => '000000', 'blanchedalmond' => 'ffebcd', 'blue' => '0000ff', 'blueviolet' => '8a2be2', 'brown' => 'a52a2a', 'burlywood' => 'deb887', 'cadetblue' => '5f9ea0', 'chartreuse' => '7fff00', 'chocolate' => 'd2691e', 'coral' => 'ff7f50', 'cornflowerblue' => '6495ed', 'cornsilk' => 'fff8dc', 'crimson' => 'dc143c', 'cyan' => '00ffff', 'darkblue' => '00008b', 'darkcyan' => '008b8b', 'darkgoldenrod' => 'b8860b', 'darkgray' => 'a9a9a9', 'darkgrey' => 'a9a9a9', 'darkgreen' => '006400', 'darkkhaki' => 'bdb76b', 'darkmagenta' => '8b008b', 'darkolivegreen' => '556b2f', 'darkorange' => 'ff8c00', 'darkorchid' => '9932cc', 'darkred' => '8b0000', 'darksalmon' => 'e9967a', 'darkseagreen' => '8fbc8f', 'darkslateblue' => '483d8b', 'darkslategray' => '2f4f4f', 'darkslategrey' => '2f4f4f', 'darkturquoise' => '00ced1', 'darkviolet' => '9400d3', 'deeppink' => 'ff1493', 'deepskyblue' => '00bfff', 'dimgray' => '696969', 'dimgrey' => '696969', 'dodgerblue' => '1e90ff', 'firebrick' => 'b22222', 'floralwhite' => 'fffaf0', 'forestgreen' => '228b22', 'fuchsia' => 'ff00ff', 'gainsboro' => 'dcdcdc', 'ghostwhite' => 'f8f8ff', 'gold' => 'ffd700', 'goldenrod' => 'daa520', 'gray' => '808080', 'green' => '008000', 'greenyellow' => 'adff2f', 'honeydew' => 'f0fff0', 'hotpink' => 'ff69b4', 'indianred' => 'cd5c5c', 'indigo' => '4b0082', 'ivory' => 'fffff0', 'khaki' => 'f0e68c', 'lavender' => 'e6e6fa', 'lavenderblush' => 'fff0f5', 'lawngreen' => '7cfc00', 'lemonchiffon' => 'fffacd', 'lightblue' => 'add8e6', 'lightcoral' => 'f08080', 'lightcyan' => 'e0ffff', 'lightgoldenrodyellow' => 'fafad2', 'lightgreen' => '90ee90', 'lightgray' => 'd3d3d3', 'lightgrey' => 'd3d3d3', 'lightpink' => 'ffb6c1', 'lightsalmon' => 'ffa07a', 'lightseagreen' => '20b2aa', 'lightskyblue' => '87cefa', 'lightslategray' => '778899', 'lightslategrey' => '778899', 'lightsteelblue' => 'b0c4de', 'lightyellow' => 'ffffe0', 'lime' => '00ff00', 'limegreen' => '32cd32', 'linen' => 'faf0e6', 'magenta' => 'ff00ff', 'maroon' => '800000', 'mediumaquamarine' => '66cdaa', 'mediumblue' => '0000cd', 'mediumorchid' => 'ba55d3', 'mediumpurple' => '9370db', 'mediumseagreen' => '3cb371', 'mediumslateblue' => '7b68ee', 'mediumspringgreen' => '00fa9a', 'mediumturquoise' => '48d1cc', 'mediumvioletred' => 'c71585', 'midnightblue' => '191970', 'mintcream' => 'f5fffa', 'mistyrose' => 'ffe4e1', 'moccasin' => 'ffe4b5', 'navajowhite' => 'ffdead', 'navy' => '000080', 'oldlace' => 'fdf5e6', 'olive' => '808000', 'olivedrab' => '6b8e23', 'orange' => 'ffa500', 'orangered' => 'ff4500', 'orchid' => 'da70d6', 'palegoldenrod' => 'eee8aa', 'palegreen' => '98fb98', 'paleturquoise' => 'afeeee', 'palevioletred' => 'db7093', 'papayawhip' => 'ffefd5', 'peachpuff' => 'ffdab9', 'peru' => 'cd853f', 'pink' => 'ffc0cb', 'plum' => 'dda0dd', 'powderblue' => 'b0e0e6', 'purple' => '800080', 'red' => 'ff0000', 'rosybrown' => 'bc8f8f', 'royalblue' => '4169e1', 'saddlebrown' => '8b4513', 'salmon' => 'fa8072', 'sandybrown' => 'f4a460', 'seagreen' => '2e8b57', 'seashell' => 'fff5ee', 'sienna' => 'a0522d', 'silver' => 'c0c0c0', 'skyblue' => '87ceeb', 'slateblue' => '6a5acd', 'slategray' => '708090', 'slategrey' => '708090', 'snow' => 'fffafa', 'springgreen' => '00ff7f', 'steelblue' => '4682b4', 'tan' => 'd2b48c', 'teal' => '008080', 'thistle' => 'd8bfd8', 'tomato' => 'ff6347', 'turquoise' => '40e0d0', 'violet' => 'ee82ee', 'wheat' => 'f5deb3', 'white' => 'ffffff', 'whitesmoke' => 'f5f5f5', 'yellow' => 'ffff00', 'yellowgreen' => '9acd32' } private def escape color = :black, layer = nil return yield unless color && colorize? previous_escape = Thread.current[:escape] || "\e[0m" escape = Thread.current[:escape] = "\e[%s%sm" % [ layer, ANSI[color] || escape_256(color) ] [escape, yield, previous_escape].join ensure Thread.current[:escape] = previous_escape end def escape_256 color "8;5;#{to_256(*to_rgb(color))}" if supports_256_colors? end def supports_256_colors? `tput colors` =~ /256/ end def to_256 r, g, b r, g, b = [r, g, b].map { |c| c / 10 } return 232 + g if r == g && g == b && g != 0 && g != 25 16 + ((r / 5) * 36) + ((g / 5) * 6) + (b / 5) end def to_rgb hex n = (WEB[hex.to_s] || hex).to_i(16) [2, 1, 0].map { |m| n >> (m << 3) & 0xff } end def offset hex h, s, l = rgb_to_hsl(to_rgb(WEB[hex.to_s] || hex)) l < 55 && !(40..80).include?(h) ? l *= 1.875 : l /= 3 hsl_to_rgb([h, s, l]).map { |c| '%02x' % c }.join end def rgb_to_hsl rgb r, g, b = rgb.map { |c| c / 255.0 } max = [r, g, b].max min = [r, g, b].min d = max - min h = case max when min then 0 when r then 60 * (g - b) / d when g then 60 * (b - r) / d + 120 when b then 60 * (r - g) / d + 240 end l = (max + min) / 2.0 s = if max == min then 0 elsif l < 0.5 then d / (2 * l) else d / (2 - 2 * l) end [h % 360, s * 100, l * 100] end def hsl_to_rgb hsl h, s, l = hsl h /= 360.0 s /= 100.0 l /= 100.0 m2 = l <= 0.5 ? l * (s + 1) : l + s - l * s m1 = l * 2 - m2 rgb = [[m1, m2, h + 1.0 / 3], [m1, m2, h], [m1, m2, h - 1.0 / 3]] rgb.map { |c| m1, m2, h = c h += 1 if h < 0 h -= 1 if h > 1 next m1 + (m2 - m1) * h * 6 if h * 6 < 1 next m2 if h * 2 < 1 next m1 + (m2 - m1) * (2.0/3 - h) * 6 if h * 3 < 2 m1 }.map { |c| c * 255 } end def hue_to_rgb m1, m2, h h += 1 if h < 0 h -= 1 if h > 1 return m1 + (m2 - m1) * h * 6 if h * 6 < 1 return m2 if h * 2 < 1 return m1 + (m2 - m1) * (2.0/3 - h) * 6 if h * 3 < 2 return m1 end def highlighter @highlighter ||= begin raise unless supports_256_colors? require 'pygments' Pygmentizer.new rescue StandardError, LoadError FakePygmentizer.new end end class FakePygmentizer def highlight(code_block) code_block end end class Pygmentizer def initialize @style = GHI.config('ghi.highlight.style') || 'monokai' end def highlight(code_block) begin indent = code_block['indent'] lang = code_block['lang'] code = code_block['code'] if lang != "" output = pygmentize(lang, code) else output = code end with_indentation(output, indent) rescue code_block end end private def pygmentize(lang, code) Pygments.highlight(unescape(code), :formatter => '256', :lexer => lang, :options => { :style => @style }) end def unescape(str) str.gsub(/\e\[[^m]*m/, '') end def with_indentation(string, indent) string.each_line.map do |line| "#{indent}#{line}" end.join end end end end end # encoding: utf-8 require 'date' require 'erb' module GHI module Formatting class << self attr_accessor :paginate end self.paginate = true # Default. attr_accessor :paging include Colors CURSOR = { :up => lambda { |n| "\e[#{n}A" }, :column => lambda { |n| "\e[#{n}G" }, :hide => "\e[?25l", :show => "\e[?25h" } THROBBERS = [ %w(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏), %w(⠋ ⠙ ⠚ ⠞ ⠖ ⠦ ⠴ ⠲ ⠳ ⠓), %w(⠄ ⠆ ⠇ ⠋ ⠙ ⠸ ⠰ ⠠ ⠰ ⠸ ⠙ ⠋ ⠇ ⠆ ), %w(⠋ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋), %w(⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠴ ⠲ ⠒ ⠂ ⠂ ⠒ ⠚ ⠙ ⠉ ⠁), %w(⠈ ⠉ ⠋ ⠓ ⠒ ⠐ ⠐ ⠒ ⠖ ⠦ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈), %w(⠁ ⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈ ⠈ ⠉) ] def puts *strings strings = strings.flatten.map { |s| s.gsub(/(^| *)@(\w+)/) { if $2 == Authorization.username bright { fg(:yellow) { "#$1@#$2" } } else bright { "#$1@#$2" } end } } super strings end def page header = nil, throttle = 0 if paginate? pager = GHI.config('ghi.pager') || GHI.config('core.pager') pager ||= ENV['PAGER'] pager ||= 'less' pager += ' -EKRX -b1' if pager =~ /^less( -[EKRX]+)?$/ if pager && !pager.empty? && pager != 'cat' $stdout = IO.popen pager, 'w' end puts header if header self.paging = true end loop do yield sleep throttle end rescue Errno::EPIPE exit ensure unless $stdout == STDOUT $stdout.close_write $stdout = STDOUT print CURSOR[:show] exit end end def paginate? ($stdout.tty? && $stdout == STDOUT && Formatting.paginate) || paging? end def paging? !!paging end def truncate string, reserved return string unless paginate? space=columns - reserved space=5 if space < 5 result = string.scan(/.{0,#{space}}(?:\s|\Z)/).first.strip result << "..." if result != string result end def indent string, level = 4, maxwidth = columns string = string.gsub(/\r/, '') string.gsub!(/[\t ]+$/, '') string.gsub!(/\n{3,}/, "\n\n") width = maxwidth - level - 1 lines = string.scan( /.{0,#{width}}(?:\s|\Z)|[\S]{#{width},}/ # TODO: Test long lines. ).map { |line| " " * level + line.chomp } format_markdown lines.join("\n").rstrip, level end def columns dimensions[1] || 80 end def dimensions `stty size 2>/dev/null`.chomp.split(' ').map { |n| n.to_i } end #-- # Specific formatters: #++ def format_username username username == Authorization.username ? 'you' : username end def format_issues_header state = assigns[:state] ||= 'open' org = assigns[:org] ||= nil header = "# #{repo || org || 'Global,'} #{state} issues" if repo if milestone = assigns[:milestone] case milestone when '*' then header << ' with a milestone' when 'none' then header << ' without a milestone' else header.sub! repo, "#{repo} milestone ##{milestone}" end end if assignee = assigns[:assignee] header << case assignee when '*' then ', assigned' when 'none' then ', unassigned' else ", assigned to #{format_username assignee}" end end if mentioned = assigns[:mentioned] header << ", mentioning #{format_username mentioned}" end else header << case assigns[:filter] when 'created' then ' you created' when 'mentioned' then ' that mention you' when 'subscribed' then " you're subscribed to" when 'all' then ' that you can see' else ' assigned to you' end end if creator = assigns[:creator] header << " #{format_username creator} created" end if labels = assigns[:labels] header << ", labeled #{labels.gsub ',', ', '}" end if excluded_labels = assigns[:exclude_labels] header << ", excluding those labeled #{excluded_labels.gsub ',', ', '}" end if sort = assigns[:sort] header << ", by #{sort} #{reverse ? 'ascending' : 'descending'}" end format_state assigns[:state], header end def format_issues issues, include_repo return 'None.' if issues.empty? include_repo and issues.each do |i| %r{/repos/[^/]+/([^/]+)} === i['url'] and i['repo'] = $1 end nmax, rmax = %w(number repo).map { |f| issues.sort_by { |i| i[f].to_s.size }.last[f].to_s.size } issues.map { |i| n, title, labels = i['number'], i['title'], i['labels'] l = 9 + nmax + rmax + no_color { format_labels labels }.to_s.length a = i['assignee'] a_is_me = a && a['login'] == Authorization.username l += a['login'].to_s.length + 2 if a p = i['pull_request']['html_url'] and l += 2 if i['pull_request'] c = i['comments'] l += c.to_s.length + 1 unless c == 0 m = i['milestone'] [ " ", (i['repo'].to_s.rjust(rmax) if i['repo']), format_number(n.to_s.rjust(nmax)), truncate(title, l), (format_labels(labels) unless assigns[:dont_print_labels]), (fg(:green) { m['title'] } if m), (fg('aaaaaa') { c } unless c == 0), (fg('aaaaaa') { '↑' } if p), (fg(a_is_me ? :yellow : :gray) { "@#{a['login']}" } if a), (fg('aaaaaa') { '‡' } if m) ].compact.join ' ' } end def format_number n colorize? ? "#{bright { n }}:" : "#{n} " end # TODO: Show milestone, number of comments, pull request attached. def format_issue i, width = columns return unless i['created_at'] ERB.new(<\ <%= bright { no_color { indent '%s%s: %s' % [p ? '↑' : '#', \ *i.values_at('number', 'title')], 0, width } } %> @<%= i['user']['login'] %> opened this <%= p ? 'pull request' : 'issue' %> \ <%= format_date DateTime.parse(i['created_at']) %>. \ <% if i['merged'] %><%= format_state 'merged', format_tag('merged'), :bg %><% end %> \ <%= format_state i['state'], format_tag(i['state']), :bg %> \ <% unless i['comments'] == 0 %>\ <%= fg('aaaaaa'){ template = "%d comment" template << "s" unless i['comments'] == 1 '(' << template % i['comments'] << ')' } %>\ <% end %>\ <% if i['assignee'] || !i['labels'].empty? %> <% if i['assignee'] %>@<%= i['assignee']['login'] %> is assigned. <% end %>\ <% unless i['labels'].empty? %><%= format_labels(i['labels']) %><% end %>\ <% end %>\ <% if i['milestone'] %> Milestone #<%= i['milestone']['number'] %>: <%= i['milestone']['title'] %>\ <%= " \#{bright{fg(:yellow){'⚠'}}}" if past_due? i['milestone'] %>\ <% end %> <% if i['body'] && !i['body'].empty? %> <%= indent i['body'], 4, width %> <% end %> EOF end def format_comments_and_events elements return 'None.' if elements.empty? elements.map do |element| if event = element['event'] format_event(element) unless unimportant_event?(event) else format_comment(element) end end.compact end def format_comment c, width = columns < @<%= m['creator']['login'] %> created this milestone \ <%= format_date DateTime.parse(m['created_at']) %>. \ <%= format_state m['state'], format_tag(m['state']), :bg %> <% if m['due_on'] %>\ <% due_on = DateTime.parse m['due_on'] %>\ <% if past_due? m %>\ <%= bright{fg(:yellow){"⚠"}} %> \ <%= bright{fg(:red){"Past due by \#{format_date due_on, false}."}} %> <% else %>\ Due in <%= format_date due_on, false %>. <% end %>\ <% end %>\ <%= percent m %> <% if m['description'] && !m['description'].empty? %> <%= indent m['description'], 4, width %> <% end %> EOF end def past_due? milestone return false unless milestone['due_on'] DateTime.parse(milestone['due_on']) <= DateTime.now end def percent milestone, string = nil open, closed = milestone.values_at('open_issues', 'closed_issues') complete = closed.to_f / (open + closed) complete = 0 if complete.nan? i = (columns * complete).round if string.nil? string = ' %d%% (%d closed, %d open)' % [complete * 100, closed, open] end string = string.ljust columns [bg('2cc200'){string[0, i]}, string[i, columns - i]].join end def format_state state, string = state, layer = :fg color_codes = { 'closed' => 'ff0000', 'open' => '2cc200', 'merged' => '511c7d', } send(layer, color_codes[state]) { string } end def format_labels labels return if labels.empty? [*labels].map { |l| bg(l['color']) { format_tag l['name'] } }.join ' ' end def format_tag tag (colorize? ? ' %s ' : '[%s]') % tag end def format_event_type(event) color_codes = { 'reopened' => '2cc200', 'closed' => 'ff0000', 'merged' => '9677b1', 'assigned' => 'e1811d', 'referenced' => 'aaaaaa' } fg(color_codes[event]) { event } end #-- # Helpers: #++ #-- # TODO: DRY up editor formatters. #++ def format_editor issue = nil message = ERB.new(< <%= no_color { format_issue issue, columns - 2 if issue } %> EOF message.rstrip! message.gsub!(/(?!\A)^.*$/) { |line| "# #{line}".rstrip } # Adding an extra newline for formatting message.insert 0, "\n" message.insert 0, [ issue['title'] || issue[:title], issue['body'] || issue[:body] ].compact.join("\n\n") if issue message end def format_milestone_editor milestone = nil message = ERB.new(< <%= no_color { format_milestone milestone, columns - 2 } if milestone %> EOF message.rstrip! message.gsub!(/(?!\A)^.*$/) { |line| "# #{line}".rstrip } message.insert 0, [ milestone['title'], milestone['description'] ].join("\n\n") if milestone message end def format_comment_editor issue, comment = nil message = ERB.new(< issue #<%= issue['number'] %> <%= no_color { format_issue issue } if verbose %>\ <%= no_color { format_comment comment, columns - 2 } if comment %> EOF message.rstrip! message.gsub!(/(?!\A)^.*$/) { |line| "# #{line}".rstrip } message.insert 0, comment['body'] if comment message end def format_markdown string, indent = 4 c = '268bd2' # Headers. string.gsub!(/^( {#{indent}}\#{1,6} .+)$/, bright{'\1'}) string.gsub!( /(^ {#{indent}}.+$\n^ {#{indent}}[-=]+$)/, bright{'\1'} ) # Strong. string.gsub!( /(^|\s)(\*{2}\w(?:[^*]*\w)?\*{2})(\s|$)/m, '\1' + bright{'\2'} + '\3' ) string.gsub!( /(^|\s)(_{2}\w(?:[^_]*\w)?_{2})(\s|$)/m, '\1' + bright {'\2'} + '\3' ) # Emphasis. string.gsub!( /(^|\s)(\*\w(?:[^*]*\w)?\*)(\s|$)/m, '\1' + underline{'\2'} + '\3' ) string.gsub!( /(^|\s)(_\w(?:[^_]*\w)?_)(\s|$)/m, '\1' + underline{'\2'} + '\3' ) # Bullets/Blockquotes. string.gsub!(/(^ {#{indent}}(?:[*>-]|\d+\.) )/, fg(c){'\1'}) # URIs. string.gsub!( %r{\b(<)?(https?://\S+|[^@\s]+@[^@\s]+)(>)?\b}, fg(c){'\1' + underline{'\2'} + '\3'} ) # Inline code string.gsub!(/`([^`].+?)`(?=[^`])/, inverse { ' \1 ' }) # Code blocks string.gsub!(/(?^\ {#{indent}})(```)\s*(?\w*$)(\n)(?.+?)(\n)(^\ {#{indent}}```$)/m) do |m| highlight(Regexp.last_match) end string end def format_date date, suffix = true days = (interval = DateTime.now - date).to_i.abs string = if days.zero? seconds, _ = interval.divmod Rational(1, 86400) hours, seconds = seconds.divmod 3600 minutes, seconds = seconds.divmod 60 if hours > 0 "#{hours} hour#{'s' unless hours == 1}" elsif minutes > 0 "#{minutes} minute#{'s' unless minutes == 1}" else "#{seconds} second#{'s' unless seconds == 1}" end else "#{days} day#{'s' unless days == 1}" end ago = interval < 0 ? 'from now' : 'ago' if suffix [string, ago].compact.join ' ' end def throb position = 0, redraw = CURSOR[:up][1] return yield unless paginate? throb = THROBBERS[rand(THROBBERS.length)] throb.reverse! if rand > 0.5 i = rand throb.length thread = Thread.new do dot = lambda do print "\r#{CURSOR[:column][position]}#{throb[i]}#{CURSOR[:hide]}" i = (i + 1) % throb.length sleep 0.1 and dot.call end dot.call end yield ensure if thread thread.kill puts "\r#{CURSOR[:column][position]}#{redraw}#{CURSOR[:show]}" end end private def unimportant_event?(event) %w{ subscribed unsubscribed mentioned }.include?(event) end end end # encoding: utf-8 require 'socket' module GHI module Authorization extend Formatting class Required < RuntimeError def message() 'Authorization required.' end end class << self def token return @token if defined? @token @token = GHI.config 'ghi.token' end def authorize! user = username, pass = password, local = true return false unless user && pass code ||= nil # 2fa args = code ? [] : [54, "✔\r"] note = %w[ghi] note << "(#{GHI.repo})" if local note << "on #{Socket.gethostname}" res = throb(*args) { headers = {} headers['X-GitHub-OTP'] = code if code body = { :scopes => %w(public_repo repo), :note => note.join(' '), :note_url => 'https://github.com/stephencelis/ghi' } Client.new(user, pass).post( '/authorizations', body, :headers => headers ) } @token = res.body['token'] unless username system "git config#{' --global' unless local} github.user #{user}" end store_token! user, token, local rescue Client::Error => e if e.response['X-GitHub-OTP'] =~ /required/ puts "Bad code." if code print "Two-factor authentication code: " trap('INT') { abort } code = gets code = '' and puts "\n" unless code retry end if e.errors.any? { |err| err['code'] == 'already_exists' } host = GHI.config('github.host') || 'github.com' message = </dev/null' end run = [ 'security', "#{command}-internet-password", "-a #{username}", '-s github.com', "-l 'ghi token'" ] run << %(-w#{" #{password}" if password}) unless password.nil? run << '>/dev/null 2>&1' unless command == 'find' run.join ' ' end end end end require 'cgi' require 'net/https' require 'json' unless defined? Net::HTTP::Patch # PATCH support for 1.8.7. Net::HTTP::Patch = Class.new(Net::HTTP::Post) { METHOD = 'PATCH' } end module GHI class Client class Error < RuntimeError attr_reader :response def initialize response @response, @json = response, JSON.parse(response.body) end def body() @json end def message() body['message'] end def errors() [*body['errors']] end end class Response def initialize response @response = response end def body @body ||= JSON.parse @response.body end def next_page() links['next'] end def last_page() links['last'] end private def links return @links if defined? @links @links = {} if links = @response['Link'] links.scan(/<([^>]+)>; rel="([^"]+)"/).each { |l, r| @links[r] = l } end @links end end CONTENT_TYPE = 'application/vnd.github.beta+json' USER_AGENT = 'ghi/%s (%s; +%s)' % [ GHI::Commands::Version::VERSION, RUBY_DESCRIPTION, 'https://github.com/stephencelis/ghi' ] METHODS = { :head => Net::HTTP::Head, :get => Net::HTTP::Get, :post => Net::HTTP::Post, :put => Net::HTTP::Put, :patch => Net::HTTP::Patch, :delete => Net::HTTP::Delete } DEFAULT_HOST = 'api.github.com' HOST = GHI.config('github.host') || DEFAULT_HOST PORT = 443 attr_reader :username, :password def initialize username = nil, password = nil @username, @password = username, password end def head path, options = {} request :head, path, options end def get path, params = {}, options = {} request :get, path, options.merge(:params => params) end def post path, body = nil, options = {} request :post, path, options.merge(:body => body) end def put path, body = nil, options = {} request :put, path, options.merge(:body => body) end def patch path, body = nil, options = {} request :patch, path, options.merge(:body => body) end def delete path, options = {} request :delete, path, options end private def request method, path, options path = "/api/v3#{path}" if HOST != DEFAULT_HOST path = URI.escape path if params = options[:params] and !params.empty? q = params.map { |k, v| "#{CGI.escape k.to_s}=#{CGI.escape v.to_s}" } path += "?#{q.join '&'}" end headers = options.fetch :headers, {} headers.update 'Accept' => CONTENT_TYPE, 'User-Agent' => USER_AGENT req = METHODS[method].new path, headers if GHI::Authorization.token req['Authorization'] = "token #{GHI::Authorization.token}" end if options.key? :body req['Content-Type'] = CONTENT_TYPE req.body = options[:body] ? JSON.dump(options[:body]) : '' end req.basic_auth username, password if username && password proxy = GHI.config 'https.proxy', :upcase => false proxy ||= GHI.config 'http.proxy', :upcase => false if proxy proxy = URI.parse proxy http = Net::HTTP::Proxy(proxy.host, proxy.port).new HOST, PORT else http = Net::HTTP.new HOST, PORT end http.use_ssl = true http.verify_mode = OpenSSL::SSL::VERIFY_NONE # FIXME 1.8.7 GHI.v? and puts "\r===> #{method.to_s.upcase} #{path} #{req.body}" res = http.start { http.request req } GHI.v? and puts "\r<=== #{res.code}: #{res.body}" case res when Net::HTTPSuccess return Response.new(res) when Net::HTTPUnauthorized if password.nil? raise Authorization::Required, 'Authorization required' end when Net::HTTPMovedPermanently return Response.new(http.get(res['location'])) end raise Error, res end end end require 'tmpdir' module GHI class Editor attr_reader :filename def initialize filename @filename = filename end def gets prefill File.open path, 'a+' do |f| f << prefill if File.zero? path f.rewind system "#{editor} #{f.path}" return File.read(f.path).gsub(/(?:^#.*$\n?)+\s*\z/, '').strip end end def unlink message = nil File.delete path abort message if message end private def editor editor = GHI.config 'ghi.editor' editor ||= GHI.config 'core.editor' editor ||= ENV['VISUAL'] editor ||= ENV['EDITOR'] editor ||= 'vi' end def path File.join dir, filename end def dir @dir ||= git_dir || Dir.tmpdir end def git_dir return unless Commands::Command.detected_repo dir = `git rev-parse --git-dir 2>/dev/null`.chomp dir unless dir.empty? end end end require 'open-uri' require 'uri' module GHI class Web HOST = GHI.config('github.host') || 'github.com' BASE_URI = "https://#{HOST}/" attr_reader :base def initialize base @base = base end def open path = '', params = {} path = uri_for path, params $stdout.puts path return unless $stdout.tty? launcher = 'open' launcher = 'xdg-open' if /linux/ =~ RUBY_PLATFORM system "#{launcher} '#{path}'" end def curl path = '', params = {} uri_for(path, params).open.read end private def uri_for path, params unless params.empty? q = params.map { |k, v| "#{CGI.escape k.to_s}=#{CGI.escape v.to_s}" } path += "?#{q.join '&'}" end URI(BASE_URI) + "#{base}/" + path end end end module GHI module Commands end end module GHI module Commands class MissingArgument < RuntimeError end class Command include Formatting class << self attr_accessor :detected_repo def execute args command = new args if i = args.index('--') command.repo = args.slice!(i, args.length)[1] # Raise if too many? end command.execute end end attr_reader :args attr_writer :issue attr_accessor :action attr_accessor :verbose def initialize args @args = args.map! { |a| a.dup } end def assigns @assigns ||= {} end def api @api ||= Client.new end def repo return @repo if defined? @repo @repo = GHI.config('ghi.repo', :flags => '--local') || detect_repo if @repo && !@repo.include?('/') @repo = [Authorization.username, @repo].join '/' end @repo end alias extract_repo repo def repo= repo @repo = repo.dup unless @repo.include? '/' @repo.insert 0, "#{Authorization.username}/" end @repo end private def require_repo return true if repo warn 'Not a GitHub repo.' warn '' abort options.to_s end def require_repo_name require_repo repo_array = repo.partition "/" if repo_array.length >= 2 repo_name = repo_array[2] else repo_name = nil end return repo_name end def detect_repo remote = remotes.find { |r| r[:remote] == 'upstream' } remote ||= remotes.find { |r| r[:remote] == 'origin' } remote ||= remotes.find { |r| r[:user] == Authorization.username } Command.detected_repo = true and remote[:repo] if remote end def remotes return @remotes if defined? @remotes @remotes = `git config --get-regexp remote\..+\.url`.split "\n" github_host = GHI.config('github.host') || 'github.com' @remotes.reject! { |r| !r.include? github_host} @remotes.map! { |r| remote, user, repo = r.scan( %r{remote\.([^\.]+)\.url .*?([^:/]+)/([^/\s]+?)(?:\.git)?$} ).flatten { :remote => remote, :user => user, :repo => "#{user}/#{repo}" } } @remotes end def issue return @issue if defined? @issue if index = args.index { |arg| /^\d+$/ === arg } @issue = args.delete_at index else infer_issue_from_branch_prefix end @issue end alias extract_issue issue alias milestone issue alias extract_milestone issue def infer_issue_from_branch_prefix @issue = `git symbolic-ref --short HEAD 2>/dev/null`[/^\d+/]; warn "(Inferring issue from branch prefix: ##@issue)" if @issue end def require_issue raise MissingArgument, 'Issue required.' unless issue end def require_milestone raise MissingArgument, 'Milestone required.' unless milestone end # Handles, e.g. `--[no-]milestone []`. def any_or_none_or input input ? input : { nil => '*', false => 'none' }[input] end def sort_by_creation(arr) arr.sort_by { |el| el['created_at'] } end end end end module GHI module Commands class Assign < Command def options OptionParser.new do |opts| opts.banner = <] or: ghi assign or: ghi unassign EOF opts.separator '' opts.on( '-u', '--assignee ', 'assign to specified user' ) do |assignee| assigns[:assignee] = assignee end opts.on '-d', '--no-assignee', 'unassign this issue' do assigns[:assignee] = nil end opts.on '-l', '--list', 'list assigned issues' do self.action = 'list' end opts.separator '' end end def execute self.action = 'edit' assigns[:args] = [] require_repo extract_issue options.parse! args unless assigns.key? :assignee assigns[:assignee] = args.pop || Authorization.username end if assigns.key? :assignee assigns[:assignee].sub! /^@/, '' if assigns[:assignee] assigns[:args].concat( assigns[:assignee] ? %W(-u #{assigns[:assignee]}) : %w(--no-assign) ) end assigns[:args] << issue if issue assigns[:args].concat %W(-- #{repo}) case action when 'list' then List.execute assigns[:args] when 'edit' then Edit.execute assigns[:args] end end end end end module GHI module Commands class Close < Command attr_accessor :web def options OptionParser.new do |opts| opts.banner = < EOF opts.separator '' opts.on '-l', '--list', 'list closed issues' do assigns[:command] = List end opts.on('-w', '--web') { self.web = true } opts.separator '' opts.separator 'Issue modification options' opts.on '-m', '--message []', 'close with message' do |text| assigns[:comment] = text end opts.separator '' end end def execute options.parse! args require_repo if list? args.unshift(*%W(-sc -- #{repo})) args.unshift '-w' if web List.execute args else require_issue if assigns.key? :comment Comment.execute [ issue, '-m', assigns[:comment], '--', repo ].compact end Edit.execute %W(-sc #{issue} -- #{repo}) end end private def list? assigns[:command] == List end end end end module GHI module Commands class Comment < Command attr_accessor :comment attr_accessor :verbose attr_accessor :web def options OptionParser.new do |opts| opts.banner = < EOF opts.separator '' opts.on '-l', '--list', 'list comments' do self.action = 'list' end opts.on('-w', '--web') { self.web = true } # opts.on '-v', '--verbose', 'list events, too' opts.separator '' opts.separator 'Comment modification options' opts.on '-m', '--message []', 'comment body' do |text| assigns[:body] = text end opts.on '--amend', 'amend previous comment' do self.action = 'update' end opts.on '-D', '--delete', 'delete previous comment' do self.action = 'destroy' end opts.on '--close', 'close associated issue' do self.action = 'close' end opts.on '-v', '--verbose' do self.verbose = true end opts.separator '' end end def execute require_issue require_repo self.action ||= 'create' options.parse! args case action when 'list' get_requests(:index, :events) res = index page do elements = sort_by_creation(res.body + paged_events(events, res)) puts format_comments_and_events(elements) break unless res.next_page res = throb { api.get res.next_page } end when 'create' if web Web.new(repo).open "issues/#{issue}#issue_comment_form" else create end when 'update', 'destroy' res = index res = throb { api.get res.last_page } if res.last_page self.comment = res.body.reverse.find { |c| c['user']['login'] == Authorization.username } if comment send action else abort 'No recent comment found.' end when 'close' Close.execute [issue, '-m', assigns[:body], '--', repo].compact end end protected def index @index ||= throb { api.get uri, :per_page => 100 } end def create message = 'Commented.' e = require_body c = throb { api.post uri, assigns }.body puts format_comment(c) puts message e.unlink if e end def update create 'Comment updated.' end def destroy throb { api.delete uri } puts 'Comment deleted.' end def events @events ||= begin events = [] res = api.get(event_uri, :per_page => 100) loop do events += res.body break unless res.next_page res = api.get res.next_page end events end end private def get_requests(*methods) threads = methods.map do |method| Thread.new { send(method) } end threads.each { |t| t.join } end def uri if comment comment['url'] else "/repos/#{repo}/issues/#{issue}/comments" end end def event_uri "/repos/#{repo}/issues/#{issue}/events" end def require_body assigns[:body] = args.join ' ' unless args.empty? return if assigns[:body] if issue && verbose i = throb { api.get "/repos/#{repo}/issues/#{issue}" }.body else i = {'number'=>issue} end filename = "GHI_COMMENT_#{issue}" filename << "_#{comment['id']}" if comment e = Editor.new filename message = e.gets format_comment_editor(i, comment) e.unlink 'No comment.' if message.nil? || message.empty? if comment && message.strip == comment['body'].strip e.unlink 'No change.' end assigns[:body] = message if message e end def paged_events(events, comments_res) if comments_res.next_page last_comment_creation = comments_res.body.last['created_at'] events_for_this_page, @events = events.partition do |event| event['created_at'] < last_comment_creation end events_for_this_page else events end end end end end module GHI module Commands class Config < Command def options OptionParser.new do |opts| opts.banner = <]' do |username| self.action = 'auth' assigns[:username] = username || Authorization.username end opts.separator '' end end def execute # TODO: Investigate whether or not this variable is needed global = true options.parse! args.empty? ? %w(-h) : args if action == 'auth' assigns[:password] = Authorization.password || get_password Authorization.authorize!( assigns[:username], assigns[:password], assigns[:local] ) end end private def get_password print "Enter #{assigns[:username]}'s GitHub password (never stored): " current_tty = `stty -g` system 'stty raw -echo -icanon isig' if $?.success? input = '' while char = $stdin.getbyte and not (char == 13 or char == 10) if char == 127 or char == 8 input[-1, 1] = '' unless input.empty? else input << char.chr end end input rescue Interrupt print '^C' ensure system "stty #{current_tty}" unless current_tty.empty? end end end end module GHI module Commands class Edit < Command attr_accessor :editor def options OptionParser.new do |opts| opts.banner = < [options] EOF opts.separator '' opts.on( '-m', '--message []', 'change issue description' ) do |text| next self.editor = true if text.nil? assigns[:title], assigns[:body] = text.split(/\n+/, 2) end opts.on( '-u', '--[no-]assign []', 'assign to specified user' ) do |assignee| assigns[:assignee] = assignee || nil end opts.on '--claim', 'assign to yourself' do assigns[:assignee] = Authorization.username end opts.on( '-s', '--state ', %w(open closed), {'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'" ) do |state| assigns[:state] = state end opts.on( '-M', '--[no-]milestone []', Integer, 'associate with milestone' ) do |milestone| assigns[:milestone] = milestone end opts.on( '-L', '--label ...', Array, 'associate with label(s)' ) do |labels| (assigns[:labels] ||= []).concat labels end opts.separator '' opts.separator 'Pull request options' opts.on( '-H', '--head [[:]]', 'branch where your changes are implemented', '(defaults to current branch)' ) do |head| self.action = 'pull' assigns[:head] = head end opts.on( '-b', '--base []', 'branch you want your changes pulled into', '(defaults to master)' ) do |base| self.action = 'pull' assigns[:base] = base end opts.separator '' end end def execute self.action = 'edit' require_repo require_issue options.parse! args case action when 'edit' begin if editor || assigns.empty? i = throb { api.get "/repos/#{repo}/issues/#{issue}" }.body e = Editor.new "GHI_ISSUE_#{issue}" message = e.gets format_editor(i) e.unlink "There's no issue." if message.nil? || message.empty? assigns[:title], assigns[:body] = message.split(/\n+/, 2) end if i && assigns.keys.map { |k| k.to_s }.sort == %w[body title] titles_match = assigns[:title].strip == i['title'].strip if assigns[:body] bodies_match = assigns[:body].to_s.strip == i['body'].to_s.strip end if titles_match && bodies_match e.unlink if e abort 'No change.' if assigns.dup.delete_if { |k, v| [:title, :body].include? k } end end unless assigns.empty? i = throb { api.patch "/repos/#{repo}/issues/#{issue}", assigns }.body puts format_issue(i) puts 'Updated.' end e.unlink if e rescue Client::Error => e raise unless error = e.errors.first abort "%s %s %s %s." % [ error['resource'], error['field'], [*error['value']].join(', '), error['code'] ] end when 'pull' begin assigns[:issue] = issue assigns[:base] ||= 'master' head = begin if ref = %x{ git rev-parse --abbrev-ref HEAD@{upstream} 2>/dev/null }.chomp! ref.split('/', 2).last if $? == 0 end end assigns[:head] ||= head if assigns[:head] assigns[:head].sub!(/:$/, ":#{head}") else abort < e raise unless error = e.errors.last abort error['message'].sub(/^base /, '') end end end end end end module GHI module Commands class Disable < Command def options OptionParser.new do |opts| opts.banner = 'usage: ghi disable' end end def execute begin options.parse! args @repo ||= ARGV[0] if ARGV.one? rescue OptionParser::InvalidOption => e fallback.parse! e.args retry end repo_name = require_repo_name unless repo_name.nil? patch_data = {} patch_data[:name] = repo_name patch_data[:has_issues] = false res = throb { api.patch "/repos/#{repo}", patch_data }.body if !res['has_issues'] puts "Issues are now disabled for this repo" else puts "Something went wrong disabling issues for this repo" end end end end end end module GHI module Commands class Enable < Command def options OptionParser.new do |opts| opts.banner = 'usage: ghi enable' end end def execute begin options.parse! args @repo ||= ARGV[0] if ARGV.one? rescue OptionParser::InvalidOption => e fallback.parse! e.args retry end repo_name = require_repo_name unless repo_name.nil? patch_data = {} patch_data[:name] = repo_name patch_data[:has_issues] = true res = throb { api.patch "/repos/#{repo}", patch_data }.body if res['has_issues'] puts "Issues are now enabled for this repo" else puts "Something went wrong enabling issues for this repo" end end end end end end module GHI module Commands class Help < Command def self.execute args, message = nil new(args).execute message end attr_accessor :command def options OptionParser.new do |opts| opts.banner = 'usage: ghi help [--all] [--man|--web] ' opts.separator '' opts.on('-a', '--all', 'print all available commands') { all } opts.on('-m', '--man', 'show man page') { man } opts.on('-w', '--web', 'show manual in web browser') { web } opts.separator '' end end def execute message = nil self.command = args.shift if args.first !~ /^-/ if command.nil? && args.empty? puts message if message puts <' for more information on a specific command. EOF exit end options.parse! args.empty? ? %w(-m) : args end def all raise 'TODO' end def man GHI.execute [command, '-h'] # TODO: # exec "man #{['ghi', command].compact.join '-'}" end def web raise 'TODO' end end end end module GHI module Commands class Label < Command attr_accessor :name #-- # FIXME: This does too much. Opt for a secondary command, e.g., # # ghi label add # ghi label rm # ghi label ... #++ def options OptionParser.new do |opts| opts.banner = < [-c ] [-r ] or: ghi label -D or: ghi label [-a] [-d] [-f]