abt-cli 0.0.3 → 0.0.4

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/bin/abt +1 -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 +12 -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 +8 -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 +26 -0
  26. data/lib/abt/providers/harvest/commands/clear_global.rb +24 -0
  27. data/lib/abt/providers/harvest/commands/current.rb +81 -0
  28. data/lib/abt/providers/harvest/commands/init.rb +66 -0
  29. data/lib/abt/providers/harvest/commands/pick.rb +49 -0
  30. data/lib/abt/providers/harvest/commands/projects.rb +34 -0
  31. data/lib/abt/providers/harvest/commands/share.rb +29 -0
  32. data/lib/abt/providers/harvest/commands/start.rb +81 -0
  33. data/lib/abt/providers/harvest/commands/stop.rb +58 -0
  34. data/lib/abt/providers/harvest/commands/tasks.rb +38 -0
  35. data/lib/abt/providers/harvest/configuration.rb +90 -0
  36. data/lib/abt/version.rb +1 -1
  37. metadata +17 -14
  38. data/lib/abt/harvest_client.rb +0 -58
  39. data/lib/abt/providers/asana/commands/move.rb +0 -56
  40. data/lib/abt/providers/harvest/clear.rb +0 -24
  41. data/lib/abt/providers/harvest/clear_global.rb +0 -24
  42. data/lib/abt/providers/harvest/current.rb +0 -79
  43. data/lib/abt/providers/harvest/init.rb +0 -61
  44. data/lib/abt/providers/harvest/pick_task.rb +0 -45
  45. data/lib/abt/providers/harvest/projects.rb +0 -29
  46. data/lib/abt/providers/harvest/start.rb +0 -58
  47. data/lib/abt/providers/harvest/stop.rb +0 -51
  48. data/lib/abt/providers/harvest/tasks.rb +0 -36
@@ -13,7 +13,7 @@ module Abt
13
13
  'Print Harvest time entry data for Asana task as json. Used by harvest start script.'
14
14
  end
15
15
 
16
- def call
16
+ def call # rubocop:disable Metrics/MethodLength
17
17
  ensure_current_is_valid!
18
18
 
19
19
  body = {
@@ -41,7 +41,7 @@ module Abt
41
41
  end
42
42
 
43
43
  def task
44
- @task ||= api.get("tasks/#{task_gid}")
44
+ @task ||= api.get("tasks/#{task_gid}", opt_fields: 'name,permalink_url,memberships.project')
45
45
  end
46
46
  end
47
47
  end
@@ -14,7 +14,7 @@ module Abt
14
14
  end
15
15
 
16
16
  def call
17
- cli.warn 'Loading projects'
17
+ cli.abort 'Must be run inside a git repository' unless config.local_available?
18
18
 
19
19
  projects # Load projects up front to make it obvious that searches are instant
20
20
  project = find_search_result
@@ -53,8 +53,13 @@ module Abt
53
53
  end
54
54
 
55
55
  def projects
56
- @projects ||=
57
- api.get_paged('projects', workspace: config.workspace_gid, archived: false)
56
+ @projects ||= begin
57
+ cli.warn 'Fetching projects...'
58
+ api.get_paged('projects',
59
+ workspace: config.workspace_gid,
60
+ archived: false,
61
+ opt_fields: 'name,permalink_url')
62
+ end
58
63
  end
59
64
  end
60
65
  end
@@ -4,9 +4,9 @@ module Abt
4
4
  module Providers
5
5
  module Asana
6
6
  module Commands
7
- class PickTask < BaseCommand
7
+ class Pick < BaseCommand
8
8
  def self.command
9
- 'pick-task asana[:<project-gid>]'
9
+ 'pick asana[:<project-gid>]'
10
10
  end
11
11
 
12
12
  def self.description
@@ -14,7 +14,10 @@ module Abt
14
14
  end
15
15
 
16
16
  def call
17
+ cli.abort 'Must be run inside a git repository' unless config.local_available?
18
+
17
19
  cli.warn project['name']
20
+
18
21
  task = cli.prompt_choice 'Select a task', tasks
19
22
 
20
23
  config.project_gid = project_gid # We might have gotten the project ID as an argument
@@ -32,14 +35,18 @@ module Abt
32
35
  def tasks
33
36
  @tasks ||= begin
34
37
  section = cli.prompt_choice 'Which section?', sections
35
- api.get_paged('tasks', section: section['gid'])
38
+ cli.warn 'Fetching tasks...'
39
+ api.get_paged('tasks', section: section['gid'], opt_fields: 'name,permalink_url')
36
40
  end
37
41
  end
38
42
 
39
43
  def sections
40
- api.get_paged("projects/#{project_gid}/sections")
41
- rescue Abt::HttpError::HttpError
42
- []
44
+ @sections ||= begin
45
+ cli.warn 'Fetching sections...'
46
+ api.get_paged("projects/#{project_gid}/sections", opt_fields: 'name')
47
+ rescue Abt::HttpError::HttpError
48
+ []
49
+ end
43
50
  end
44
51
  end
45
52
  end
@@ -22,8 +22,15 @@ module Abt
22
22
  private
23
23
 
24
24
  def projects
25
- @projects ||=
26
- api.get_paged('projects', workspace: config.workspace_gid, archived: false)
25
+ @projects ||= begin
26
+ cli.warn 'Fetching projects...'
27
+ api.get_paged(
28
+ 'projects',
29
+ workspace: config.workspace_gid,
30
+ archived: false,
31
+ opt_fields: 'name'
32
+ )
33
+ end
27
34
  end
28
35
  end
29
36
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Asana
6
+ module Commands
7
+ class Share < BaseCommand
8
+ def self.command
9
+ 'share asana[:<project-gid>[/<task-gid>]]'
10
+ end
11
+
12
+ def self.description
13
+ 'Print project/task config string'
14
+ end
15
+
16
+ def call
17
+ if project_gid.nil?
18
+ cli.warn 'No project selected'
19
+ elsif task_gid.nil?
20
+ cli.print_provider_command('asana', project_gid)
21
+ else
22
+ cli.print_provider_command('asana', "#{project_gid}/#{task_gid}")
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -14,18 +14,53 @@ module Abt
14
14
  end
15
15
 
16
16
  def call
17
- Current.new(arg_str: arg_str, cli: cli).call unless arg_str.nil?
17
+ abort 'No current/provided task' if task_gid.nil?
18
+
19
+ maybe_override_current_task
20
+
21
+ update_assignee_if_needed
22
+ move_if_needed
23
+ end
24
+
25
+ private
26
+
27
+ def maybe_override_current_task
28
+ return if arg_str.nil?
29
+ return if same_args_as_config?
30
+ return unless config.local_available?
31
+
32
+ should_override = cli.prompt_boolean 'Set selected task as current?'
33
+ Current.new(arg_str: arg_str, cli: cli).call if should_override
34
+ end
35
+
36
+ def update_assignee_if_needed
37
+ current_assignee = task.dig('assignee')
38
+
39
+ if current_assignee.nil?
40
+ cli.warn "Assigning task to user: #{current_user['name']}"
41
+ update_assignee
42
+ elsif current_assignee['gid'] == current_user['gid']
43
+ cli.warn 'You are already assigned to this task'
44
+ elsif cli.prompt_boolean "Task is assigned to: #{current_assignee['name']}, take over?"
45
+ cli.warn "Reassigning task to user: #{current_user['name']}"
46
+ update_assignee
47
+ end
48
+ end
49
+
50
+ def move_if_needed
51
+ unless project_gid == config.project_gid
52
+ cli.warn 'Task was not moved, this is not implemented for tasks outside current project'
53
+ return
54
+ end
18
55
 
19
56
  if task_already_in_wip_section?
20
- cli.warn "Task already in #{current_task_section['name']}"
57
+ cli.warn "Task already in section: #{current_task_section['name']}"
21
58
  else
22
- cli.warn "Moving task to #{wip_section['name']}"
59
+ cli.warn "Moving task to section: #{wip_section['name']}"
23
60
  move_task
24
61
  end
25
62
  end
26
63
 
27
- private
28
-
29
64
  def task_already_in_wip_section?
30
65
  !task_section_membership.nil?
31
66
  end
@@ -50,8 +85,18 @@ module Abt
50
85
  api.post("sections/#{config.wip_section_gid}/addTask", body_json)
51
86
  end
52
87
 
88
+ def update_assignee
89
+ body = { data: { assignee: current_user['gid'] } }
90
+ body_json = Oj.dump(body, mode: :json)
91
+ api.put("tasks/#{task_gid}", body_json)
92
+ end
93
+
94
+ def current_user
95
+ @current_user ||= api.get('users/me', opt_fields: 'name')
96
+ end
97
+
53
98
  def task
54
- @task ||= api.get("tasks/#{task_gid}")
99
+ @task ||= api.get("tasks/#{task_gid}", opt_fields: 'name,memberships.section.name,assignee.name')
55
100
  end
56
101
  end
57
102
  end
@@ -28,7 +28,10 @@ module Abt
28
28
  end
29
29
 
30
30
  def tasks
31
- @tasks ||= api.get_paged('tasks', project: project['gid'])
31
+ @tasks ||= begin
32
+ cli.warn 'Fetching tasks...'
33
+ api.get_paged('tasks', project: project['gid'], opt_fields: 'name')
34
+ end
32
35
  end
33
36
  end
34
37
  end
@@ -8,19 +8,24 @@ module Abt
8
8
 
9
9
  def initialize(cli:)
10
10
  @cli = cli
11
+ @git = GitConfig.new(namespace: 'abt.asana')
12
+ end
13
+
14
+ def local_available?
15
+ GitConfig.local_available?
11
16
  end
12
17
 
13
18
  def project_gid
14
- Abt::GitConfig.local('abt.asana.projectGid')
19
+ local_available? ? git['projectGid'] : nil
15
20
  end
16
21
 
17
22
  def task_gid
18
- Abt::GitConfig.local('abt.asana.taskGid')
23
+ local_available? ? git['taskGid'] : nil
19
24
  end
20
25
 
21
26
  def workspace_gid
22
27
  @workspace_gid ||= begin
23
- current = Abt::GitConfig.global('abt.asana.workspaceGid')
28
+ current = git.global['workspaceGid']
24
29
  if current.nil?
25
30
  prompt_workspace['gid']
26
31
  else
@@ -30,69 +35,84 @@ module Abt
30
35
  end
31
36
 
32
37
  def wip_section_gid
33
- @wip_section_gid ||= begin
34
- current = Abt::GitConfig.global('abt.asana.wipSectionGid')
35
- if current.nil?
36
- prompt_wip_section['gid']
37
- else
38
- current
39
- end
40
- end
38
+ return nil unless local_available?
39
+
40
+ @wip_section_gid ||= git['wipSectionGid'] || prompt_wip_section['gid']
41
+ end
42
+
43
+ def finalized_section_gid
44
+ return nil unless local_available?
45
+
46
+ @finalized_section_gid ||= git['finalizedSectionGid'] || prompt_finalized_section['gid']
41
47
  end
42
48
 
43
49
  def project_gid=(value)
44
50
  return if project_gid == value
45
51
 
46
52
  clear_local
47
- Abt::GitConfig.local('abt.asana.projectGid', value) unless value.nil?
53
+ git['projectGid'] = value unless value.nil?
48
54
  end
49
55
 
50
56
  def task_gid=(value)
51
- if value.nil?
52
- Abt::GitConfig.unset_local('abt.asana.taskGid')
53
- elsif task_gid != value
54
- Abt::GitConfig.local('abt.asana.taskGid', value)
55
- end
57
+ git['taskGid'] = value
56
58
  end
57
59
 
58
60
  def clear_local
59
- Abt::GitConfig.unset_local('abt.asana.projectGid')
60
- Abt::GitConfig.unset_local('abt.asana.taskGid')
61
- Abt::GitConfig.unset_local('abt.asana.wipSectionGid')
61
+ cli.abort 'No local configuration was found' unless local_available?
62
+
63
+ git['projectGid'] = nil
64
+ git['taskGid'] = nil
65
+ git['wipSectionGid'] = nil
66
+ git['finalizedSectionGid'] = nil
62
67
  end
63
68
 
64
69
  def clear_global
65
- Abt::GitConfig.unset_global('abt.asana.workspaceGid')
66
- Abt::GitConfig.unset_global('abt.asana.accessToken')
70
+ git.global['workspaceGid'] = nil
71
+ git.global['accessToken'] = nil
67
72
  end
68
73
 
69
74
  def access_token
70
- Abt::GitConfig.prompt_global(
71
- 'abt.asana.accessToken',
72
- 'Please enter your personal asana access_token',
73
- 'Create a personal access token here: https://app.asana.com/0/developer-console'
74
- )
75
+ return git.global['accessToken'] unless git.global['accessToken'].nil?
76
+
77
+ git.global['accessToken'] = cli.prompt([
78
+ 'Please provide your personal access token for Asana.',
79
+ 'If you don\'t have one, create one here: https://app.asana.com/0/developer-console',
80
+ '',
81
+ 'Enter access token'
82
+ ].join("\n"))
75
83
  end
76
84
 
77
85
  private
78
86
 
79
- def prompt_wip_section
80
- sections = api.get_paged("projects/#{project_gid}/sections")
87
+ attr_reader :git
81
88
 
82
- section = cli.prompt_choice('Select WIP (Work In Progress) section', sections)
83
- Abt::GitConfig.global('abt.asana.wipSectionGid', section['gid'])
89
+ def prompt_finalized_section
90
+ section = prompt_section('Select section for finalized tasks (E.g. "Merged")')
91
+ git['finalizedSectionGid'] = section['gid']
84
92
  section
85
93
  end
86
94
 
95
+ def prompt_wip_section
96
+ section = prompt_section('Select WIP (Work In Progress) section')
97
+ git['wipSectionGid'] = section['gid']
98
+ section
99
+ end
100
+
101
+ def prompt_section(message)
102
+ cli.warn 'Fetching sections...'
103
+ sections = api.get_paged("projects/#{project_gid}/sections")
104
+ cli.prompt_choice(message, sections)
105
+ end
106
+
87
107
  def prompt_workspace
108
+ cli.warn 'Fetching workspaces...'
88
109
  workspaces = api.get_paged('workspaces')
89
110
  if workspaces.empty?
90
111
  cli.abort 'Your asana access token does not have access to any workspaces'
91
112
  end
92
113
 
93
- # TODO: Handle if there are multiple workspaces
94
- workspace = workspaces.first
95
- Abt::GitConfig.global('abt.asana.workspaceGid', workspace['gid'])
114
+ workspace = cli.prompt_choice('Select Asana workspace', workspaces)
115
+ git.global['workspaceGid'] = workspace['gid']
96
116
  workspace
97
117
  end
98
118
 
@@ -1,60 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Dir.glob("#{File.expand_path(__dir__)}/harvest/*.rb").sort.each do |file|
4
- require file
5
- end
3
+ Dir.glob("#{File.expand_path(__dir__)}/harvest/*.rb").sort.each { |file| require file }
4
+ Dir.glob("#{File.expand_path(__dir__)}/harvest/commands/*.rb").sort.each { |file| require file }
6
5
 
7
6
  module Abt
8
7
  module Providers
9
- class Harvest
10
- class << self
11
- def command_names
12
- constants.sort.map { |constant_name| Helpers.const_to_command(constant_name) }
13
- end
14
-
15
- def command_class(name)
16
- const_name = Helpers.command_to_const(name)
17
- const_get(const_name) if const_defined?(const_name)
18
- end
19
-
20
- def user_id
21
- Abt::GitConfig.prompt_global(
22
- 'abt.harvest.userId',
23
- 'Please enter your harvest User ID',
24
- 'In harvest open "My profile". The ID is the number part of the URL you are taken to'
25
- )
26
- end
27
-
28
- def access_token
29
- Abt::GitConfig.prompt_global(
30
- 'abt.harvest.accessToken',
31
- 'Please enter your personal harvest access token',
32
- 'Create your personal access token here: https://id.getharvest.com/developers'
33
- )
34
- end
35
-
36
- def account_id
37
- Abt::GitConfig.prompt_global(
38
- 'abt.harvest.accountId',
39
- 'Please enter the harvest account id',
40
- 'This information is shown next to your generated access token'
41
- )
42
- end
43
-
44
- def clear
45
- Abt::GitConfig.unset_local('abt.harvest.projectId')
46
- Abt::GitConfig.unset_local('abt.harvest.taskId')
47
- end
48
-
49
- def clear_global
50
- Abt::GitConfig.unset_global('abt.harvest.userId')
51
- Abt::GitConfig.unset_global('abt.harvest.accountId')
52
- Abt::GitConfig.unset_global('abt.harvest.accessToken')
53
- end
8
+ module Harvest
9
+ def self.command_names
10
+ Commands.constants.sort.map { |constant_name| Helpers.const_to_command(constant_name) }
11
+ end
54
12
 
55
- def client
56
- @client ||= Abt::HarvestClient.new(access_token: access_token, account_id: account_id)
57
- end
13
+ def self.command_class(name)
14
+ const_name = Helpers.command_to_const(name)
15
+ Commands.const_get(const_name) if Commands.const_defined?(const_name)
58
16
  end
59
17
  end
60
18
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Harvest
6
+ class Api
7
+ API_ENDPOINT = 'https://api.harvestapp.com/v2'
8
+ VERBS = %i[get post patch].freeze
9
+
10
+ attr_reader :access_token, :account_id
11
+
12
+ def initialize(access_token:, account_id:)
13
+ @access_token = access_token
14
+ @account_id = account_id
15
+ end
16
+
17
+ VERBS.each do |verb|
18
+ define_method(verb) do |*args|
19
+ request(verb, *args)
20
+ end
21
+ end
22
+
23
+ def get_paged(path, query = {})
24
+ result_key = path.split('?').first.split('/').last
25
+
26
+ page = 1
27
+ records = []
28
+
29
+ loop do
30
+ result = get(path, query.merge(page: page))
31
+ records += result[result_key]
32
+ break if result['total_pages'] == page
33
+
34
+ page += 1
35
+ end
36
+
37
+ records
38
+ end
39
+
40
+ def request(*args)
41
+ response = connection.public_send(*args)
42
+
43
+ if response.success?
44
+ Oj.load(response.body)
45
+ else
46
+ error_class = Abt::HttpError.error_class_for_status(response.status)
47
+ encoded_response_body = response.body.force_encoding('utf-8')
48
+ raise error_class, "Code: #{response.status}, body: #{encoded_response_body}"
49
+ end
50
+ end
51
+
52
+ def connection
53
+ @connection ||= Faraday.new(API_ENDPOINT) do |connection|
54
+ connection.headers['Authorization'] = "Bearer #{access_token}"
55
+ connection.headers['Harvest-Account-Id'] = account_id
56
+ connection.headers['Content-Type'] = 'application/json'
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end