abt-cli 0.0.3 → 0.0.8

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/bin/abt +2 -0
  3. data/lib/abt/cli.rb +16 -7
  4. data/lib/abt/cli/dialogs.rb +18 -2
  5. data/lib/abt/cli/io.rb +8 -6
  6. data/lib/abt/docs.rb +16 -5
  7. data/lib/abt/docs/cli.rb +1 -1
  8. data/lib/abt/docs/markdown.rb +1 -1
  9. data/lib/abt/git_config.rb +55 -49
  10. data/lib/abt/providers/asana/api.rb +1 -1
  11. data/lib/abt/providers/asana/base_command.rb +9 -4
  12. data/lib/abt/providers/asana/commands/current.rb +10 -4
  13. data/lib/abt/providers/asana/commands/finalize.rb +71 -0
  14. data/lib/abt/providers/asana/commands/harvest_time_entry_data.rb +2 -2
  15. data/lib/abt/providers/asana/commands/init.rb +10 -3
  16. data/lib/abt/providers/asana/commands/{pick_task.rb → pick.rb} +13 -6
  17. data/lib/abt/providers/asana/commands/projects.rb +9 -2
  18. data/lib/abt/providers/asana/commands/share.rb +29 -0
  19. data/lib/abt/providers/asana/commands/start.rb +51 -6
  20. data/lib/abt/providers/asana/commands/tasks.rb +4 -1
  21. data/lib/abt/providers/asana/configuration.rb +54 -34
  22. data/lib/abt/providers/harvest.rb +9 -51
  23. data/lib/abt/providers/harvest/api.rb +62 -0
  24. data/lib/abt/providers/harvest/base_command.rb +12 -16
  25. data/lib/abt/providers/harvest/commands/clear.rb +24 -0
  26. data/lib/abt/providers/harvest/commands/clear_global.rb +24 -0
  27. data/lib/abt/providers/harvest/commands/current.rb +83 -0
  28. data/lib/abt/providers/harvest/commands/init.rb +83 -0
  29. data/lib/abt/providers/harvest/commands/pick.rb +51 -0
  30. data/lib/abt/providers/harvest/commands/projects.rb +40 -0
  31. data/lib/abt/providers/harvest/commands/share.rb +29 -0
  32. data/lib/abt/providers/harvest/commands/start.rb +58 -0
  33. data/lib/abt/providers/harvest/commands/stop.rb +58 -0
  34. data/lib/abt/providers/harvest/commands/tasks.rb +45 -0
  35. data/lib/abt/providers/harvest/commands/track.rb +70 -0
  36. data/lib/abt/providers/harvest/configuration.rb +91 -0
  37. data/lib/abt/version.rb +1 -1
  38. metadata +18 -14
  39. data/lib/abt/harvest_client.rb +0 -58
  40. data/lib/abt/providers/asana/commands/move.rb +0 -56
  41. data/lib/abt/providers/harvest/clear.rb +0 -24
  42. data/lib/abt/providers/harvest/clear_global.rb +0 -24
  43. data/lib/abt/providers/harvest/current.rb +0 -79
  44. data/lib/abt/providers/harvest/init.rb +0 -61
  45. data/lib/abt/providers/harvest/pick_task.rb +0 -45
  46. data/lib/abt/providers/harvest/projects.rb +0 -29
  47. data/lib/abt/providers/harvest/start.rb +0 -58
  48. data/lib/abt/providers/harvest/stop.rb +0 -51
  49. data/lib/abt/providers/harvest/tasks.rb +0 -36
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a08875790c31e73415b7e1317db284d1fe5b62ff9d2c3e66449c457c757bfe56
4
- data.tar.gz: cb9857e53d577777371b669f5b253fe48ded2530976fc7c76982d6fef3e660e3
3
+ metadata.gz: 3568d2d1acd067392e3832ee00bb1eb6f1fdcb6d9526fe1f747674c452173543
4
+ data.tar.gz: f189b4c27d9cac1703211dc4c9fefdd33d359149d336d74f67f47aa9e9d96137
5
5
  SHA512:
6
- metadata.gz: e24c4f96b42d0eab847c1c5bdef2b79b3847c51db8b862c236f82d61217b295fcfc754933f4f998510b0377b0847f754cf3a6e2d4f937c73b95a8992ad7962f1
7
- data.tar.gz: 2a41173f1fddbd52084dbc8943b6ebcb893a81a47ffd5a07e1194c4b518d042d6c2407c6babc8774c69822d3093b6d0d306ad864edc06302797002594bad3715
6
+ metadata.gz: c38b239739f77fd58ce6df48e7f4057fb66a1a50f96bb2d7469ef7b0809b085d643face8fdbc82f9bb654ea75e565f60e9629952f9cc894abe9745101fd670bb
7
+ data.tar.gz: f5fdf894aba1ca64f7a815f7aa1e17d2c23e3c690220f54b4cd0351586c293fd7a5716dbac544c9a63be6fb6ea8154e9e53c2e76658406e67f95656c597ab4f6
data/bin/abt CHANGED
@@ -4,6 +4,8 @@
4
4
  require 'dry-inflector'
5
5
  require 'faraday'
6
6
  require 'oj'
7
+ require 'open3'
8
+ require 'stringio'
7
9
 
8
10
  require_relative '../lib/abt.rb'
9
11
 
@@ -31,8 +31,10 @@ module Abt
31
31
  process_providers
32
32
  end
33
33
 
34
- def print_provider_command(provider, arg_str, description)
35
- puts "#{provider}:#{arg_str} # #{description}"
34
+ def print_provider_command(provider, arg_str, description = nil)
35
+ command = "#{provider}:#{arg_str}"
36
+ command += " # #{description}" unless description.nil?
37
+ output.puts command
36
38
  end
37
39
 
38
40
  private
@@ -60,9 +62,14 @@ module Abt
60
62
 
61
63
  return [] if input.nil?
62
64
 
63
- input.split("\n").map do |line|
64
- line.split(' # ').first # Exclude comment part of piped input lines
65
+ # Exclude comment part of piped input lines
66
+ lines_without_comments = input.lines.map do |line|
67
+ line.split(' # ').first
65
68
  end
69
+
70
+ # Allow multiple provider arguments on a single piped input line
71
+ joined_lines = lines_without_comments.join(' ').strip
72
+ joined_lines.split(/\s+/)
66
73
  end
67
74
 
68
75
  def process_providers
@@ -88,12 +95,14 @@ module Abt
88
95
  command = provider.command_class(command_name)
89
96
  return false if command.nil?
90
97
 
91
- if output.isatty
92
- warn "===== #{command_name} #{provider_name}#{arg_str.nil? ? '' : ":#{arg_str}"} =====".upcase
93
- end
98
+ print_command(command_name, provider_name, arg_str) if output.isatty
94
99
 
95
100
  command.new(arg_str: arg_str, cli: self).call
96
101
  true
97
102
  end
103
+
104
+ def print_command(name, provider, arg_str)
105
+ warn "===== #{name} #{provider}#{arg_str.nil? ? '' : ":#{arg_str}"} =====".upcase
106
+ end
98
107
  end
99
108
  end
@@ -4,10 +4,26 @@ module Abt
4
4
  class Cli
5
5
  module Dialogs
6
6
  def prompt(question)
7
- print "#{question}: "
7
+ err_output.print "#{question}: "
8
8
  read_user_input.strip
9
9
  end
10
10
 
11
+ def prompt_boolean(text)
12
+ warn text
13
+
14
+ loop do
15
+ err_output.print '(y / n): '
16
+
17
+ case read_user_input.strip
18
+ when 'y', 'Y' then return true
19
+ when 'n', 'N' then return false
20
+ else
21
+ warn 'Invalid choice'
22
+ next
23
+ end
24
+ end
25
+ end
26
+
11
27
  def prompt_choice(text, options, allow_back_option = false)
12
28
  if options.one?
13
29
  warn "Selected: #{options.first['name']}"
@@ -44,7 +60,7 @@ module Abt
44
60
  end
45
61
 
46
62
  def read_option_number(options_length, allow_back_option)
47
- print "(1-#{options_length}#{allow_back_option ? ', q: back' : ''}): "
63
+ err_output.print "(1-#{options_length}#{allow_back_option ? ', q: back' : ''}): "
48
64
 
49
65
  input = read_user_input
50
66
 
@@ -3,16 +3,18 @@
3
3
  module Abt
4
4
  class Cli
5
5
  module Io
6
- %i[puts print].each do |method_name|
7
- define_method(method_name) do |*args|
8
- output.puts(*args)
9
- end
10
- end
11
-
12
6
  def warn(*args)
13
7
  err_output.puts(*args)
14
8
  end
15
9
 
10
+ def puts(*args)
11
+ output.puts(*args)
12
+ end
13
+
14
+ def print(*args)
15
+ output.print(*args)
16
+ end
17
+
16
18
  def abort(message)
17
19
  raise AbortError, message
18
20
  end
@@ -9,15 +9,26 @@ module Abt
9
9
  class << self
10
10
  def examples # rubocop:disable Metrics/MethodLength
11
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
12
+ 'Getting started:' => {
13
+ 'abt init asana harvest' => 'Setup asana and harvest project git repo in working dir',
14
+ 'abt pick harvest' => 'Pick harvest tasks, for most projects this will stay the same',
15
+ 'abt pick asana | abt start harvest' => 'Pick asana task and start working',
16
+ 'abt stop harvest' => 'Stop time tracker',
17
+ 'abt start asana harvest' => 'Continue working, e.g. after a break',
18
+ 'abt finalize asana' => 'Finalize the selected asana task'
19
+ },
20
+ 'Tracking meetings (without changing the config):' => {
21
+ 'abt tasks asana | grep -i standup | abt track harvest' => 'Track on asana meeting task without changing any configuration',
22
+ 'abt tasks harvest | grep -i comment | abt track harvest' => 'Track on harvest "Comment"-task (will prompt for a comment)'
17
23
  },
18
24
  'Command output can be piped, e.g.:' => {
19
25
  'abt tasks asana | grep -i <name of task>' => nil,
20
26
  'abt tasks asana | grep -i <name of task> | abt start' => nil
27
+ },
28
+ 'Sharing configuration:' => {
29
+ 'abt share asana harvest | tr "\n" " "' => 'Print current configuration',
30
+ 'abt share asana harvest | tr "\n" " " | pbcopy' => 'Copy configuration (mac only)',
31
+ 'abt start <shared configuration>' => 'Start a shared configuration'
21
32
  }
22
33
  }
23
34
  end
@@ -6,7 +6,7 @@ module Abt
6
6
  class << self
7
7
  def content
8
8
  <<~TXT
9
- Usage: abt <command> [<provider:arguments>...]
9
+ Usage: abt <command> [<provider[:<arguments>]>...]
10
10
 
11
11
  #{example_commands}
12
12
 
@@ -10,7 +10,7 @@ module Abt
10
10
  This readme was generated with `abt help-md > README.md`
11
11
 
12
12
  ## Usage
13
- `abt <command> [<provider:arguments>...]`
13
+ `abt <command> [<provider[:<arguments>]>...]`
14
14
 
15
15
  #{example_commands}
16
16
 
@@ -2,76 +2,82 @@
2
2
 
3
3
  module Abt
4
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
5
+ attr_reader :namespace, :scope
13
6
 
14
- def prompt_local(*args)
15
- prompt_for_config(true, *args)
7
+ def self.local_available?
8
+ @local_available ||= begin
9
+ status = nil
10
+ Open3.popen3('git config --local -l') do |_i, _o, _e, thread|
11
+ status = thread.value
12
+ end
13
+ status.success?
16
14
  end
15
+ end
17
16
 
18
- def prompt_global(*args)
19
- prompt_for_config(false, *args)
20
- end
17
+ def initialize(namespace: '', scope: 'local')
18
+ @namespace = namespace
21
19
 
22
- def unset_local(key)
23
- unset(true, key)
20
+ unless %w[local global].include? scope
21
+ raise ArgumentError, 'scope must be "local" or "global"'
24
22
  end
25
23
 
26
- def unset_global(key)
27
- unset(false, key)
28
- end
24
+ @scope = scope
25
+ end
29
26
 
30
- private
27
+ def [](key)
28
+ get(key)
29
+ end
31
30
 
32
- def unset(local, key)
33
- `git config --#{local ? 'local' : 'global'} --unset #{key.inspect}`
34
- end
31
+ def []=(key, value)
32
+ set(key, value)
33
+ end
35
34
 
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
35
+ def local
36
+ @local ||= begin
37
+ if scope == 'local'
38
+ self
40
39
  else
41
- git_value = `git config --get #{key.inspect}`.strip
42
- git_value.empty? ? nil : git_value
40
+ self.class.new(namespace: namespace, scope: 'local')
43
41
  end
44
42
  end
43
+ end
45
44
 
46
- def prompt(msg)
47
- STDERR.print "#{msg} > "
48
- value = read_user_input.strip
49
- warn
50
- value
45
+ def global
46
+ @global ||= begin
47
+ if scope == 'global'
48
+ self
49
+ else
50
+ self.class.new(namespace: namespace, scope: 'global')
51
+ end
51
52
  end
53
+ end
52
54
 
53
- def prompt_for_config(local, key, prompt_msg, remedy = '') # rubocop:disable Metrics/MethodLength
54
- value = git_config(local, key)
55
+ private
55
56
 
56
- return value unless value == '' || value.nil?
57
+ def key_with_namespace(key)
58
+ namespace.empty? ? key : "#{namespace}.#{key}"
59
+ end
57
60
 
58
- warn <<~TXT
59
- Missing git config "#{key}":
60
- To find this value:
61
- #{remedy}
62
- TXT
61
+ def get(key)
62
+ if scope == 'local' && !self.class.local_available?
63
+ raise StandardError, 'Local configuration is not available outside a git repository'
64
+ end
63
65
 
64
- new_value = prompt(prompt_msg)
66
+ git_value = `git config --#{scope} --get #{key_with_namespace(key).inspect}`.strip
67
+ git_value.empty? ? nil : git_value
68
+ end
65
69
 
66
- if new_value.empty?
67
- abort 'Empty value, aborting'
68
- else
69
- git_config(local, key, new_value)
70
- end
70
+ def set(key, value)
71
+ if scope == 'local' && !self.class.local_available?
72
+ raise StandardError, 'Local configuration is not available outside a git repository'
71
73
  end
72
74
 
73
- def read_user_input
74
- open('/dev/tty', &:gets)
75
+ if value.nil? || value.empty?
76
+ `git config --#{scope} --unset #{key_with_namespace(key).inspect}`
77
+ nil
78
+ else
79
+ `git config --#{scope} --replace-all #{key_with_namespace(key).inspect} #{value.inspect}`
80
+ value
75
81
  end
76
82
  end
77
83
  end
@@ -5,7 +5,7 @@ module Abt
5
5
  module Asana
6
6
  class Api
7
7
  API_ENDPOINT = 'https://app.asana.com/api/1.0'
8
- VERBS = %i[get post patch].freeze
8
+ VERBS = %i[get post put].freeze
9
9
 
10
10
  attr_reader :access_token
11
11
 
@@ -20,19 +20,24 @@ module Abt
20
20
 
21
21
  private
22
22
 
23
+ def same_args_as_config?
24
+ project_gid == config.project_gid && task_gid == config.task_gid
25
+ end
26
+
23
27
  def print_project(project)
24
28
  cli.print_provider_command('asana', project['gid'], project['name'])
29
+ cli.warn project['permalink_url'] if project.key?('permalink_url') && cli.output.isatty
25
30
  end
26
31
 
27
32
  def print_task(project, task)
33
+ project = { 'gid' => project } if project.is_a?(String)
28
34
  cli.print_provider_command('asana', "#{project['gid']}/#{task['gid']}", task['name'])
35
+ cli.warn task['permalink_url'] if task.key?('permalink_url') && cli.output.isatty
29
36
  end
30
37
 
31
38
  def use_current_args
32
- @project_gid = Abt::GitConfig.local('abt.asana.projectGid').to_s
33
- @project_gid = nil if project_gid.empty?
34
- @task_gid = Abt::GitConfig.local('abt.asana.taskGid').to_s
35
- @task_gid = nil if task_gid.empty?
39
+ @project_gid = config.project_gid
40
+ @task_gid = config.task_gid
36
41
  end
37
42
 
38
43
  def use_arg_str(arg_str)
@@ -14,7 +14,7 @@ module Abt
14
14
  end
15
15
 
16
16
  def call
17
- if arg_str.nil?
17
+ if same_args_as_config? || !config.local_available?
18
18
  show_current_configuration
19
19
  else
20
20
  cli.warn 'Updating configuration'
@@ -43,7 +43,7 @@ module Abt
43
43
  config.task_gid = nil
44
44
  else
45
45
  ensure_task_is_valid!
46
- config.task_gid task_gid
46
+ config.task_gid = task_gid
47
47
 
48
48
  print_task(project, task)
49
49
  end
@@ -58,11 +58,17 @@ module Abt
58
58
  end
59
59
 
60
60
  def project
61
- @project ||= api.get("projects/#{project_gid}")
61
+ @project ||= begin
62
+ cli.warn 'Fetching project...'
63
+ api.get("projects/#{project_gid}", opt_fields: 'name,permalink_url')
64
+ end
62
65
  end
63
66
 
64
67
  def task
65
- @task ||= api.get("tasks/#{task_gid}")
68
+ @task ||= begin
69
+ cli.warn 'Fetching task...'
70
+ api.get("tasks/#{task_gid}", opt_fields: 'name,permalink_url')
71
+ end
66
72
  end
67
73
  end
68
74
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Asana
6
+ module Commands
7
+ class Finalize < BaseCommand
8
+ def self.command
9
+ 'finalize asana[:<project-gid>/<task-gid>]'
10
+ end
11
+
12
+ def self.description
13
+ 'Move current/specified task to section (column) for finalized tasks'
14
+ end
15
+
16
+ def call
17
+ unless config.local_available?
18
+ cli.abort 'This is a no-op for tasks outside the current project'
19
+ end
20
+ cli.abort 'No current or specified task' if task.nil?
21
+ print_task(project_gid, task)
22
+
23
+ if task_already_in_finalized_section?
24
+ cli.warn "Task already in section: #{current_task_section['name']}"
25
+ else
26
+ cli.warn "Moving task to section: #{finalized_section['name']}"
27
+ move_task
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def task_already_in_finalized_section?
34
+ !task_section_membership.nil?
35
+ end
36
+
37
+ def current_task_section
38
+ task_section_membership&.dig('section')
39
+ end
40
+
41
+ def task_section_membership
42
+ task['memberships'].find do |membership|
43
+ membership.dig('section', 'gid') == config.finalized_section_gid
44
+ end
45
+ end
46
+
47
+ def finalized_section
48
+ @finalized_section ||= api.get("sections/#{config.finalized_section_gid}",
49
+ opt_fields: 'name')
50
+ end
51
+
52
+ def move_task
53
+ body = { data: { task: task_gid } }
54
+ body_json = Oj.dump(body, mode: :json)
55
+ api.post("sections/#{config.finalized_section_gid}/addTask", body_json)
56
+ end
57
+
58
+ def task
59
+ @task ||= begin
60
+ if task_gid.nil?
61
+ nil
62
+ else
63
+ api.get("tasks/#{task_gid}", opt_fields: 'name,memberships.section.name,permalink_url')
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end