abt-cli 0.0.26 → 0.0.31

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/abt.rb +0 -4
  3. data/lib/abt/cli.rb +5 -1
  4. data/lib/abt/directory_config.rb +28 -10
  5. data/lib/abt/docs.rb +10 -6
  6. data/lib/abt/providers/asana.rb +1 -0
  7. data/lib/abt/providers/asana/base_command.rb +33 -3
  8. data/lib/abt/providers/asana/commands/add.rb +0 -4
  9. data/lib/abt/providers/asana/commands/branch_name.rb +0 -13
  10. data/lib/abt/providers/asana/commands/clear.rb +1 -1
  11. data/lib/abt/providers/asana/commands/current.rb +0 -18
  12. data/lib/abt/providers/asana/commands/finalize.rb +2 -4
  13. data/lib/abt/providers/asana/commands/pick.rb +11 -41
  14. data/lib/abt/providers/asana/commands/tasks.rb +2 -7
  15. data/lib/abt/providers/asana/commands/write_config.rb +73 -0
  16. data/lib/abt/providers/asana/configuration.rb +1 -1
  17. data/lib/abt/providers/asana/path.rb +2 -2
  18. data/lib/abt/providers/asana/services/project_picker.rb +54 -0
  19. data/lib/abt/providers/asana/services/task_picker.rb +83 -0
  20. data/lib/abt/providers/devops.rb +1 -0
  21. data/lib/abt/providers/devops/api.rb +27 -20
  22. data/lib/abt/providers/devops/base_command.rb +42 -25
  23. data/lib/abt/providers/devops/commands/branch_name.rb +8 -16
  24. data/lib/abt/providers/devops/commands/clear.rb +1 -1
  25. data/lib/abt/providers/devops/commands/current.rb +2 -21
  26. data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +8 -16
  27. data/lib/abt/providers/devops/commands/pick.rb +11 -60
  28. data/lib/abt/providers/devops/commands/work_items.rb +3 -7
  29. data/lib/abt/providers/devops/commands/write_config.rb +47 -0
  30. data/lib/abt/providers/devops/configuration.rb +1 -1
  31. data/lib/abt/providers/devops/path.rb +24 -8
  32. data/lib/abt/providers/devops/services/board_picker.rb +69 -0
  33. data/lib/abt/providers/devops/services/project_picker.rb +73 -0
  34. data/lib/abt/providers/devops/services/work_item_picker.rb +99 -0
  35. data/lib/abt/providers/harvest.rb +1 -0
  36. data/lib/abt/providers/harvest/base_command.rb +45 -3
  37. data/lib/abt/providers/harvest/commands/clear.rb +1 -1
  38. data/lib/abt/providers/harvest/commands/current.rb +0 -28
  39. data/lib/abt/providers/harvest/commands/pick.rb +12 -27
  40. data/lib/abt/providers/harvest/commands/projects.rb +2 -9
  41. data/lib/abt/providers/harvest/commands/tasks.rb +2 -19
  42. data/lib/abt/providers/harvest/commands/track.rb +72 -39
  43. data/lib/abt/providers/harvest/commands/write_config.rb +41 -0
  44. data/lib/abt/providers/harvest/configuration.rb +1 -1
  45. data/lib/abt/providers/harvest/harvest_helpers.rb +25 -0
  46. data/lib/abt/providers/harvest/path.rb +1 -1
  47. data/lib/abt/providers/harvest/services/project_picker.rb +53 -0
  48. data/lib/abt/providers/harvest/services/task_picker.rb +50 -0
  49. data/lib/abt/version.rb +1 -1
  50. metadata +13 -6
  51. data/lib/abt/providers/asana/commands/init.rb +0 -42
  52. data/lib/abt/providers/devops/commands/boards.rb +0 -34
  53. data/lib/abt/providers/devops/commands/init.rb +0 -79
  54. data/lib/abt/providers/harvest/commands/init.rb +0 -53
@@ -71,7 +71,7 @@ module Abt
71
71
  private
72
72
 
73
73
  def directory_config
74
- Abt.directory_config.fetch("asana", {})
74
+ cli.directory_config.fetch("asana", {})
75
75
  end
76
76
 
77
77
  def git
@@ -6,7 +6,7 @@ module Abt
6
6
  class Path < String
7
7
  PATH_REGEX = %r{^(?<project_gid>\d+)?/?(?<task_gid>\d+)?$}.freeze
8
8
 
9
- def self.from_ids(project_gid: nil, task_gid: nil)
9
+ def self.from_gids(project_gid: nil, task_gid: nil)
10
10
  path = project_gid ? [project_gid, *task_gid].join("/") : ""
11
11
  new(path)
12
12
  end
@@ -28,7 +28,7 @@ module Abt
28
28
  private
29
29
 
30
30
  def match
31
- @match ||= PATH_REGEX.match(self)
31
+ @match ||= PATH_REGEX.match(to_s)
32
32
  end
33
33
  end
34
34
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Asana
6
+ module Services
7
+ class ProjectPicker
8
+ class Result
9
+ attr_reader :project, :path
10
+
11
+ def initialize(project:, path:)
12
+ @project = project
13
+ @path = path
14
+ end
15
+ end
16
+
17
+ def self.call(**args)
18
+ new(**args).call
19
+ end
20
+
21
+ attr_reader :cli, :config
22
+
23
+ def initialize(cli:, config:)
24
+ @cli = cli
25
+ @config = config
26
+ end
27
+
28
+ def call
29
+ project = cli.prompt.search("Select a project", projects)
30
+ path = Path.from_gids(project_gid: project["gid"])
31
+
32
+ Result.new(project: project, path: path)
33
+ end
34
+
35
+ private
36
+
37
+ def projects
38
+ @projects ||= begin
39
+ cli.warn("Fetching projects...")
40
+ api.get_paged("projects",
41
+ workspace: config.workspace_gid,
42
+ archived: false,
43
+ opt_fields: "name,permalink_url")
44
+ end
45
+ end
46
+
47
+ def api
48
+ Abt::Providers::Asana::Api.new(access_token: config.access_token)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Asana
6
+ module Services
7
+ class TaskPicker
8
+ class Result
9
+ attr_reader :task, :path
10
+
11
+ def initialize(task:, path:)
12
+ @task = task
13
+ @path = path
14
+ end
15
+ end
16
+
17
+ def self.call(**args)
18
+ new(**args).call
19
+ end
20
+
21
+ attr_reader :cli, :path, :config, :project
22
+
23
+ def initialize(cli:, path:, config:, project:)
24
+ @cli = cli
25
+ @path = path
26
+ @config = config
27
+ @project = project
28
+ end
29
+
30
+ def call
31
+ task = select_task
32
+
33
+ path_with_task = Path.from_gids(project_gid: path.project_gid, task_gid: task["gid"])
34
+
35
+ Result.new(task: task, path: path_with_task)
36
+ end
37
+
38
+ private
39
+
40
+ def select_task
41
+ section = prompt_section
42
+ tasks = tasks_in_section(section)
43
+
44
+ if tasks.length.zero?
45
+ cli.warn("Section is empty")
46
+ select_task
47
+ else
48
+ cli.prompt.choice("Select a task", tasks, nil_option: true) || select_task
49
+ end
50
+ end
51
+
52
+ def prompt_section
53
+ cli.prompt.choice("Which section in #{project['name']}?", sections)
54
+ end
55
+
56
+ def tasks_in_section(section)
57
+ cli.warn("Fetching tasks...")
58
+ tasks = api.get_paged(
59
+ "tasks",
60
+ section: section["gid"],
61
+ opt_fields: "name,completed,permalink_url"
62
+ )
63
+
64
+ # The below filtering is the best we can do with Asanas api, see this:
65
+ # https://forum.asana.com/t/tasks-query-completed-since-is-broken-for-sections/21461
66
+ tasks.reject { |task| task["completed"] }
67
+ end
68
+
69
+ def sections
70
+ @sections ||= begin
71
+ cli.warn("Fetching sections...")
72
+ api.get_paged("projects/#{project['gid']}/sections", opt_fields: "name")
73
+ end
74
+ end
75
+
76
+ def api
77
+ Abt::Providers::Asana::Api.new(access_token: config.access_token)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  Dir.glob("#{File.expand_path(__dir__)}/devops/*.rb").sort.each { |file| require file }
4
4
  Dir.glob("#{File.expand_path(__dir__)}/devops/commands/*.rb").sort.each { |file| require file }
5
+ Dir.glob("#{File.expand_path(__dir__)}/devops/services/*.rb").sort.each { |file| require file }
5
6
 
6
7
  module Abt
7
8
  module Providers
@@ -4,15 +4,22 @@ module Abt
4
4
  module Providers
5
5
  module Devops
6
6
  class Api
7
+ # Shamelessly copied from ERB::Util.url_encode
8
+ # https://apidock.com/ruby/ERB/Util/url_encode
9
+ def self.rfc_3986_encode_path_segment(string)
10
+ string.to_s.b.gsub(/[^a-zA-Z0-9_\-.~]/) do |match|
11
+ format("%%%02X", match.unpack1("C"))
12
+ end
13
+ end
14
+
7
15
  VERBS = [:get, :post, :put].freeze
8
16
 
9
17
  CONDITIONAL_ACCESS_POLICY_ERROR_CODE = "VS403463"
10
18
 
11
19
  attr_reader :organization_name, :project_name, :username, :access_token, :cli
12
20
 
13
- def initialize(organization_name:, project_name:, username:, access_token:, cli:)
21
+ def initialize(organization_name:, username:, access_token:, cli:)
14
22
  @organization_name = organization_name
15
- @project_name = project_name
16
23
  @username = username
17
24
  @access_token = access_token
18
25
  @cli = cli
@@ -32,12 +39,12 @@ module Abt
32
39
  end
33
40
 
34
41
  def work_item_query(wiql)
35
- response = post("wit/wiql", Oj.dump({ query: wiql }, mode: :json))
42
+ response = post("_apis/wit/wiql", Oj.dump({ query: wiql }, mode: :json))
36
43
  ids = response["workItems"].map { |work_item| work_item["id"] }
37
44
 
38
45
  work_items = []
39
46
  ids.each_slice(200) do |page_ids|
40
- work_items += get_paged("wit/workitems", ids: page_ids.join(","))
47
+ work_items += get_paged("_apis/wit/workitems", ids: page_ids.join(","))
41
48
  end
42
49
 
43
50
  work_items
@@ -58,23 +65,31 @@ module Abt
58
65
  end
59
66
 
60
67
  def base_url
61
- "https://#{organization_name}.visualstudio.com/#{project_name}"
68
+ "https://#{organization_name}.visualstudio.com"
62
69
  end
63
70
 
64
- def api_endpoint
65
- "#{base_url}/_apis"
71
+ def url_for_work_item(work_item)
72
+ project_name = self.class.rfc_3986_encode_path_segment(work_item["fields"]["System.TeamProject"])
73
+ "#{base_url}/#{project_name}/_workitems/edit/#{work_item['id']}"
66
74
  end
67
75
 
68
- def url_for_work_item(work_item)
69
- "#{base_url}/_workitems/edit/#{work_item['id']}"
76
+ def url_for_board(project_name, team_name, board)
77
+ board_name = self.class.rfc_3986_encode_path_segment(board["name"])
78
+ "#{base_url}/#{project_name}/_boards/board/t/#{team_name}/#{board_name}"
70
79
  end
71
80
 
72
- def url_for_board(board)
73
- "#{base_url}/_boards/board/#{rfc_3986_encode_path_segment(board['name'])}"
81
+ def sanitize_work_item(work_item)
82
+ return nil if work_item.nil?
83
+
84
+ work_item.merge(
85
+ "id" => work_item["id"].to_s,
86
+ "name" => work_item["fields"]["System.Title"],
87
+ "url" => url_for_work_item(work_item)
88
+ )
74
89
  end
75
90
 
76
91
  def connection
77
- @connection ||= Faraday.new(api_endpoint) do |connection|
92
+ @connection ||= Faraday.new(base_url) do |connection|
78
93
  connection.basic_auth(username, access_token)
79
94
  connection.headers["Content-Type"] = "application/json"
80
95
  connection.headers["Accept"] = "application/json; api-version=6.0"
@@ -83,14 +98,6 @@ module Abt
83
98
 
84
99
  private
85
100
 
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
-
94
101
  def handle_denied_by_conditional_access_policy!(exception)
95
102
  raise exception unless exception.message.include?(CONDITIONAL_ACCESS_POLICY_ERROR_CODE)
96
103
 
@@ -8,7 +8,7 @@ module Abt
8
8
 
9
9
  attr_reader :config, :path
10
10
 
11
- def_delegators(:@path, :organization_name, :project_name, :board_id, :work_item_id)
11
+ def_delegators(:@path, :organization_name, :project_name, :team_name, :board_name, :work_item_id)
12
12
 
13
13
  def initialize(ari:, cli:)
14
14
  super
@@ -24,51 +24,68 @@ module Abt
24
24
  end
25
25
 
26
26
  def require_board!
27
- return if organization_name && project_name && board_id
27
+ return if board_name && organization_name && project_name && team_name
28
28
 
29
- abort("No current/specified board. Did you initialize DevOps?")
29
+ abort("No current/specified board. Did you forget to `pick`?")
30
30
  end
31
31
 
32
32
  def require_work_item!
33
- unless organization_name && project_name && board_id
34
- abort("No current/specified board. Did you initialize DevOps and pick a work item?")
35
- end
36
-
33
+ require_board!
37
34
  return if work_item_id
38
35
 
39
- abort("No current/specified work item. Did you pick a DevOps work item?")
36
+ abort("No current/specified work item. Did you forget to `pick`?")
40
37
  end
41
38
 
42
- def sanitize_work_item(work_item)
43
- return nil if work_item.nil?
39
+ def prompt_board!
40
+ result = Services::BoardPicker.call(cli: cli, config: config)
41
+ @path = result.path
42
+ @board = result.board
43
+ end
44
+
45
+ def prompt_work_item!
46
+ result = Services::WorkItemPicker.call(cli: cli, path: path, config: config, board: board)
47
+ @path = result.path
48
+ @work_item = result.work_item
49
+ end
44
50
 
45
- work_item.merge(
46
- "id" => work_item["id"].to_s,
47
- "name" => work_item["fields"]["System.Title"],
48
- "url" => api.url_for_work_item(work_item)
49
- )
51
+ def board
52
+ @board ||= begin
53
+ api.get("#{project_name}/#{team_name}/_apis/work/boards/#{board_name}")
54
+ rescue HttpError::NotFoundError
55
+ nil
56
+ end
57
+ end
58
+
59
+ def work_item
60
+ @work_item ||= begin
61
+ work_item = api.get_paged("_apis/wit/workitems", ids: work_item_id)[0]
62
+ api.sanitize_work_item(work_item)
63
+ rescue HttpError::NotFoundError
64
+ nil
65
+ end
50
66
  end
51
67
 
52
- def print_board(organization_name, project_name, board)
53
- path = "#{organization_name}/#{project_name}/#{board['id']}"
68
+ def print_board(organization_name, project_name, team_name, board)
69
+ board_name = Api.rfc_3986_encode_path_segment(board["name"])
70
+ path = "#{organization_name}/#{project_name}/#{team_name}/#{board_name}"
54
71
 
55
72
  cli.print_ari("devops", path, board["name"])
56
- warn(api.url_for_board(board)) if cli.output.isatty
73
+ warn(api.url_for_board(project_name, team_name, board)) if cli.output.isatty
57
74
  end
58
75
 
59
- def print_work_item(organization, project, board, work_item)
60
- path = "#{organization}/#{project}/#{board['id']}/#{work_item['id']}"
76
+ def print_work_item(organization, project, team_name, board, work_item)
77
+ board_name = Api.rfc_3986_encode_path_segment(board["name"])
78
+ path = "#{organization}/#{project}/#{team_name}/#{board_name}/#{work_item['id']}"
61
79
 
62
80
  cli.print_ari("devops", path, work_item["name"])
63
81
  warn(work_item["url"]) if work_item.key?("url") && cli.output.isatty
64
82
  end
65
83
 
66
84
  def api
67
- Abt::Providers::Devops::Api.new(organization_name: organization_name,
68
- project_name: project_name,
69
- username: config.username_for_organization(organization_name),
70
- access_token: config.access_token_for_organization(organization_name),
71
- cli: cli)
85
+ Api.new(organization_name: organization_name,
86
+ username: config.username_for_organization(organization_name),
87
+ access_token: config.access_token_for_organization(organization_name),
88
+ cli: cli)
72
89
  end
73
90
  end
74
91
  end
@@ -16,15 +16,14 @@ module Abt
16
16
  def perform
17
17
  require_work_item!
18
18
 
19
- puts name
20
- rescue HttpError::NotFoundError
21
- args = [organization_name, project_name, board_id, work_item_id].compact
22
-
23
- error_message = [
24
- "Unable to find work item for configuration:",
25
- "devops:#{args.join('/')}"
26
- ].join("\n")
27
- abort(error_message)
19
+ if work_item
20
+ puts name
21
+ else
22
+ abort(<<~TXT)
23
+ Unable to find work item for configuration:
24
+ devops:#{path}
25
+ TXT
26
+ end
28
27
  end
29
28
 
30
29
  private
@@ -35,13 +34,6 @@ module Abt
35
34
  str += work_item["name"].downcase.gsub(/[^\w]/, "-")
36
35
  str.squeeze("-").gsub(/(^-|-$)/, "")
37
36
  end
38
-
39
- def work_item
40
- @work_item ||= begin
41
- work_item = api.get_paged("wit/workitems", ids: work_item_id)[0]
42
- sanitize_work_item(work_item)
43
- end
44
- end
45
37
  end
46
38
  end
47
39
  end
@@ -22,7 +22,7 @@ module Abt
22
22
  end
23
23
 
24
24
  def perform
25
- abort("Flags --global and --all cannot be used at the same time") if flags[:global] && flags[:all]
25
+ abort("Flags --global and --all cannot be used together") if flags[:global] && flags[:all]
26
26
 
27
27
  config.clear_local unless flags[:global]
28
28
  config.clear_global if flags[:global] || flags[:all]
@@ -30,9 +30,9 @@ module Abt
30
30
 
31
31
  def print_configuration
32
32
  if work_item_id.nil?
33
- print_board(organization_name, project_name, board)
33
+ print_board(organization_name, project_name, team_name, board)
34
34
  else
35
- print_work_item(organization_name, project_name, board, work_item)
35
+ print_work_item(organization_name, project_name, team_name, board, work_item)
36
36
  end
37
37
  end
38
38
 
@@ -42,25 +42,6 @@ module Abt
42
42
  end
43
43
  abort("No such work item: ##{work_item_id}") if work_item_id && work_item.nil?
44
44
  end
45
-
46
- def board
47
- @board ||= begin
48
- warn("Fetching board...")
49
- api.get("work/boards/#{board_id}")
50
- rescue HttpError::NotFoundError
51
- nil
52
- end
53
- end
54
-
55
- def work_item
56
- @work_item ||= begin
57
- warn("Fetching work item...")
58
- work_item = api.get_paged("wit/workitems", ids: work_item_id)[0]
59
- sanitize_work_item(work_item)
60
- rescue HttpError::NotFoundError
61
- nil
62
- end
63
- end
64
45
  end
65
46
  end
66
47
  end