abt-cli 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/bin/abt +14 -0
  3. data/lib/abt.rb +5 -0
  4. data/lib/abt/asana_client.rb +53 -0
  5. data/lib/abt/cli.rb +103 -0
  6. data/lib/abt/cli/dialogs.rb +67 -0
  7. data/lib/abt/git_config.rb +78 -0
  8. data/lib/abt/harvest_client.rb +58 -0
  9. data/lib/abt/help.rb +56 -0
  10. data/lib/abt/help/cli.rb +59 -0
  11. data/lib/abt/help/markdown.rb +66 -0
  12. data/lib/abt/http_error.rb +45 -0
  13. data/lib/abt/providers.rb +5 -0
  14. data/lib/abt/providers/asana.rb +60 -0
  15. data/lib/abt/providers/asana/base_command.rb +62 -0
  16. data/lib/abt/providers/asana/clear.rb +24 -0
  17. data/lib/abt/providers/asana/clear_global.rb +24 -0
  18. data/lib/abt/providers/asana/current.rb +69 -0
  19. data/lib/abt/providers/asana/harvest_link_entry_data.rb +48 -0
  20. data/lib/abt/providers/asana/init.rb +62 -0
  21. data/lib/abt/providers/asana/move.rb +54 -0
  22. data/lib/abt/providers/asana/pick_task.rb +46 -0
  23. data/lib/abt/providers/asana/projects.rb +30 -0
  24. data/lib/abt/providers/asana/start.rb +22 -0
  25. data/lib/abt/providers/asana/tasks.rb +35 -0
  26. data/lib/abt/providers/harvest.rb +52 -0
  27. data/lib/abt/providers/harvest/base_command.rb +70 -0
  28. data/lib/abt/providers/harvest/clear.rb +24 -0
  29. data/lib/abt/providers/harvest/clear_global.rb +24 -0
  30. data/lib/abt/providers/harvest/current.rb +79 -0
  31. data/lib/abt/providers/harvest/init.rb +61 -0
  32. data/lib/abt/providers/harvest/pick_task.rb +45 -0
  33. data/lib/abt/providers/harvest/projects.rb +29 -0
  34. data/lib/abt/providers/harvest/start.rb +58 -0
  35. data/lib/abt/providers/harvest/stop.rb +51 -0
  36. data/lib/abt/providers/harvest/tasks.rb +36 -0
  37. data/lib/abt/version.rb +5 -0
  38. metadata +138 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 929ab52c8ebd307b90ddb7daa14f2f63eeeebda8ed9905490a13dcd2f67770da
4
+ data.tar.gz: e2582299b29e39faa8d4baccaba42954e7140a447d30c3c56a92507ac00f4a09
5
+ SHA512:
6
+ metadata.gz: dec30158debfe3e97c4969d8724d5f56d04725f0cab1aedf79840bb01ef81d9f17c5245cc538c559e47bfa245f36c5aeed8d02dc6ff21fcbae46b30f79e88c5b
7
+ data.tar.gz: c74d6a7ae33ff5843385796cf21d764de61d0981501b5ac7e24691a7c61c85ae6f646abacd1f1b74417d29559ae022881969dd71598acf1190487c6b64c0af02
data/bin/abt ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'dry-inflector'
5
+ require 'faraday'
6
+ require 'oj'
7
+
8
+ require_relative '../lib/abt.rb'
9
+
10
+ begin
11
+ Abt::Cli.new(ARGV).perform
12
+ rescue Interrupt
13
+ abort 'Aborted'
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob("#{File.dirname(File.absolute_path(__FILE__))}/abt/*.rb").sort.each do |file|
4
+ require file
5
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ class AsanaClient
5
+ API_ENDPOINT = 'https://app.asana.com/api/1.0'
6
+ VERBS = %i[get post patch].freeze
7
+
8
+ attr_reader :access_token
9
+
10
+ def initialize(access_token:)
11
+ @access_token = access_token
12
+ end
13
+
14
+ VERBS.each do |verb|
15
+ define_method(verb) do |*args|
16
+ request(verb, *args)['data']
17
+ end
18
+ end
19
+
20
+ def get_paged(path, query = {})
21
+ records = []
22
+
23
+ loop do
24
+ result = request(:get, path, query.merge(limit: 100))
25
+ records += result['data']
26
+ break if result['next_page'].nil?
27
+
28
+ path = result['next_page']['path'][1..-1]
29
+ end
30
+
31
+ records
32
+ end
33
+
34
+ def request(*args)
35
+ response = connection.public_send(*args)
36
+
37
+ if response.success?
38
+ Oj.load(response.body)
39
+ else
40
+ error_class = Abt::HttpError.error_class_for_status(response.status)
41
+ encoded_response_body = response.body.force_encoding('utf-8')
42
+ raise error_class, "Code: #{response.status}, body: #{encoded_response_body}"
43
+ end
44
+ end
45
+
46
+ def connection
47
+ @connection ||= Faraday.new(API_ENDPOINT) do |connection|
48
+ connection.headers['Authorization'] = "Bearer #{access_token}"
49
+ connection.headers['Content-Type'] = 'application/json'
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob("#{File.expand_path(__dir__)}/cli/*.rb").sort.each do |file|
4
+ require file
5
+ end
6
+
7
+ module Abt
8
+ class Cli
9
+ include Dialogs
10
+
11
+ attr_reader :command, :args
12
+
13
+ def initialize(argv)
14
+ (@command, *@args) = argv
15
+
16
+ @args += args_from_stdin unless STDIN.isatty # Add piped arguments
17
+ end
18
+
19
+ def perform(command: @command, args: @args)
20
+ handle_global_commands!
21
+
22
+ abort('No provider arguments') if args.empty?
23
+
24
+ process_providers(command: command, args: args)
25
+ end
26
+
27
+ def print_provider_command(provider, arg_str, description)
28
+ puts "#{provider}:#{arg_str} # #{description}"
29
+ end
30
+
31
+ private
32
+
33
+ def handle_global_commands! # rubocop:disable Metrics/MethodLength
34
+ case command
35
+ when nil
36
+ warn("No command specified\n\n")
37
+ puts(Abt::Help::Cli.content)
38
+ exit
39
+ when '--help', '-h', 'help', 'commands'
40
+ puts(Abt::Help::Cli.content)
41
+ exit
42
+ when 'help-md'
43
+ puts(Abt::Help::Markdown.content)
44
+ exit
45
+ when '--version', '-v', 'version'
46
+ puts(Abt::VERSION)
47
+ exit
48
+ end
49
+ end
50
+
51
+ def args_from_stdin
52
+ input = STDIN.read
53
+
54
+ return [] if input.nil?
55
+
56
+ input.split("\n").map do |line|
57
+ line.split(' # ').first # Exclude comment part of piped input lines
58
+ end
59
+ end
60
+
61
+ def process_providers(command:, args:)
62
+ used_providers = []
63
+ args.each do |provider_args|
64
+ (provider, arg_str) = provider_args.split(':')
65
+
66
+ if used_providers.include?(provider)
67
+ warn "Dropping command for already used provider: #{provider_args}"
68
+ next
69
+ end
70
+
71
+ used_providers << provider if process_provider_command(provider, command, arg_str)
72
+ end
73
+
74
+ warn 'No matching providers found for command' if used_providers.empty?
75
+ end
76
+
77
+ def process_provider_command(provider, command, arg_str)
78
+ command_class = class_for_provider_and_command(provider, command)
79
+
80
+ return false unless command_class
81
+
82
+ if STDOUT.isatty
83
+ warn "===== #{command} #{provider}#{arg_str.nil? ? '' : ":#{arg_str}"} =====".upcase
84
+ end
85
+
86
+ command_class.new(arg_str: arg_str, cli: self).call
87
+ true
88
+ end
89
+
90
+ def class_for_provider_and_command(provider, command)
91
+ inflector = Dry::Inflector.new
92
+ provider_class_name = inflector.camelize(inflector.underscore(provider))
93
+
94
+ return unless Abt::Providers.const_defined? provider_class_name
95
+
96
+ provider_class = Abt::Providers.const_get provider_class_name
97
+ command_class_name = inflector.camelize(inflector.underscore(command))
98
+ return unless provider_class.const_defined? command_class_name
99
+
100
+ provider_class.const_get command_class_name
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ class Cli
5
+ module Dialogs
6
+ def prompt(question)
7
+ STDERR.print "#{question}: "
8
+ read_user_input.strip
9
+ end
10
+
11
+ def prompt_choice(text, options, allow_back_option = false)
12
+ if options.one?
13
+ warn "Selected: #{options.first['name']}"
14
+ return options.first
15
+ end
16
+
17
+ warn "#{text}:"
18
+
19
+ print_options(options)
20
+ select_options(options, allow_back_option)
21
+ end
22
+
23
+ private
24
+
25
+ def print_options(options)
26
+ options.each_with_index do |option, index|
27
+ warn "(#{index + 1}) #{option['name']}"
28
+ end
29
+ end
30
+
31
+ def select_options(options, allow_back_option)
32
+ while (number = read_option_number(options.length, allow_back_option))
33
+ if number.nil?
34
+ return nil if allow_back_option
35
+
36
+ abort
37
+ end
38
+
39
+ option = options[number - 1]
40
+
41
+ warn "Selected: (#{number}) #{option['name']}"
42
+ return option
43
+ end
44
+ end
45
+
46
+ def read_option_number(options_length, allow_back_option)
47
+ STDERR.print "(1-#{options_length}#{allow_back_option ? ', q: back' : ''}): "
48
+
49
+ input = read_user_input
50
+
51
+ return nil if allow_back_option && input == 'q'
52
+
53
+ option_number = input.to_i
54
+ if option_number <= 0 || option_number > options_length
55
+ warn 'Invalid selection'
56
+ return nil
57
+ end
58
+
59
+ option_number
60
+ end
61
+
62
+ def read_user_input
63
+ open('/dev/tty', &:gets).strip
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ class GitConfig
5
+ class << self
6
+ def local(*args)
7
+ git_config(true, *args)
8
+ end
9
+
10
+ def global(*args)
11
+ git_config(false, *args)
12
+ end
13
+
14
+ def prompt_local(*args)
15
+ prompt_for_config(true, *args)
16
+ end
17
+
18
+ def prompt_global(*args)
19
+ prompt_for_config(false, *args)
20
+ end
21
+
22
+ def unset_local(key)
23
+ unset(true, key)
24
+ end
25
+
26
+ def unset_global(key)
27
+ unset(false, key)
28
+ end
29
+
30
+ private
31
+
32
+ def unset(local, key)
33
+ `git config --#{local ? 'local' : 'global'} --unset #{key.inspect}`
34
+ end
35
+
36
+ def git_config(local, key, value = nil)
37
+ if value
38
+ `git config --#{local ? 'local' : 'global'} --replace-all #{key.inspect} #{value.inspect}`
39
+ value
40
+ else
41
+ git_value = `git config --get #{key.inspect}`.strip
42
+ git_value.empty? ? nil : git_value
43
+ end
44
+ end
45
+
46
+ def prompt(msg)
47
+ STDERR.print "#{msg} > "
48
+ value = read_user_input.strip
49
+ warn
50
+ value
51
+ end
52
+
53
+ def prompt_for_config(local, key, prompt_msg, remedy = '') # rubocop:disable Metrics/MethodLength
54
+ value = git_config(local, key)
55
+
56
+ return value unless value == '' || value.nil?
57
+
58
+ warn <<~TXT
59
+ Missing git config "#{key}":
60
+ To find this value:
61
+ #{remedy}
62
+ TXT
63
+
64
+ new_value = prompt(prompt_msg)
65
+
66
+ if new_value.empty?
67
+ abort 'Empty value, aborting'
68
+ else
69
+ git_config(local, key, new_value)
70
+ end
71
+ end
72
+
73
+ def read_user_input
74
+ open('/dev/tty', &:gets)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ class HarvestClient
5
+ API_ENDPOINT = 'https://api.harvestapp.com/v2'
6
+ VERBS = %i[get post patch].freeze
7
+
8
+ attr_reader :access_token, :account_id
9
+
10
+ def initialize(access_token:, account_id:)
11
+ @access_token = access_token
12
+ @account_id = account_id
13
+ end
14
+
15
+ VERBS.each do |verb|
16
+ define_method(verb) do |*args|
17
+ request(verb, *args)
18
+ end
19
+ end
20
+
21
+ def get_paged(path, query = {})
22
+ result_key = path.split('?').first.split('/').last
23
+
24
+ page = 1
25
+ records = []
26
+
27
+ loop do
28
+ result = get(path, query.merge(page: page))
29
+ records += result[result_key]
30
+ break if result['total_pages'] == page
31
+
32
+ page += 1
33
+ end
34
+
35
+ records
36
+ end
37
+
38
+ def request(*args)
39
+ response = connection.public_send(*args)
40
+
41
+ if response.success?
42
+ Oj.load(response.body)
43
+ else
44
+ error_class = Abt::HttpError.error_class_for_status(response.status)
45
+ encoded_response_body = response.body.force_encoding('utf-8')
46
+ raise error_class, "Code: #{response.status}, body: #{encoded_response_body}"
47
+ end
48
+ end
49
+
50
+ def connection
51
+ @connection ||= Faraday.new(API_ENDPOINT) do |connection|
52
+ connection.headers['Authorization'] = "Bearer #{access_token}"
53
+ connection.headers['Harvest-Account-Id'] = account_id
54
+ connection.headers['Content-Type'] = 'application/json'
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob("#{File.expand_path(__dir__)}/help/*.rb").sort.each do |file|
4
+ require file
5
+ end
6
+
7
+ module Abt
8
+ module Help
9
+ class << self
10
+ def examples # rubocop:disable Metrics/MethodLength
11
+ {
12
+ 'Multiple providers and arguments can be passed, e.g.:' => {
13
+ 'abt init asana harvest' => nil,
14
+ 'abt pick-task asana harvest' => nil,
15
+ 'abt start asana harvest' => nil,
16
+ 'abt clear asana harvest' => nil
17
+ },
18
+ 'Command output can be piped, e.g.:' => {
19
+ 'abt tasks asana | grep -i <name of task>' => nil,
20
+ 'abt tasks asana | grep -i <name of task> | abt start' => nil
21
+ }
22
+ }
23
+ end
24
+
25
+ def providers
26
+ provider_definitions
27
+ end
28
+
29
+ private
30
+
31
+ def commandize(string)
32
+ string = string.to_s
33
+ string[0] = string[0].downcase
34
+ string.gsub(/([A-Z])/, '-\1').downcase
35
+ end
36
+
37
+ def provider_definitions
38
+ Abt::Providers.constants.sort.each_with_object({}) do |provider_name, definition|
39
+ provider_class = Abt::Providers.const_get(provider_name)
40
+
41
+ definition[commandize(provider_name)] = command_definitions(provider_class)
42
+ end
43
+ end
44
+
45
+ def command_definitions(provider_class)
46
+ provider_class.constants.sort.each_with_object({}) do |command_name, definition|
47
+ command_class = provider_class.const_get(command_name)
48
+
49
+ if command_class.respond_to?(:command) && command_class.respond_to?(:description)
50
+ definition[command_class.command] = command_class.description
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end