diff --git a/README.md b/README.md index 0ec3948..0471c5f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,11 @@ You can install macvim from brew as well, or download it from their website. brew install ack ctags git hub macvim ``` + +### Github Issues: [ghi gem](https://github.com/stephencelis/ghi) + +We include the `ghi` command. Try `ghi list` and have fun managing issues from command line! + ### Ruby Debugger This gem is optonal and not included. It's used to give you visual IDE-style debugging within vim, combined diff --git a/bin/ghi b/bin/ghi new file mode 100755 index 0000000..6fc885a --- /dev/null +++ b/bin/ghi @@ -0,0 +1,3738 @@ +#!/usr/bin/env ruby +# encoding: utf-8 +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 == '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 + var = key.gsub('core', 'git').gsub('.', '_').upcase + value = ENV[var] || `git config #{key}`.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 +require 'strscan' + +module JSON + module Pure + # This class implements the JSON parser that is used to parse a JSON string + # into a Ruby data structure. + class Parser < StringScanner + STRING = /" ((?:[^\x0-\x1f"\\] | + # escaped special characters: + \\["\\\/bfnrt] | + \\u[0-9a-fA-F]{4} | + # match all but escaped special characters: + \\[\x20-\x21\x23-\x2e\x30-\x5b\x5d-\x61\x63-\x65\x67-\x6d\x6f-\x71\x73\x75-\xff])*) + "/nx + INTEGER = /(-?0|-?[1-9]\d*)/ + FLOAT = /(-? + (?:0|[1-9]\d*) + (?: + \.\d+(?i:e[+-]?\d+) | + \.\d+ | + (?i:e[+-]?\d+) + ) + )/x + NAN = /NaN/ + INFINITY = /Infinity/ + MINUS_INFINITY = /-Infinity/ + OBJECT_OPEN = /\{/ + OBJECT_CLOSE = /\}/ + ARRAY_OPEN = /\[/ + ARRAY_CLOSE = /\]/ + PAIR_DELIMITER = /:/ + COLLECTION_DELIMITER = /,/ + TRUE = /true/ + FALSE = /false/ + NULL = /null/ + IGNORE = %r( + (?: + //[^\n\r]*[\n\r]| # line comments + /\* # c-style comments + (?: + [^*/]| # normal chars + /[^*]| # slashes that do not start a nested comment + \*[^/]| # asterisks that do not end this comment + /(?=\*/) # single slash before this comment's end + )* + \*/ # the End of this comment + |[ \t\r\n]+ # whitespaces: space, horicontal tab, lf, cr + )+ + )mx + + UNPARSED = Object.new + + # Creates a new JSON::Pure::Parser instance for the string _source_. + # + # It will be configured by the _opts_ hash. _opts_ can have the following + # keys: + # * *max_nesting*: The maximum depth of nesting allowed in the parsed data + # structures. Disable depth checking with :max_nesting => false|nil|0, + # it defaults to 19. + # * *allow_nan*: If set to true, allow NaN, Infinity and -Infinity in + # defiance of RFC 4627 to be parsed by the Parser. This option defaults + # to false. + # * *symbolize_names*: If set to true, returns symbols for the names + # (keys) in a JSON object. Otherwise strings are returned, which is also + # the default. + # * *create_additions*: If set to false, the Parser doesn't create + # additions even if a matchin class and create_id was found. This option + # defaults to true. + # * *object_class*: Defaults to Hash + # * *array_class*: Defaults to Array + # * *quirks_mode*: Enables quirks_mode for parser, that is for example + # parsing single JSON values instead of documents is possible. + def initialize(source, opts = {}) + opts ||= {} + unless @quirks_mode = opts[:quirks_mode] + source = convert_encoding source + end + super source + if !opts.key?(:max_nesting) # defaults to 19 + @max_nesting = 19 + elsif opts[:max_nesting] + @max_nesting = opts[:max_nesting] + else + @max_nesting = 0 + end + @allow_nan = !!opts[:allow_nan] + @symbolize_names = !!opts[:symbolize_names] + if opts.key?(:create_additions) + @create_additions = !!opts[:create_additions] + else + @create_additions = true + end + @create_id = @create_additions ? JSON.create_id : nil + @object_class = opts[:object_class] || Hash + @array_class = opts[:array_class] || Array + @match_string = opts[:match_string] + end + + alias source string + + def quirks_mode? + !!@quirks_mode + end + + def reset + super + @current_nesting = 0 + end + + # Parses the current JSON string _source_ and returns the complete data + # structure as a result. + def parse + reset + obj = nil + if @quirks_mode + while !eos? && skip(IGNORE) + end + if eos? + raise ParserError, "source did not contain any JSON!" + else + obj = parse_value + obj == UNPARSED and raise ParserError, "source did not contain any JSON!" + end + else + until eos? + case + when scan(OBJECT_OPEN) + obj and raise ParserError, "source '#{peek(20)}' not in JSON!" + @current_nesting = 1 + obj = parse_object + when scan(ARRAY_OPEN) + obj and raise ParserError, "source '#{peek(20)}' not in JSON!" + @current_nesting = 1 + obj = parse_array + when skip(IGNORE) + ; + else + raise ParserError, "source '#{peek(20)}' not in JSON!" + end + end + obj or raise ParserError, "source did not contain any JSON!" + end + obj + end + + private + + def convert_encoding(source) + if source.respond_to?(:to_str) + source = source.to_str + else + raise TypeError, "#{source.inspect} is not like a string" + end + if defined?(::Encoding) + if source.encoding == ::Encoding::ASCII_8BIT + b = source[0, 4].bytes.to_a + source = + case + when b.size >= 4 && b[0] == 0 && b[1] == 0 && b[2] == 0 + source.dup.force_encoding(::Encoding::UTF_32BE).encode!(::Encoding::UTF_8) + when b.size >= 4 && b[0] == 0 && b[2] == 0 + source.dup.force_encoding(::Encoding::UTF_16BE).encode!(::Encoding::UTF_8) + when b.size >= 4 && b[1] == 0 && b[2] == 0 && b[3] == 0 + source.dup.force_encoding(::Encoding::UTF_32LE).encode!(::Encoding::UTF_8) + when b.size >= 4 && b[1] == 0 && b[3] == 0 + source.dup.force_encoding(::Encoding::UTF_16LE).encode!(::Encoding::UTF_8) + else + source.dup + end + else + source = source.encode(::Encoding::UTF_8) + end + source.force_encoding(::Encoding::ASCII_8BIT) + else + b = source + source = + case + when b.size >= 4 && b[0] == 0 && b[1] == 0 && b[2] == 0 + JSON.iconv('utf-8', 'utf-32be', b) + when b.size >= 4 && b[0] == 0 && b[2] == 0 + JSON.iconv('utf-8', 'utf-16be', b) + when b.size >= 4 && b[1] == 0 && b[2] == 0 && b[3] == 0 + JSON.iconv('utf-8', 'utf-32le', b) + when b.size >= 4 && b[1] == 0 && b[3] == 0 + JSON.iconv('utf-8', 'utf-16le', b) + else + b + end + end + source + end + + # Unescape characters in strings. + UNESCAPE_MAP = Hash.new { |h, k| h[k] = k.chr } + UNESCAPE_MAP.update({ + ?" => '"', + ?\\ => '\\', + ?/ => '/', + ?b => "\b", + ?f => "\f", + ?n => "\n", + ?r => "\r", + ?t => "\t", + ?u => nil, + }) + + EMPTY_8BIT_STRING = '' + if ::String.method_defined?(:encode) + EMPTY_8BIT_STRING.force_encoding Encoding::ASCII_8BIT + end + + def parse_string + if scan(STRING) + return '' if self[1].empty? + string = self[1].gsub(%r((?:\\[\\bfnrt"/]|(?:\\u(?:[A-Fa-f\d]{4}))+|\\[\x20-\xff]))n) do |c| + if u = UNESCAPE_MAP[$&[1]] + u + else # \uXXXX + bytes = EMPTY_8BIT_STRING.dup + i = 0 + while c[6 * i] == ?\\ && c[6 * i + 1] == ?u + bytes << c[6 * i + 2, 2].to_i(16) << c[6 * i + 4, 2].to_i(16) + i += 1 + end + JSON.iconv('utf-8', 'utf-16be', bytes) + end + end + if string.respond_to?(:force_encoding) + string.force_encoding(::Encoding::UTF_8) + end + if @create_additions and @match_string + for (regexp, klass) in @match_string + klass.json_creatable? or next + string =~ regexp and return klass.json_create(string) + end + end + string + else + UNPARSED + end + rescue => e + raise ParserError, "Caught #{e.class} at '#{peek(20)}': #{e}" + end + + def parse_value + case + when scan(FLOAT) + Float(self[1]) + when scan(INTEGER) + Integer(self[1]) + when scan(TRUE) + true + when scan(FALSE) + false + when scan(NULL) + nil + when (string = parse_string) != UNPARSED + string + when scan(ARRAY_OPEN) + @current_nesting += 1 + ary = parse_array + @current_nesting -= 1 + ary + when scan(OBJECT_OPEN) + @current_nesting += 1 + obj = parse_object + @current_nesting -= 1 + obj + when @allow_nan && scan(NAN) + NaN + when @allow_nan && scan(INFINITY) + Infinity + when @allow_nan && scan(MINUS_INFINITY) + MinusInfinity + else + UNPARSED + end + end + + def parse_array + raise NestingError, "nesting of #@current_nesting is too deep" if + @max_nesting.nonzero? && @current_nesting > @max_nesting + result = @array_class.new + delim = false + until eos? + case + when (value = parse_value) != UNPARSED + delim = false + result << value + skip(IGNORE) + if scan(COLLECTION_DELIMITER) + delim = true + elsif match?(ARRAY_CLOSE) + ; + else + raise ParserError, "expected ',' or ']' in array at '#{peek(20)}'!" + end + when scan(ARRAY_CLOSE) + if delim + raise ParserError, "expected next element in array at '#{peek(20)}'!" + end + break + when skip(IGNORE) + ; + else + raise ParserError, "unexpected token in array at '#{peek(20)}'!" + end + end + result + end + + def parse_object + raise NestingError, "nesting of #@current_nesting is too deep" if + @max_nesting.nonzero? && @current_nesting > @max_nesting + result = @object_class.new + delim = false + until eos? + case + when (string = parse_string) != UNPARSED + skip(IGNORE) + unless scan(PAIR_DELIMITER) + raise ParserError, "expected ':' in object at '#{peek(20)}'!" + end + skip(IGNORE) + unless (value = parse_value).equal? UNPARSED + result[@symbolize_names ? string.to_sym : string] = value + delim = false + skip(IGNORE) + if scan(COLLECTION_DELIMITER) + delim = true + elsif match?(OBJECT_CLOSE) + ; + else + raise ParserError, "expected ',' or '}' in object at '#{peek(20)}'!" + end + else + raise ParserError, "expected value in object at '#{peek(20)}'!" + end + when scan(OBJECT_CLOSE) + if delim + raise ParserError, "expected next name, value pair in object at '#{peek(20)}'!" + end + if @create_additions and klassname = result[@create_id] + klass = JSON.deep_const_get klassname + break unless klass and klass.json_creatable? + result = klass.json_create(result) + end + break + when skip(IGNORE) + ; + else + raise ParserError, "unexpected token in object at '#{peek(20)}'!" + end + end + result + end + end + end +end + +module JSON + MAP = { + "\x0" => '\u0000', + "\x1" => '\u0001', + "\x2" => '\u0002', + "\x3" => '\u0003', + "\x4" => '\u0004', + "\x5" => '\u0005', + "\x6" => '\u0006', + "\x7" => '\u0007', + "\b" => '\b', + "\t" => '\t', + "\n" => '\n', + "\xb" => '\u000b', + "\f" => '\f', + "\r" => '\r', + "\xe" => '\u000e', + "\xf" => '\u000f', + "\x10" => '\u0010', + "\x11" => '\u0011', + "\x12" => '\u0012', + "\x13" => '\u0013', + "\x14" => '\u0014', + "\x15" => '\u0015', + "\x16" => '\u0016', + "\x17" => '\u0017', + "\x18" => '\u0018', + "\x19" => '\u0019', + "\x1a" => '\u001a', + "\x1b" => '\u001b', + "\x1c" => '\u001c', + "\x1d" => '\u001d', + "\x1e" => '\u001e', + "\x1f" => '\u001f', + '"' => '\"', + '\\' => '\\\\', + } # :nodoc: + + # Convert a UTF8 encoded Ruby string _string_ to a JSON string, encoded with + # UTF16 big endian characters as \u????, and return it. + if defined?(::Encoding) + def utf8_to_json(string) # :nodoc: + string = string.dup + string << '' # XXX workaround: avoid buffer sharing + string.force_encoding(::Encoding::ASCII_8BIT) + string.gsub!(/["\\\x0-\x1f]/) { MAP[$&] } + string.force_encoding(::Encoding::UTF_8) + string + end + + def utf8_to_json_ascii(string) # :nodoc: + string = string.dup + string << '' # XXX workaround: avoid buffer sharing + string.force_encoding(::Encoding::ASCII_8BIT) + string.gsub!(/["\\\x0-\x1f]/) { MAP[$&] } + string.gsub!(/( + (?: + [\xc2-\xdf][\x80-\xbf] | + [\xe0-\xef][\x80-\xbf]{2} | + [\xf0-\xf4][\x80-\xbf]{3} + )+ | + [\x80-\xc1\xf5-\xff] # invalid + )/nx) { |c| + c.size == 1 and raise GeneratorError, "invalid utf8 byte: '#{c}'" + s = JSON.iconv('utf-16be', 'utf-8', c).unpack('H*')[0] + s.gsub!(/.{4}/n, '\\\\u\&') + } + string.force_encoding(::Encoding::UTF_8) + string + rescue => e + raise GeneratorError, "Caught #{e.class}: #{e}" + end + else + def utf8_to_json(string) # :nodoc: + string.gsub(/["\\\x0-\x1f]/) { MAP[$&] } + end + + def utf8_to_json_ascii(string) # :nodoc: + string = string.gsub(/["\\\x0-\x1f]/) { MAP[$&] } + string.gsub!(/( + (?: + [\xc2-\xdf][\x80-\xbf] | + [\xe0-\xef][\x80-\xbf]{2} | + [\xf0-\xf4][\x80-\xbf]{3} + )+ | + [\x80-\xc1\xf5-\xff] # invalid + )/nx) { |c| + c.size == 1 and raise GeneratorError, "invalid utf8 byte: '#{c}'" + s = JSON.iconv('utf-16be', 'utf-8', c).unpack('H*')[0] + s.gsub!(/.{4}/n, '\\\\u\&') + } + string + rescue => e + raise GeneratorError, "Caught #{e.class}: #{e}" + end + end + module_function :utf8_to_json, :utf8_to_json_ascii + + module Pure + module Generator + # This class is used to create State instances, that are use to hold data + # while generating a JSON text from a Ruby data structure. + class State + # Creates a State object from _opts_, which ought to be Hash to create + # a new State instance configured by _opts_, something else to create + # an unconfigured instance. If _opts_ is a State object, it is just + # returned. + def self.from_state(opts) + case + when self === opts + opts + when opts.respond_to?(:to_hash) + new(opts.to_hash) + when opts.respond_to?(:to_h) + new(opts.to_h) + else + SAFE_STATE_PROTOTYPE.dup + end + end + + # Instantiates a new State object, configured by _opts_. + # + # _opts_ can have the following keys: + # + # * *indent*: a string used to indent levels (default: ''), + # * *space*: a string that is put after, a : or , delimiter (default: ''), + # * *space_before*: a string that is put before a : pair delimiter (default: ''), + # * *object_nl*: a string that is put at the end of a JSON object (default: ''), + # * *array_nl*: a string that is put at the end of a JSON array (default: ''), + # * *check_circular*: is deprecated now, use the :max_nesting option instead, + # * *max_nesting*: sets the maximum level of data structure nesting in + # the generated JSON, max_nesting = 0 if no maximum should be checked. + # * *allow_nan*: true if NaN, Infinity, and -Infinity should be + # generated, otherwise an exception is thrown, if these values are + # encountered. This options defaults to false. + # * *quirks_mode*: Enables quirks_mode for parser, that is for example + # generating single JSON values instead of documents is possible. + def initialize(opts = {}) + @indent = '' + @space = '' + @space_before = '' + @object_nl = '' + @array_nl = '' + @allow_nan = false + @ascii_only = false + @quirks_mode = false + @buffer_initial_length = 1024 + configure opts + end + + # This string is used to indent levels in the JSON text. + attr_accessor :indent + + # This string is used to insert a space between the tokens in a JSON + # string. + attr_accessor :space + + # This string is used to insert a space before the ':' in JSON objects. + attr_accessor :space_before + + # This string is put at the end of a line that holds a JSON object (or + # Hash). + attr_accessor :object_nl + + # This string is put at the end of a line that holds a JSON array. + attr_accessor :array_nl + + # This integer returns the maximum level of data structure nesting in + # the generated JSON, max_nesting = 0 if no maximum is checked. + attr_accessor :max_nesting + + # If this attribute is set to true, quirks mode is enabled, otherwise + # it's disabled. + attr_accessor :quirks_mode + + # :stopdoc: + attr_reader :buffer_initial_length + + def buffer_initial_length=(length) + if length > 0 + @buffer_initial_length = length + end + end + # :startdoc: + + # This integer returns the current depth data structure nesting in the + # generated JSON. + attr_accessor :depth + + def check_max_nesting # :nodoc: + return if @max_nesting.zero? + current_nesting = depth + 1 + current_nesting > @max_nesting and + raise NestingError, "nesting of #{current_nesting} is too deep" + end + + # Returns true, if circular data structures are checked, + # otherwise returns false. + def check_circular? + !@max_nesting.zero? + end + + # Returns true if NaN, Infinity, and -Infinity should be considered as + # valid JSON and output. + def allow_nan? + @allow_nan + end + + # Returns true, if only ASCII characters should be generated. Otherwise + # returns false. + def ascii_only? + @ascii_only + end + + # Returns true, if quirks mode is enabled. Otherwise returns false. + def quirks_mode? + @quirks_mode + end + + # Configure this State instance with the Hash _opts_, and return + # itself. + def configure(opts) + @indent = opts[:indent] if opts.key?(:indent) + @space = opts[:space] if opts.key?(:space) + @space_before = opts[:space_before] if opts.key?(:space_before) + @object_nl = opts[:object_nl] if opts.key?(:object_nl) + @array_nl = opts[:array_nl] if opts.key?(:array_nl) + @allow_nan = !!opts[:allow_nan] if opts.key?(:allow_nan) + @ascii_only = opts[:ascii_only] if opts.key?(:ascii_only) + @depth = opts[:depth] || 0 + @quirks_mode = opts[:quirks_mode] if opts.key?(:quirks_mode) + if !opts.key?(:max_nesting) # defaults to 19 + @max_nesting = 19 + elsif opts[:max_nesting] + @max_nesting = opts[:max_nesting] + else + @max_nesting = 0 + end + self + end + alias merge configure + + # Returns the configuration instance variables as a hash, that can be + # passed to the configure method. + def to_h + result = {} + for iv in %w[indent space space_before object_nl array_nl allow_nan max_nesting ascii_only quirks_mode buffer_initial_length depth] + result[iv.intern] = instance_variable_get("@#{iv}") + end + result + end + + # Generates a valid JSON document from object +obj+ and returns the + # result. If no valid JSON document can be created this method raises a + # GeneratorError exception. + def generate(obj) + result = obj.to_json(self) + unless @quirks_mode + unless result =~ /\A\s*\[/ && result =~ /\]\s*\Z/ || + result =~ /\A\s*\{/ && result =~ /\}\s*\Z/ + then + raise GeneratorError, "only generation of JSON objects or arrays allowed" + end + end + result + end + + # Return the value returned by method +name+. + def [](name) + __send__ name + end + end + + module GeneratorMethods + module Object + # Converts this object to a string (calling #to_s), converts + # it to a JSON string, and returns the result. This is a fallback, if no + # special method #to_json was defined for some object. + def to_json(*) to_s.to_json end + end + + module Hash + # Returns a JSON string containing a JSON object, that is unparsed from + # this Hash instance. + # _state_ is a JSON::State object, that can also be used to configure the + # produced JSON string output further. + # _depth_ is used to find out nesting depth, to indent accordingly. + def to_json(state = nil, *) + state = State.from_state(state) + state.check_max_nesting + json_transform(state) + end + + private + + def json_shift(state) + state.object_nl.empty? or return '' + state.indent * state.depth + end + + def json_transform(state) + delim = ',' + delim << state.object_nl + result = '{' + result << state.object_nl + depth = state.depth += 1 + first = true + indent = !state.object_nl.empty? + each { |key,value| + result << delim unless first + result << state.indent * depth if indent + result << key.to_s.to_json(state) + result << state.space_before + result << ':' + result << state.space + result << value.to_json(state) + first = false + } + depth = state.depth -= 1 + result << state.object_nl + result << state.indent * depth if indent if indent + result << '}' + result + end + end + + module Array + # Returns a JSON string containing a JSON array, that is unparsed from + # this Array instance. + # _state_ is a JSON::State object, that can also be used to configure the + # produced JSON string output further. + def to_json(state = nil, *) + state = State.from_state(state) + state.check_max_nesting + json_transform(state) + end + + private + + def json_transform(state) + delim = ',' + delim << state.array_nl + result = '[' + result << state.array_nl + depth = state.depth += 1 + first = true + indent = !state.array_nl.empty? + each { |value| + result << delim unless first + result << state.indent * depth if indent + result << value.to_json(state) + first = false + } + depth = state.depth -= 1 + result << state.array_nl + result << state.indent * depth if indent + result << ']' + end + end + + module Integer + # Returns a JSON string representation for this Integer number. + def to_json(*) to_s end + end + + module Float + # Returns a JSON string representation for this Float number. + def to_json(state = nil, *) + state = State.from_state(state) + case + when infinite? + if state.allow_nan? + to_s + else + raise GeneratorError, "#{self} not allowed in JSON" + end + when nan? + if state.allow_nan? + to_s + else + raise GeneratorError, "#{self} not allowed in JSON" + end + else + to_s + end + end + end + + module String + if defined?(::Encoding) + # This string should be encoded with UTF-8 A call to this method + # returns a JSON string encoded with UTF16 big endian characters as + # \u????. + def to_json(state = nil, *args) + state = State.from_state(state) + if encoding == ::Encoding::UTF_8 + string = self + else + string = encode(::Encoding::UTF_8) + end + if state.ascii_only? + '"' << JSON.utf8_to_json_ascii(string) << '"' + else + '"' << JSON.utf8_to_json(string) << '"' + end + end + else + # This string should be encoded with UTF-8 A call to this method + # returns a JSON string encoded with UTF16 big endian characters as + # \u????. + def to_json(state = nil, *args) + state = State.from_state(state) + if state.ascii_only? + '"' << JSON.utf8_to_json_ascii(self) << '"' + else + '"' << JSON.utf8_to_json(self) << '"' + end + end + end + + # Module that holds the extinding methods if, the String module is + # included. + module Extend + # Raw Strings are JSON Objects (the raw bytes are stored in an + # array for the key "raw"). The Ruby String can be created by this + # module method. + def json_create(o) + o['raw'].pack('C*') + end + end + + # Extends _modul_ with the String::Extend module. + def self.included(modul) + modul.extend Extend + end + + # This method creates a raw object hash, that can be nested into + # other data structures and will be unparsed as a raw string. This + # method should be used, if you want to convert raw strings to JSON + # instead of UTF-8 strings, e. g. binary data. + def to_json_raw_object + { + JSON.create_id => self.class.name, + 'raw' => self.unpack('C*'), + } + end + + # This method creates a JSON text from the result of + # a call to to_json_raw_object of this String. + def to_json_raw(*args) + to_json_raw_object.to_json(*args) + end + end + + module TrueClass + # Returns a JSON string for true: 'true'. + def to_json(*) 'true' end + end + + module FalseClass + # Returns a JSON string for false: 'false'. + def to_json(*) 'false' end + end + + module NilClass + # Returns a JSON string for nil: 'null'. + def to_json(*) 'null' end + end + end + end + end +end + +module JSON + class << self + # If _object_ is string-like, parse the string and return the parsed result + # as a Ruby data structure. Otherwise generate a JSON text from the Ruby + # data structure object and return it. + # + # The _opts_ argument is passed through to generate/parse respectively. See + # generate and parse for their documentation. + def [](object, opts = {}) + if object.respond_to? :to_str + JSON.parse(object.to_str, opts) + else + JSON.generate(object, opts) + end + end + + # Returns the JSON parser class that is used by JSON. This is either + # JSON::Ext::Parser or JSON::Pure::Parser. + attr_reader :parser + + # Set the JSON parser class _parser_ to be used by JSON. + def parser=(parser) # :nodoc: + @parser = parser + remove_const :Parser if JSON.const_defined_in?(self, :Parser) + const_set :Parser, parser + end + + # Return the constant located at _path_. The format of _path_ has to be + # either ::A::B::C or A::B::C. In any case, A has to be located at the top + # level (absolute namespace path?). If there doesn't exist a constant at + # the given path, an ArgumentError is raised. + def deep_const_get(path) # :nodoc: + path.to_s.split(/::/).inject(Object) do |p, c| + case + when c.empty? then p + when JSON.const_defined_in?(p, c) then p.const_get(c) + else + begin + p.const_missing(c) + rescue NameError => e + raise ArgumentError, "can't get const #{path}: #{e}" + end + end + end + end + + # Set the module _generator_ to be used by JSON. + def generator=(generator) # :nodoc: + old, $VERBOSE = $VERBOSE, nil + @generator = generator + generator_methods = generator::GeneratorMethods + for const in generator_methods.constants + klass = deep_const_get(const) + modul = generator_methods.const_get(const) + klass.class_eval do + instance_methods(false).each do |m| + m.to_s == 'to_json' and remove_method m + end + include modul + end + end + self.state = generator::State + const_set :State, self.state + const_set :SAFE_STATE_PROTOTYPE, State.new + const_set :FAST_STATE_PROTOTYPE, State.new( + :indent => '', + :space => '', + :object_nl => "", + :array_nl => "", + :max_nesting => false + ) + const_set :PRETTY_STATE_PROTOTYPE, State.new( + :indent => ' ', + :space => ' ', + :object_nl => "\n", + :array_nl => "\n" + ) + ensure + $VERBOSE = old + end + + # Returns the JSON generator module that is used by JSON. This is + # either JSON::Ext::Generator or JSON::Pure::Generator. + attr_reader :generator + + # Returns the JSON generator state class that is used by JSON. This is + # either JSON::Ext::Generator::State or JSON::Pure::Generator::State. + attr_accessor :state + + # This is create identifier, which is used to decide if the _json_create_ + # hook of a class should be called. It defaults to 'json_class'. + attr_accessor :create_id + end + self.create_id = 'json_class' + + NaN = 0.0/0 + + Infinity = 1.0/0 + + MinusInfinity = -Infinity + + # The base exception for JSON errors. + class JSONError < StandardError; end + + # This exception is raised if a parser error occurs. + class ParserError < JSONError; end + + # This exception is raised if the nesting of parsed data structures is too + # deep. + class NestingError < ParserError; end + + # :stopdoc: + class CircularDatastructure < NestingError; end + # :startdoc: + + # This exception is raised if a generator or unparser error occurs. + class GeneratorError < JSONError; end + # For backwards compatibility + UnparserError = GeneratorError + + # This exception is raised if the required unicode support is missing on the + # system. Usually this means that the iconv library is not installed. + class MissingUnicodeSupport < JSONError; end + + module_function + + # Parse the JSON document _source_ into a Ruby data structure and return it. + # + # _opts_ can have the following + # keys: + # * *max_nesting*: The maximum depth of nesting allowed in the parsed data + # structures. Disable depth checking with :max_nesting => false. It defaults + # to 19. + # * *allow_nan*: If set to true, allow NaN, Infinity and -Infinity in + # defiance of RFC 4627 to be parsed by the Parser. This option defaults + # to false. + # * *symbolize_names*: If set to true, returns symbols for the names + # (keys) in a JSON object. Otherwise strings are returned. Strings are + # the default. + # * *create_additions*: If set to false, the Parser doesn't create + # additions even if a matching class and create_id was found. This option + # defaults to true. + # * *object_class*: Defaults to Hash + # * *array_class*: Defaults to Array + def parse(source, opts = {}) + Parser.new(source, opts).parse + end + + # Parse the JSON document _source_ into a Ruby data structure and return it. + # The bang version of the parse method defaults to the more dangerous values + # for the _opts_ hash, so be sure only to parse trusted _source_ documents. + # + # _opts_ can have the following keys: + # * *max_nesting*: The maximum depth of nesting allowed in the parsed data + # structures. Enable depth checking with :max_nesting => anInteger. The parse! + # methods defaults to not doing max depth checking: This can be dangerous + # if someone wants to fill up your stack. + # * *allow_nan*: If set to true, allow NaN, Infinity, and -Infinity in + # defiance of RFC 4627 to be parsed by the Parser. This option defaults + # to true. + # * *create_additions*: If set to false, the Parser doesn't create + # additions even if a matching class and create_id was found. This option + # defaults to true. + def parse!(source, opts = {}) + opts = { + :max_nesting => false, + :allow_nan => true + }.update(opts) + Parser.new(source, opts).parse + end + + # Generate a JSON document from the Ruby data structure _obj_ and return + # it. _state_ is * a JSON::State object, + # * or a Hash like object (responding to to_hash), + # * an object convertible into a hash by a to_h method, + # that is used as or to configure a State object. + # + # It defaults to a state object, that creates the shortest possible JSON text + # in one line, checks for circular data structures and doesn't allow NaN, + # Infinity, and -Infinity. + # + # A _state_ hash can have the following keys: + # * *indent*: a string used to indent levels (default: ''), + # * *space*: a string that is put after, a : or , delimiter (default: ''), + # * *space_before*: a string that is put before a : pair delimiter (default: ''), + # * *object_nl*: a string that is put at the end of a JSON object (default: ''), + # * *array_nl*: a string that is put at the end of a JSON array (default: ''), + # * *allow_nan*: true if NaN, Infinity, and -Infinity should be + # generated, otherwise an exception is thrown if these values are + # encountered. This options defaults to false. + # * *max_nesting*: The maximum depth of nesting allowed in the data + # structures from which JSON is to be generated. Disable depth checking + # with :max_nesting => false, it defaults to 19. + # + # See also the fast_generate for the fastest creation method with the least + # amount of sanity checks, and the pretty_generate method for some + # defaults for pretty output. + def generate(obj, opts = nil) + if State === opts + state, opts = opts, nil + else + state = SAFE_STATE_PROTOTYPE.dup + end + if opts + if opts.respond_to? :to_hash + opts = opts.to_hash + elsif opts.respond_to? :to_h + opts = opts.to_h + else + raise TypeError, "can't convert #{opts.class} into Hash" + end + state = state.configure(opts) + end + state.generate(obj) + end + + # :stopdoc: + # I want to deprecate these later, so I'll first be silent about them, and + # later delete them. + alias unparse generate + module_function :unparse + # :startdoc: + + # Generate a JSON document from the Ruby data structure _obj_ and return it. + # This method disables the checks for circles in Ruby objects. + # + # *WARNING*: Be careful not to pass any Ruby data structures with circles as + # _obj_ argument because this will cause JSON to go into an infinite loop. + def fast_generate(obj, opts = nil) + if State === opts + state, opts = opts, nil + else + state = FAST_STATE_PROTOTYPE.dup + end + if opts + if opts.respond_to? :to_hash + opts = opts.to_hash + elsif opts.respond_to? :to_h + opts = opts.to_h + else + raise TypeError, "can't convert #{opts.class} into Hash" + end + state.configure(opts) + end + state.generate(obj) + end + + # :stopdoc: + # I want to deprecate these later, so I'll first be silent about them, and later delete them. + alias fast_unparse fast_generate + module_function :fast_unparse + # :startdoc: + + # Generate a JSON document from the Ruby data structure _obj_ and return it. + # The returned document is a prettier form of the document returned by + # #unparse. + # + # The _opts_ argument can be used to configure the generator. See the + # generate method for a more detailed explanation. + def pretty_generate(obj, opts = nil) + if State === opts + state, opts = opts, nil + else + state = PRETTY_STATE_PROTOTYPE.dup + end + if opts + if opts.respond_to? :to_hash + opts = opts.to_hash + elsif opts.respond_to? :to_h + opts = opts.to_h + else + raise TypeError, "can't convert #{opts.class} into Hash" + end + state.configure(opts) + end + state.generate(obj) + end + + # :stopdoc: + # I want to deprecate these later, so I'll first be silent about them, and later delete them. + alias pretty_unparse pretty_generate + module_function :pretty_unparse + # :startdoc: + + class << self + # The global default options for the JSON.load method: + # :max_nesting: false + # :allow_nan: true + # :quirks_mode: true + attr_accessor :load_default_options + end + self.load_default_options = { + :max_nesting => false, + :allow_nan => true, + :quirks_mode => true, + } + + # Load a ruby data structure from a JSON _source_ and return it. A source can + # either be a string-like object, an IO-like object, or an object responding + # to the read method. If _proc_ was given, it will be called with any nested + # Ruby object as an argument recursively in depth first order. The default + # options for the parser can be changed via the load_default_options method. + # + # This method is part of the implementation of the load/dump interface of + # Marshal and YAML. + def load(source, proc = nil) + opts = load_default_options + if source.respond_to? :to_str + source = source.to_str + elsif source.respond_to? :to_io + source = source.to_io.read + elsif source.respond_to?(:read) + source = source.read + end + if opts[:quirks_mode] && (source.nil? || source.empty?) + source = 'null' + end + result = parse(source, opts) + recurse_proc(result, &proc) if proc + result + end + + # Recursively calls passed _Proc_ if the parsed data structure is an _Array_ or _Hash_ + def recurse_proc(result, &proc) + case result + when Array + result.each { |x| recurse_proc x, &proc } + proc.call result + when Hash + result.each { |x, y| recurse_proc x, &proc; recurse_proc y, &proc } + proc.call result + else + proc.call result + end + end + + alias restore load + module_function :restore + + class << self + # The global default options for the JSON.dump method: + # :max_nesting: false + # :allow_nan: true + # :quirks_mode: true + attr_accessor :dump_default_options + end + self.dump_default_options = { + :max_nesting => false, + :allow_nan => true, + :quirks_mode => true, + } + + # Dumps _obj_ as a JSON string, i.e. calls generate on the object and returns + # the result. + # + # If anIO (an IO-like object or an object that responds to the write method) + # was given, the resulting JSON is written to it. + # + # If the number of nested arrays or objects exceeds _limit_, an ArgumentError + # exception is raised. This argument is similar (but not exactly the + # same!) to the _limit_ argument in Marshal.dump. + # + # The default options for the generator can be changed via the + # dump_default_options method. + # + # This method is part of the implementation of the load/dump interface of + # Marshal and YAML. + def dump(obj, anIO = nil, limit = nil) + if anIO and limit.nil? + anIO = anIO.to_io if anIO.respond_to?(:to_io) + unless anIO.respond_to?(:write) + limit = anIO + anIO = nil + end + end + opts = JSON.dump_default_options + limit and opts.update(:max_nesting => limit) + result = generate(obj, opts) + if anIO + anIO.write result + anIO + else + result + end + rescue JSON::NestingError + raise ArgumentError, "exceed depth limit" + end + + # Swap consecutive bytes of _string_ in place. + def self.swap!(string) # :nodoc: + 0.upto(string.size / 2) do |i| + break unless string[2 * i + 1] + string[2 * i], string[2 * i + 1] = string[2 * i + 1], string[2 * i] + end + string + end + + # Shortuct for iconv. + if ::String.method_defined?(:encode) + # Encodes string using Ruby's _String.encode_ + def self.iconv(to, from, string) + string.encode(to, from) + end + else + require 'iconv' + # Encodes string using _iconv_ library + def self.iconv(to, from, string) + Iconv.conv(to, from, string) + end + end + + if ::Object.method(:const_defined?).arity == 1 + def self.const_defined_in?(modul, constant) + modul.const_defined?(constant) + end + else + def self.const_defined_in?(modul, constant) + modul.const_defined?(constant, false) + end + end +end + +module ::Kernel + private + + # Outputs _objs_ to STDOUT as JSON strings in the shortest form, that is in + # one line. + def j(*objs) + objs.each do |obj| + puts JSON::generate(obj, :allow_nan => true, :max_nesting => false) + end + nil + end + + # Ouputs _objs_ to STDOUT as JSON strings in a pretty format, with + # indentation and over many lines. + def jj(*objs) + objs.each do |obj| + puts JSON::pretty_generate(obj, :allow_nan => true, :max_nesting => false) + end + nil + end + + # If _object_ is string-like, parse the string and return the parsed result as + # a Ruby data structure. Otherwise, generate a JSON text from the Ruby data + # structure object and return it. + # + # The _opts_ argument is passed through to generate/parse respectively. See + # generate and parse for their documentation. + def JSON(object, *args) + if object.respond_to? :to_str + JSON.parse(object.to_str, args.first) + else + JSON.generate(object, args.first) + end + end +end + +# Extends any Class to include _json_creatable?_ method. +class ::Class + # Returns true if this class can be used to create an instance + # from a serialised JSON string. The class has to implement a class + # method _json_create_ that expects a hash as first parameter. The hash + # should include the required data. + def json_creatable? + respond_to?(:json_create) + end +end + +JSON.generator = JSON::Pure::Generator +JSON.parser = JSON::Pure::Parser +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 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] || "8;5;#{to_256(*to_rgb(color))}" + ] + [escape, yield, previous_escape].join + ensure + Thread.current[:escape] = previous_escape + 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 + end + end +end +# encoding: utf-8 +require 'date' +require 'erb' + +module GHI + module Formatting + class << self + attr_accessor :paginate + end + self.paginate = true # Default. + 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(/@([^@\s]+)/) { + if $1 == Authorization.username + bright { fg(:yellow) { "@#$1" } } + else + bright { "@#$1" } + 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 + 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 + end + + def truncate string, reserved + result = string.scan(/.{0,#{columns - reserved}}(?:\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`.chomp.split(' ').map { |n| n.to_i } + end + + #-- + # Specific formatters: + #++ + + def format_issues_header + state = assigns[:state] || 'open' + header = "# #{repo || '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 + assignee = 'you' if Authorization.username == assignee + ", assigned to #{assignee}" + end + end + if mentioned = assigns[:mentioned] + mentioned = 'you' if Authorization.username == mentioned + header << ", mentioning #{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" + else + ' assigned to you' + end + end + if labels = assigns[:labels] + header << ", labeled #{assigns[:labels].gsub ',', ', '}" + end + if sort = assigns[:sort] + header << ", by #{sort} #{reverse ? 'ascending' : 'descending'}" + end + format_state assigns[:state], header + end + + # TODO: Show milestones. + 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'] && i['assignee']['login'] == Authorization.username + l += 2 if a + p = i['pull_request']['html_url'] and l += 2 + c = i['comments'] + l += c.to_s.length + 1 unless c == 0 + [ + " ", + (i['repo'].to_s.rjust(rmax) if i['repo']), + "#{bright { n.to_s.rjust nmax }}:", + truncate(title, l), + format_labels(labels), + (fg('aaaaaa') { c } unless c == 0), + (fg('aaaaaa') { '↑' } if p), + (fg(:yellow) { '@' } if a) + ].compact.join ' ' + } + 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']) %>. \ +<%= 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 comments + return 'None.' if comments.empty? + comments.map { |comment| format_comment comment } + 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 + send(layer, state == 'closed' ? 'ff0000' : '2cc200') { 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 + + #-- + # 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 } + 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'} + ) + # Code. + # string.gsub!( + # / + # (^\ {#{indent}}```.*?$)(.+?^\ {#{indent}}```$)| + # (^|[^`])(`[^`]+`)([^`]|$) + # /mx + # ) { + # post = $5 + # fg(c){"#$1#$2#$3#$4".gsub(/\e\[[\d;]+m/, '')} + "#{post}" + # } + 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 + end +end +# encoding: utf-8 + +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 + + res = throb(54, "✔\r") { + Client.new(user, pass).post( + '/authorizations', + :scopes => %w(public_repo repo), + :note => 'ghi', + :note_url => 'https://github.com/stephencelis/ghi' + ) + } + @token = res.body['token'] + + run = [] + unless username + run << "git config#{' --global' unless local} github.user #{user}" + end + run << "git config#{' --global' unless local} ghi.token #{token}" + + system run.join('; ') + + unless local + at_exit do + warn < e + abort "#{e.message}#{CURSOR[:column][0]}" + end + + def username + return @username if defined? @username + @username = GHI.config 'github.user' + end + + def password + return @password if defined? @password + @password = GHI.config 'github.password' + end + end + end +end +require 'cgi' +require 'net/https' + +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+json' + 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 + } + + 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 + 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 + + req = METHODS[method].new path, 'Accept' => CONTENT_TYPE + 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 + + http = Net::HTTP.new 'api.github.com', 443 + 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 + 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 'uri' + +module GHI + class Web + BASE_URI = 'https://github.com/' + + attr_reader :base + def initialize base + @base = base + end + + def open path = '', params = {} + unless params.empty? + q = params.map { |k, v| "#{CGI.escape k.to_s}=#{CGI.escape v.to_s}" } + path += "?#{q.join '&'}" + end + system "open '#{uri + path}'" + end + + private + + def uri + URI(BASE_URI) + "#{base}/" + 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') || 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 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" + @remotes.reject! { |r| !r.include? 'github.com'} + @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 + index = args.index { |arg| /^\d+$/ === arg } + @issue = (args.delete_at index if index) + end + alias extract_issue issue + alias milestone issue + alias extract_milestone issue + + 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 + 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[: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' + res = index + page do + puts format_comments(res.body) + 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 + 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 + + private + + def uri + if comment + comment['url'] + else + "/repos/#{repo}/issues/#{issue}/comments" + end + 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 + 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 + global = true + options.parse! args.empty? ? %w(-h) : args + + if self.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 = < +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 + 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.sort == [: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('/').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 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] + or: ghi label -l [] +EOF + opts.separator '' + opts.on '-l', '--list []', 'list label names' do |n| + self.action = 'index' + @issue ||= n + end + opts.on '-D', '--delete', 'delete label' do + self.action = 'destroy' + end + opts.separator '' + opts.separator 'Label modification options' + opts.on( + '-c', '--color ', 'color name or 6-character hex code' + ) do |color| + assigns[:color] = to_hex color + self.action ||= 'create' + end + opts.on '-r', '--rename ', 'new label name' do |name| + assigns[:name] = name + self.action = 'update' + end + opts.separator '' + opts.separator 'Issue modification options' + opts.on '-a', '--add', 'add labels to issue' do + self.action = issue ? 'add' : 'create' + end + opts.on '-d', '--delete', 'remove labels from issue' do + self.action = issue ? 'remove' : 'destroy' + end + opts.on '-f', '--force', 'replace existing labels' do + self.action = issue ? 'replace' : 'update' + end + opts.separator '' + end + end + + def execute + extract_issue + require_repo + options.parse! args.empty? ? %w(-l) : args + + if issue + self.action ||= 'add' + self.name = args.shift.to_s.split ',' + self.name.concat args + else + self.action ||= 'create' + self.name ||= args.shift + end + + send action + end + + protected + + def index + if issue + uri = "/repos/#{repo}/issues/#{issue}/labels" + else + uri = "/repos/#{repo}/labels" + end + labels = throb { api.get uri }.body + if labels.empty? + puts 'None.' + else + puts labels.map { |label| + name = label['name'] + colorize? ? bg(label['color']) { " #{name} " } : name + } + end + end + + def create + label = throb { + api.post "/repos/#{repo}/labels", assigns.merge(:name => name) + }.body + return update if label.nil? + puts "%s created." % bg(label['color']) { " #{label['name']} "} + rescue Client::Error => e + if e.errors.find { |error| error['code'] == 'already_exists' } + return update + end + raise + end + + def update + label = throb { + api.patch "/repos/#{repo}/labels/#{name}", assigns + }.body + puts "%s updated." % bg(label['color']) { " #{label['name']} "} + end + + def destroy + throb { api.delete "/repos/#{repo}/labels/#{name}" } + puts "[#{name}] deleted." + end + + def add + labels = throb { + api.post "/repos/#{repo}/issues/#{issue}/labels", name + }.body + puts "Issue #%d labeled %s." % [issue, format_labels(labels)] + end + + def remove + case name.length + when 0 + throb { api.delete base_uri } + puts "Labels removed." + when 1 + labels = throb { api.delete "#{base_uri}/#{name.join}" }.body + if labels.empty? + puts "Issue #%d unlabeled." % issue + else + puts "Issue #%d labeled %s." % [issue, format_labels(labels)] + end + else + labels = throb { + api.get "/repos/#{repo}/issues/#{issue}/labels" + }.body + self.name = labels.map { |l| l['name'] } - name + replace + end + end + + def replace + labels = throb { api.put base_uri, name }.body + if labels.empty? + puts "Issue #%d unlabeled." % issue + else + puts "Issue #%d labeled %s." % [issue, format_labels(labels)] + end + end + + private + + def base_uri + "/repos/#{repo}/#{issue ? "issues/#{issue}/labels" : 'labels'}" + end + end + end +end +require 'date' + +module GHI + module Commands + class List < Command + attr_accessor :web + attr_accessor :reverse + attr_accessor :quiet + + def options + OptionParser.new do |opts| + opts.banner = 'usage: ghi list [options]' + opts.separator '' + opts.on '-a', '--global', '--all', 'all of your issues on GitHub' do + @repo = nil + end + opts.on( + '-s', '--state ', %w(open closed), + {'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'" + ) do |state| + assigns[:state] = state + end + opts.on( + '-L', '--label ...', Array, 'by label(s)' + ) do |labels| + (assigns[:labels] ||= []).concat labels + end + opts.on( + '-S', '--sort ', %w(created updated comments), + {'c'=>'created','u'=>'updated','m'=>'comments'}, + "'created', 'updated', or 'comments'" + ) do |sort| + assigns[:sort] = sort + end + opts.on '--reverse', 'reverse (ascending) sort order' do + self.reverse = !reverse + end + opts.on( + '--since ', 'issues more recent than', + "e.g., '2011-04-30'" + ) do |date| + begin + assigns[:since] = DateTime.parse date # TODO: Better parsing. + rescue ArgumentError => e + raise OptionParser::InvalidArgument, e.message + end + end + opts.on('-v', '--verbose') { self.verbose = true } + opts.on('-w', '--web') { self.web = true } + opts.separator '' + opts.separator 'Global options' + opts.on( + '-f', '--filter ', + filters = %w(assigned created mentioned subscribed), + Hash[filters.map { |f| [f[0, 1], f] }], + "'assigned', 'created', 'mentioned', or", "'subscribed'" + ) do |filter| + assigns[:filter] = filter + end + opts.separator '' + opts.separator 'Project options' + opts.on( + '-M', '--[no-]milestone []', Integer, + 'with (specified) milestone' + ) do |milestone| + assigns[:milestone] = any_or_none_or milestone + end + opts.on( + '-u', '--[no-]assignee []', 'assigned to specified user' + ) do |assignee| + assignee = assignee.sub /^@/, '' + assigns[:assignee] = any_or_none_or assignee + end + opts.on '--mine', 'assigned to you' do + assigns[:assignee] = Authorization.username + end + opts.on( + '-U', '--mentioned []', 'mentioning you or specified user' + ) do |mentioned| + assigns[:mentioned] = mentioned || Authorization.username + end + opts.separator '' + end + end + + def execute + if index = args.index { |arg| /^@/ === arg } + assigns[:assignee] = args.delete_at(index)[1..-1] + end + + begin + options.parse! args + rescue OptionParser::InvalidOption => e + fallback.parse! e.args + retry + end + assigns[:labels] = assigns[:labels].join ',' if assigns[:labels] + if reverse + assigns[:sort] ||= 'created' + assigns[:direction] = 'asc' + end + if web + Web.new(repo || 'dashboard').open 'issues', assigns + else + assigns[:per_page] = 100 + unless quiet + print header = format_issues_header + print "\n" unless paginate? + end + res = throb( + 0, format_state(assigns[:state], quiet ? CURSOR[:up][1] : '#') + ) { api.get uri, assigns } + print "\r#{CURSOR[:up][1]}" if header && paginate? + page header do + issues = res.body + if verbose + puts issues.map { |i| format_issue i } + else + puts format_issues(issues, repo.nil?) + end + break unless res.next_page + res = throb { api.get res.next_page } + end + end + rescue Client::Error => e + if e.response.code == '422' + e.errors.any? { |err| + err['code'] == 'missing' && err['field'] == 'milestone' + } and abort 'No such milestone.' + end + + raise + end + + private + + def uri + (repo ? "/repos/#{repo}" : '') << '/issues' + end + + def fallback + OptionParser.new do |opts| + opts.on('-c', '--closed') { assigns[:state] = 'closed' } + opts.on('-q', '--quiet') { self.quiet = true } + end + end + end + end +end +require 'date' + +module GHI + module Commands + class Milestone < Command + attr_accessor :edit + attr_accessor :reverse + attr_accessor :web + + #-- + # FIXME: Opt for better interface, e.g., + # + # ghi milestone [-v | --verbose] [--[no-]closed] + # ghi milestone add + # ghi milestone rm + #++ + def options + OptionParser.new do |opts| + opts.banner = <] [] + or: ghi milestone -D + or: ghi milestone -l [-c] [-v] +EOF + opts.separator '' + opts.on '-l', '--list', 'list milestones' do + self.action = 'index' + end + opts.on '-c', '--[no-]closed', 'show closed milestones' do |closed| + assigns[:state] = closed ? 'closed' : 'open' + end + opts.on( + '-S', '--sort ', %w(due_date completeness), + {'d'=>'due_date', 'due'=>'due_date', 'c'=>'completeness'}, + "'due_date' or 'completeness'" + ) do |sort| + assigns[:sort] = sort + end + opts.on '--reverse', 'reverse (ascending) sort order' do + self.reverse = !reverse + end + opts.on '-v', '--verbose', 'list milestones verbosely' do + self.verbose = true + end + opts.on('-w', '--web') { self.web = true } + opts.separator '' + opts.separator 'Milestone modification options' + opts.on( + '-m', '--message []', 'change milestone description' + ) do |text| + self.action = 'create' + self.edit = true + next unless text + assigns[:title], assigns[:description] = text.split(/\n+/, 2) + end + # FIXME: We already describe --[no-]closed; describe this, too? + opts.on( + '-s', '--state ', %w(open closed), + {'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'" + ) do |state| + self.action = 'create' + assigns[:state] = state + end + opts.on( + '--due ', 'when milestone should be complete', + "e.g., '2012-04-30'" + ) do |date| + self.action = 'create' + begin + # TODO: Better parsing. + assigns[:due_on] = DateTime.parse(date).strftime + rescue ArgumentError => e + raise OptionParser::InvalidArgument, e.message + end + end + opts.on '-D', '--delete', 'delete milestone' do + self.action = 'destroy' + end + opts.separator '' + end + end + + def execute + self.action = 'index' + require_repo + extract_milestone + + begin + options.parse! args + rescue OptionParser::AmbiguousOption => e + fallback.parse! e.args + end + + milestone and case action + when 'create' then self.action = 'update' + when 'index' then self.action = 'show' + end + + if reverse + assigns[:sort] ||= 'created' + assigns[:direction] = 'asc' + end + + case action + when 'index' + if web + Web.new(repo).open 'issues/milestones', assigns + else + assigns[:per_page] = 100 + state = assigns[:state] || 'open' + print format_state state, "# #{repo} #{state} milestones" + print "\n" unless paginate? + res = throb(0, format_state(state, '#')) { api.get uri, assigns } + page do + milestones = res.body + if verbose + puts milestones.map { |m| format_milestone m } + else + puts format_milestones(milestones) + end + break unless res.next_page + res = throb { api.get res.next_page } + end + end + when 'show' + if web + List.execute %W(-w -M #{milestone} -- #{repo}) + else + m = throb { api.get uri }.body + page do + puts format_milestone(m) + puts 'Issues:' + args.unshift(*%W(-q -M #{milestone} -- #{repo})) + args.unshift '-v' if verbose + List.execute args + break + end + end + when 'create' + if web + Web.new(repo).open 'issues/milestones/new' + else + if assigns[:title].nil? + e = Editor.new 'GHI_MILESTONE' + message = e.gets format_milestone_editor + e.unlink 'Empty milestone.' if message.nil? || message.empty? + assigns[:title], assigns[:description] = message.split(/\n+/, 2) + end + m = throb { api.post uri, assigns }.body + puts 'Milestone #%d created.' % m['number'] + e.unlink if e + end + when 'update' + if web + Web.new(repo).open "issues/milestones/#{milestone}/edit" + else + if edit || assigns.empty? + m = throb { api.get "/repos/#{repo}/milestones/#{milestone}" }.body + e = Editor.new "GHI_MILESTONE_#{milestone}" + message = e.gets format_milestone_editor(m) + e.unlink 'Empty milestone.' if message.nil? || message.empty? + assigns[:title], assigns[:description] = message.split(/\n+/, 2) + end + if assigns[:title] && m + t_match = assigns[:title].strip == m['title'].strip + if assigns[:description] + b_match = assigns[:description].strip == m['description'].strip + end + if t_match && b_match + e.unlink if e + abort 'No change.' if assigns.dup.delete_if { |k, v| + [:title, :description].include? k + } + end + end + m = throb { api.patch uri, assigns }.body + puts format_milestone(m) + puts 'Updated.' + e.unlink if e + end + when 'destroy' + require_milestone + throb { api.delete uri } + puts 'Milestone deleted.' + end + end + + private + + def uri + if milestone + "/repos/#{repo}/milestones/#{milestone}" + else + "/repos/#{repo}/milestones" + end + end + + def fallback + OptionParser.new do |opts| + opts.on '-d' do + self.action = 'destroy' + end + end + end + end + end +end +module GHI + module Commands + class Open < Command + attr_accessor :editor + attr_accessor :web + + def options + OptionParser.new do |opts| + opts.banner = < +EOF + opts.separator '' + opts.on '-l', '--list', 'list open tickets' do + self.action = 'index' + end + opts.on('-w', '--web') { self.web = true } + opts.separator '' + opts.separator 'Issue modification options' + opts.on '-m', '--message []', 'describe issue' do |text| + if text + assigns[:title], assigns[:body] = text.split(/\n+/, 2) + else + self.editor = true + end + end + opts.on( + '-u', '--[no-]assign []', 'assign to specified user' + ) do |assignee| + assigns[:assignee] = assignee + end + opts.on '--claim', 'assign to yourself' do + assigns[:assignee] = Authorization.username + end + opts.on( + '-M', '--milestone ', '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 '' + end + end + + def execute + require_repo + self.action = 'create' + + if extract_issue + Edit.execute args.push('-so', issue, '--', repo) + exit + end + + options.parse! args + + case action + when 'index' + if assigns.key? :assignee + args.unshift assigns[:assignee] if assigns[:assignee] + args.unshift '-u' + end + args.unshift '-w' if web + List.execute args.push('--', repo) + when 'create' + if web + Web.new(repo).open 'issues/new' + else + unless args.empty? + assigns[:title], assigns[:body] = args.join(' '), assigns[:title] + end + assigns[:title] = args.join ' ' unless args.empty? + if assigns[:title].nil? || editor + e = Editor.new 'GHI_ISSUE' + message = e.gets format_editor(assigns) + e.unlink "There's no issue?" if message.nil? || message.empty? + assigns[:title], assigns[:body] = message.split(/\n+/, 2) + end + i = throb { api.post "/repos/#{repo}/issues", assigns }.body + e.unlink if e + puts format_issue(i) + puts 'Opened.' + end + end + 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 + end + end +end +module GHI + module Commands + class Show < Command + attr_accessor :web + + def options + OptionParser.new do |opts| + opts.banner = 'usage: ghi show ' + opts.separator '' + opts.on('-w', '--web') { self.web = true } + end + end + + def execute + require_issue + require_repo + options.parse! args + if web + Web.new(repo).open "issues/#{issue}" + else + i = throb { api.get "/repos/#{repo}/issues/#{issue}" }.body + page do + puts format_issue(i) + n = i['comments'] + if n > 0 + puts "#{n} comment#{'s' unless n == 1}:\n\n" + Comment.execute %W(-l #{issue} -- #{repo}) + end + break + end + end + end + end + end +end +module GHI + module Commands + module Version + MAJOR = 0 + MINOR = 9 + PATCH = 0 + PRE = 20120731 + + VERSION = [MAJOR, MINOR, PATCH, PRE].compact.join '.' + + def self.execute args + puts "ghi version #{VERSION}" + end + end + end +end +#!/usr/bin/env ruby +GHI.execute ARGV