abt-cli 0.0.18 → 0.0.23

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/bin/abt +3 -3
  3. data/lib/abt.rb +6 -6
  4. data/lib/abt/ari.rb +20 -0
  5. data/lib/abt/ari_list.rb +13 -0
  6. data/lib/abt/base_command.rb +63 -0
  7. data/lib/abt/cli.rb +51 -52
  8. data/lib/abt/cli/arguments_parser.rb +7 -26
  9. data/lib/abt/cli/global_commands.rb +23 -0
  10. data/lib/abt/cli/global_commands/commands.rb +23 -0
  11. data/lib/abt/cli/global_commands/examples.rb +23 -0
  12. data/lib/abt/cli/global_commands/help.rb +23 -0
  13. data/lib/abt/cli/global_commands/readme.rb +23 -0
  14. data/lib/abt/cli/global_commands/share.rb +36 -0
  15. data/lib/abt/cli/global_commands/version.rb +23 -0
  16. data/lib/abt/cli/prompt.rb +64 -51
  17. data/lib/abt/docs.rb +48 -25
  18. data/lib/abt/docs/cli.rb +3 -3
  19. data/lib/abt/docs/markdown.rb +11 -8
  20. data/lib/abt/git_config.rb +21 -39
  21. data/lib/abt/helpers.rb +26 -8
  22. data/lib/abt/providers/asana/api.rb +9 -9
  23. data/lib/abt/providers/asana/base_command.rb +20 -38
  24. data/lib/abt/providers/asana/commands/add.rb +18 -15
  25. data/lib/abt/providers/asana/commands/branch_name.rb +13 -8
  26. data/lib/abt/providers/asana/commands/clear.rb +8 -7
  27. data/lib/abt/providers/asana/commands/current.rb +22 -38
  28. data/lib/abt/providers/asana/commands/finalize.rb +17 -18
  29. data/lib/abt/providers/asana/commands/harvest_time_entry_data.rb +20 -13
  30. data/lib/abt/providers/asana/commands/init.rb +8 -41
  31. data/lib/abt/providers/asana/commands/pick.rb +27 -26
  32. data/lib/abt/providers/asana/commands/projects.rb +5 -5
  33. data/lib/abt/providers/asana/commands/share.rb +6 -8
  34. data/lib/abt/providers/asana/commands/start.rb +33 -24
  35. data/lib/abt/providers/asana/commands/tasks.rb +6 -5
  36. data/lib/abt/providers/asana/configuration.rb +46 -44
  37. data/lib/abt/providers/asana/path.rb +36 -0
  38. data/lib/abt/providers/devops/api.rb +23 -11
  39. data/lib/abt/providers/devops/base_command.rb +22 -43
  40. data/lib/abt/providers/devops/commands/boards.rb +5 -7
  41. data/lib/abt/providers/devops/commands/branch_name.rb +14 -10
  42. data/lib/abt/providers/devops/commands/clear.rb +8 -7
  43. data/lib/abt/providers/devops/commands/current.rb +24 -49
  44. data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +26 -16
  45. data/lib/abt/providers/devops/commands/init.rb +33 -26
  46. data/lib/abt/providers/devops/commands/pick.rb +23 -24
  47. data/lib/abt/providers/devops/commands/share.rb +7 -6
  48. data/lib/abt/providers/devops/commands/{work-items.rb → work_items.rb} +3 -3
  49. data/lib/abt/providers/devops/configuration.rb +27 -56
  50. data/lib/abt/providers/devops/path.rb +51 -0
  51. data/lib/abt/providers/git/commands/branch.rb +25 -19
  52. data/lib/abt/providers/harvest/api.rb +8 -8
  53. data/lib/abt/providers/harvest/base_command.rb +20 -36
  54. data/lib/abt/providers/harvest/commands/clear.rb +8 -7
  55. data/lib/abt/providers/harvest/commands/current.rb +27 -35
  56. data/lib/abt/providers/harvest/commands/init.rb +10 -40
  57. data/lib/abt/providers/harvest/commands/pick.rb +15 -12
  58. data/lib/abt/providers/harvest/commands/projects.rb +5 -5
  59. data/lib/abt/providers/harvest/commands/share.rb +6 -8
  60. data/lib/abt/providers/harvest/commands/start.rb +5 -3
  61. data/lib/abt/providers/harvest/commands/stop.rb +13 -13
  62. data/lib/abt/providers/harvest/commands/tasks.rb +9 -6
  63. data/lib/abt/providers/harvest/commands/track.rb +60 -38
  64. data/lib/abt/providers/harvest/configuration.rb +28 -37
  65. data/lib/abt/providers/harvest/path.rb +36 -0
  66. data/lib/abt/version.rb +1 -1
  67. metadata +18 -6
  68. data/lib/abt/cli/base_command.rb +0 -61
@@ -6,11 +6,11 @@ module Abt
6
6
  module Commands
7
7
  class Tasks < BaseCommand
8
8
  def self.usage
9
- 'abt tasks asana'
9
+ "abt tasks asana"
10
10
  end
11
11
 
12
12
  def self.description
13
- 'List available tasks on project - useful for piping into grep etc.'
13
+ "List available tasks on project - useful for piping into grep etc."
14
14
  end
15
15
 
16
16
  def perform
@@ -25,14 +25,15 @@ module Abt
25
25
 
26
26
  def project
27
27
  @project ||= begin
28
- api.get("projects/#{project_gid}")
28
+ api.get("projects/#{project_gid}", opt_fields: "name")
29
29
  end
30
30
  end
31
31
 
32
32
  def tasks
33
33
  @tasks ||= begin
34
- cli.warn 'Fetching tasks...'
35
- api.get_paged('tasks', project: project['gid'], opt_fields: 'name')
34
+ warn("Fetching tasks...")
35
+ tasks = api.get_paged("tasks", project: project["gid"], opt_fields: "name,completed")
36
+ tasks.reject { |task| task["completed"] }
36
37
  end
37
38
  end
38
39
  end
@@ -8,26 +8,25 @@ module Abt
8
8
 
9
9
  def initialize(cli:)
10
10
  @cli = cli
11
- @git = GitConfig.new(namespace: 'abt.asana')
12
11
  end
13
12
 
14
13
  def local_available?
15
- GitConfig.local_available?
14
+ git.available?
16
15
  end
17
16
 
18
- def project_gid
19
- local_available? ? git['projectGid'] : nil
17
+ def path
18
+ Path.new(local_available? && git["path"] || "")
20
19
  end
21
20
 
22
- def task_gid
23
- local_available? ? git['taskGid'] : nil
21
+ def path=(new_path)
22
+ git["path"] = new_path
24
23
  end
25
24
 
26
25
  def workspace_gid
27
26
  @workspace_gid ||= begin
28
- current = git.global['workspaceGid']
27
+ current = git_global["workspaceGid"]
29
28
  if current.nil?
30
- prompt_workspace['gid']
29
+ prompt_workspace_gid
31
30
  else
32
31
  current
33
32
  end
@@ -37,24 +36,13 @@ module Abt
37
36
  def wip_section_gid
38
37
  return nil unless local_available?
39
38
 
40
- @wip_section_gid ||= git['wipSectionGid'] || prompt_wip_section['gid']
39
+ @wip_section_gid ||= git["wipSectionGid"] || prompt_wip_section["gid"]
41
40
  end
42
41
 
43
42
  def finalized_section_gid
44
43
  return nil unless local_available?
45
44
 
46
- @finalized_section_gid ||= git['finalizedSectionGid'] || prompt_finalized_section['gid']
47
- end
48
-
49
- def project_gid=(value)
50
- return if project_gid == value
51
-
52
- clear_local(verbose: false)
53
- git['projectGid'] = value unless value.nil?
54
- end
55
-
56
- def task_gid=(value)
57
- git['taskGid'] = value
45
+ @finalized_section_gid ||= git["finalizedSectionGid"] || prompt_finalized_section["gid"]
58
46
  end
59
47
 
60
48
  def clear_local(verbose: true)
@@ -62,56 +50,70 @@ module Abt
62
50
  end
63
51
 
64
52
  def clear_global(verbose: true)
65
- git.global.clear(output: verbose ? cli.err_output : nil)
53
+ git_global.clear(output: verbose ? cli.err_output : nil)
66
54
  end
67
55
 
68
56
  def access_token
69
- return git.global['accessToken'] unless git.global['accessToken'].nil?
57
+ return git_global["accessToken"] unless git_global["accessToken"].nil?
70
58
 
71
- git.global['accessToken'] = cli.prompt.text([
72
- 'Please provide your personal access token for Asana.',
73
- 'If you don\'t have one, create one here: https://app.asana.com/0/developer-console',
74
- '',
75
- 'Enter access token'
59
+ git_global["accessToken"] = cli.prompt.text([
60
+ "Please provide your personal access token for Asana.",
61
+ "If you don't have one, create one here: https://app.asana.com/0/developer-console",
62
+ "",
63
+ "Enter access token"
76
64
  ].join("\n"))
77
65
  end
78
66
 
79
67
  private
80
68
 
81
- attr_reader :git
69
+ def git
70
+ @git ||= GitConfig.new("local", "abt.asana")
71
+ end
72
+
73
+ def git_global
74
+ @git_global ||= GitConfig.new("global", "abt.asana")
75
+ end
82
76
 
83
77
  def prompt_finalized_section
84
78
  section = prompt_section('Select section for finalized tasks (E.g. "Merged")')
85
- git['finalizedSectionGid'] = section['gid']
79
+ git["finalizedSectionGid"] = section["gid"]
86
80
  section
87
81
  end
88
82
 
89
83
  def prompt_wip_section
90
- section = prompt_section('Select WIP (Work In Progress) section')
91
- git['wipSectionGid'] = section['gid']
84
+ section = prompt_section("Select WIP (Work In Progress) section")
85
+ git["wipSectionGid"] = section["gid"]
92
86
  section
93
87
  end
94
88
 
95
89
  def prompt_section(message)
96
- cli.warn 'Fetching sections...'
97
- sections = api.get_paged("projects/#{project_gid}/sections")
90
+ cli.warn("Fetching sections...")
91
+ sections = api.get_paged("projects/#{path.project_gid}/sections", opt_fields: "name")
98
92
  cli.prompt.choice(message, sections)
99
93
  end
100
94
 
101
- def prompt_workspace
102
- cli.warn 'Fetching workspaces...'
103
- workspaces = api.get_paged('workspaces')
104
- if workspaces.empty?
105
- cli.abort 'Your asana access token does not have access to any workspaces'
106
- elsif workspaces.one?
95
+ def prompt_workspace_gid
96
+ cli.abort("Your asana access token does not have access to any workspaces") if workspaces.empty?
97
+
98
+ if workspaces.one?
107
99
  workspace = workspaces.first
108
- cli.warn "Selected Asana workspace #{workspace['name']}"
100
+ cli.warn("Selected Asana workspace: #{workspace['name']}")
109
101
  else
110
- workspace = cli.prompt.choice('Select Asana workspace', workspaces)
102
+ workspace = pick_workspace
111
103
  end
112
104
 
113
- git.global['workspaceGid'] = workspace['gid']
114
- workspace
105
+ git_global["workspaceGid"] = workspace["gid"]
106
+ end
107
+
108
+ def pick_workspace
109
+ cli.prompt.choice("Select Asana workspace", workspaces)
110
+ end
111
+
112
+ def workspaces
113
+ @workspaces ||= begin
114
+ cli.warn("Fetching workspaces...")
115
+ api.get_paged("workspaces", opt_fields: "name")
116
+ end
115
117
  end
116
118
 
117
119
  def api
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Asana
6
+ class Path < String
7
+ PATH_REGEX = %r{^(?<project_gid>\d+)?/?(?<task_gid>\d+)?$}.freeze
8
+
9
+ def self.from_ids(project_gid: nil, task_gid: nil)
10
+ path = project_gid ? [project_gid, *task_gid].join("/") : ""
11
+ new(path)
12
+ end
13
+
14
+ def initialize(path = "")
15
+ raise Abt::Cli::Abort, "Invalid path: #{path}" unless PATH_REGEX.match?(path)
16
+
17
+ super
18
+ end
19
+
20
+ def project_gid
21
+ match[:project_gid]
22
+ end
23
+
24
+ def task_gid
25
+ match[:task_gid]
26
+ end
27
+
28
+ private
29
+
30
+ def match
31
+ @match ||= PATH_REGEX.match(self)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -4,9 +4,9 @@ module Abt
4
4
  module Providers
5
5
  module Devops
6
6
  class Api
7
- VERBS = %i[get post put].freeze
7
+ VERBS = [:get, :post, :put].freeze
8
8
 
9
- CONDITIONAL_ACCESS_POLICY_ERROR_CODE = 'VS403463'
9
+ CONDITIONAL_ACCESS_POLICY_ERROR_CODE = "VS403463"
10
10
 
11
11
  attr_reader :organization_name, :project_name, :username, :access_token, :cli
12
12
 
@@ -26,18 +26,18 @@ module Abt
26
26
 
27
27
  def get_paged(path, query = {})
28
28
  result = request(:get, path, query)
29
- result['value']
29
+ result["value"]
30
30
 
31
31
  # TODO: Loop if necessary
32
32
  end
33
33
 
34
34
  def work_item_query(wiql)
35
- response = post('wit/wiql', Oj.dump({ query: wiql }, mode: :json))
36
- ids = response['workItems'].map { |work_item| work_item['id'] }
35
+ response = post("wit/wiql", Oj.dump({ query: wiql }, mode: :json))
36
+ ids = response["workItems"].map { |work_item| work_item["id"] }
37
37
 
38
38
  work_items = []
39
39
  ids.each_slice(200) do |page_ids|
40
- work_items += get_paged('wit/workitems', ids: page_ids.join(','))
40
+ work_items += get_paged("wit/workitems", ids: page_ids.join(","))
41
41
  end
42
42
 
43
43
  work_items
@@ -50,7 +50,7 @@ module Abt
50
50
  Oj.load(response.body)
51
51
  else
52
52
  error_class = Abt::HttpError.error_class_for_status(response.status)
53
- encoded_response_body = response.body.force_encoding('utf-8')
53
+ encoded_response_body = response.body.force_encoding("utf-8")
54
54
  raise error_class, "Code: #{response.status}, body: #{encoded_response_body}"
55
55
  end
56
56
  rescue Abt::HttpError::ForbiddenError => e
@@ -69,20 +69,32 @@ module Abt
69
69
  "#{base_url}/_workitems/edit/#{work_item['id']}"
70
70
  end
71
71
 
72
+ def url_for_board(board)
73
+ "#{base_url}/_boards/board/#{rfc_3986_encode_path_segment(board['name'])}"
74
+ end
75
+
72
76
  def connection
73
77
  @connection ||= Faraday.new(api_endpoint) do |connection|
74
- connection.basic_auth username, access_token
75
- connection.headers['Content-Type'] = 'application/json'
76
- connection.headers['Accept'] = 'application/json; api-version=6.0'
78
+ connection.basic_auth(username, access_token)
79
+ connection.headers["Content-Type"] = "application/json"
80
+ connection.headers["Accept"] = "application/json; api-version=6.0"
77
81
  end
78
82
  end
79
83
 
80
84
  private
81
85
 
86
+ # Shamelessly copied from ERB::Util.url_encode
87
+ # https://apidock.com/ruby/ERB/Util/url_encode
88
+ def rfc_3986_encode_path_segment(string)
89
+ string.to_s.b.gsub(/[^a-zA-Z0-9_\-.~]/) do |match|
90
+ format("%%%02X", match.unpack1("C"))
91
+ end
92
+ end
93
+
82
94
  def handle_denied_by_conditional_access_policy!(exception)
83
95
  raise exception unless exception.message.include?(CONDITIONAL_ACCESS_POLICY_ERROR_CODE)
84
96
 
85
- cli.abort <<~TXT
97
+ cli.abort(<<~TXT)
86
98
  Access denied by conditional access policy.
87
99
  Try logging into the board using the URL below, then retry the command.
88
100
 
@@ -3,85 +3,64 @@
3
3
  module Abt
4
4
  module Providers
5
5
  module Devops
6
- class BaseCommand < Abt::Cli::BaseCommand
7
- attr_reader :organization_name, :project_name, :board_id, :work_item_id, :config
6
+ class BaseCommand < Abt::BaseCommand
7
+ extend Forwardable
8
8
 
9
- def initialize(path:, cli:, **)
9
+ attr_reader :config, :path
10
+
11
+ def_delegators(:@path, :organization_name, :project_name, :board_id, :work_item_id)
12
+
13
+ def initialize(ari:, cli:)
10
14
  super
11
15
 
12
16
  @config = Configuration.new(cli: cli)
13
-
14
- if path.nil?
15
- use_current_path
16
- else
17
- use_path(path)
18
- end
17
+ @path = ari.path ? Path.new(ari.path) : config.path
19
18
  end
20
19
 
21
20
  private
22
21
 
22
+ def require_local_config!
23
+ abort("Must be run inside a git repository") unless config.local_available?
24
+ end
25
+
23
26
  def require_board!
24
27
  return if organization_name && project_name && board_id
25
28
 
26
- cli.abort 'No current/specified board. Did you initialize DevOps?'
29
+ abort("No current/specified board. Did you initialize DevOps?")
27
30
  end
28
31
 
29
32
  def require_work_item!
30
33
  unless organization_name && project_name && board_id
31
- cli.abort 'No current/specified board. Did you initialize DevOps and pick a work item?'
34
+ abort("No current/specified board. Did you initialize DevOps and pick a work item?")
32
35
  end
33
36
 
34
37
  return if work_item_id
35
38
 
36
- cli.abort 'No current/specified work item. Did you pick a DevOps work item?'
39
+ abort("No current/specified work item. Did you pick a DevOps work item?")
37
40
  end
38
41
 
39
42
  def sanitize_work_item(work_item)
40
43
  return nil if work_item.nil?
41
44
 
42
45
  work_item.merge(
43
- 'id' => work_item['id'].to_s,
44
- 'name' => work_item['fields']['System.Title'],
45
- 'url' => api.url_for_work_item(work_item)
46
+ "id" => work_item["id"].to_s,
47
+ "name" => work_item["fields"]["System.Title"],
48
+ "url" => api.url_for_work_item(work_item)
46
49
  )
47
50
  end
48
51
 
49
- def same_args_as_config?
50
- organization_name == config.organization_name &&
51
- project_name == config.project_name &&
52
- board_id == config.board_id &&
53
- work_item_id == config.work_item_id
54
- end
55
-
56
52
  def print_board(organization_name, project_name, board)
57
53
  path = "#{organization_name}/#{project_name}/#{board['id']}"
58
54
 
59
- cli.print_ari('devops', path, board['name'])
60
- # cli.warn board['url'] if board.key?('url') && cli.output.isatty # TODO: Web URL
55
+ cli.print_ari("devops", path, board["name"])
56
+ warn(api.url_for_board(board)) if cli.output.isatty
61
57
  end
62
58
 
63
59
  def print_work_item(organization, project, board, work_item)
64
60
  path = "#{organization}/#{project}/#{board['id']}/#{work_item['id']}"
65
61
 
66
- cli.print_ari('devops', path, work_item['name'])
67
- cli.warn work_item['url'] if work_item.key?('url') && cli.output.isatty
68
- end
69
-
70
- def use_current_path
71
- @organization_name = config.organization_name
72
- @project_name = config.project_name
73
- @board_id = config.board_id
74
- @work_item_id = config.work_item_id
75
- end
76
-
77
- def use_path(path)
78
- args = path.to_s.split('/')
79
-
80
- if args.length < 3
81
- cli.abort 'Argument format is <organization>/<project>/<board-id>[/<work-item-id>]'
82
- end
83
-
84
- (@organization_name, @project_name, @board_id, @work_item_id) = args
62
+ cli.print_ari("devops", path, work_item["name"])
63
+ warn(work_item["url"]) if work_item.key?("url") && cli.output.isatty
85
64
  end
86
65
 
87
66
  def api
@@ -6,18 +6,16 @@ module Abt
6
6
  module Commands
7
7
  class Boards < BaseCommand
8
8
  def self.usage
9
- 'abt boards devops'
9
+ "abt boards devops"
10
10
  end
11
11
 
12
12
  def self.description
13
- 'List all boards - useful for piping into grep etc'
13
+ "List all boards - useful for piping into grep etc"
14
14
  end
15
15
 
16
16
  def perform
17
- if organization_name.nil?
18
- cli.abort 'No organization selected. Did you initialize DevOps?'
19
- end
20
- cli.abort 'No project selected. Did you initialize DevOps?' if project_name.nil?
17
+ abort("No organization selected. Did you initialize DevOps?") if organization_name.nil?
18
+ abort("No project selected. Did you initialize DevOps?") if project_name.nil?
21
19
 
22
20
  boards.map do |board|
23
21
  print_board(organization_name, project_name, board)
@@ -27,7 +25,7 @@ module Abt
27
25
  private
28
26
 
29
27
  def boards
30
- @boards ||= api.get_paged('work/boards')
28
+ @boards ||= api.get_paged("work/boards")
31
29
  end
32
30
  end
33
31
  end
@@ -6,35 +6,39 @@ module Abt
6
6
  module Commands
7
7
  class BranchName < BaseCommand
8
8
  def self.usage
9
- 'abt branch-name devops[:<organization-name>/<project-name>/<board-id>/<work-item-id>]'
9
+ "abt branch-name devops[:<organization-name>/<project-name>/<board-id>/<work-item-id>]"
10
10
  end
11
11
 
12
12
  def self.description
13
- 'Suggest a git branch name for the current/specified work-item.'
13
+ "Suggest a git branch name for the current/specified work-item."
14
14
  end
15
15
 
16
16
  def perform
17
17
  require_work_item!
18
18
 
19
- cli.puts name
19
+ puts name
20
20
  rescue HttpError::NotFoundError
21
21
  args = [organization_name, project_name, board_id, work_item_id].compact
22
- cli.warn 'Unable to find work item for configuration:'
23
- cli.abort "devops:#{args.join('/')}"
22
+
23
+ error_message = [
24
+ "Unable to find work item for configuration:",
25
+ "devops:#{args.join('/')}"
26
+ ].join("\n")
27
+ abort(error_message)
24
28
  end
25
29
 
26
30
  private
27
31
 
28
32
  def name
29
- str = work_item['id']
30
- str += '-'
31
- str += work_item['name'].downcase.gsub(/[^\w]/, '-')
32
- str.gsub(/-+/, '-')
33
+ str = work_item["id"]
34
+ str += "-"
35
+ str += work_item["name"].downcase.gsub(/[^\w]/, "-")
36
+ str.squeeze("-").gsub(/(^-|-$)/, "")
33
37
  end
34
38
 
35
39
  def work_item
36
40
  @work_item ||= begin
37
- work_item = api.get_paged('wit/workitems', ids: work_item_id)[0]
41
+ work_item = api.get_paged("wit/workitems", ids: work_item_id)[0]
38
42
  sanitize_work_item(work_item)
39
43
  end
40
44
  end