abt-cli 0.0.18 → 0.0.23

Sign up to get free protection for your applications and to get access to all the features.
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