abt-cli 0.0.26 → 0.0.31
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.
- checksums.yaml +4 -4
- data/lib/abt.rb +0 -4
- data/lib/abt/cli.rb +5 -1
- data/lib/abt/directory_config.rb +28 -10
- data/lib/abt/docs.rb +10 -6
- data/lib/abt/providers/asana.rb +1 -0
- data/lib/abt/providers/asana/base_command.rb +33 -3
- data/lib/abt/providers/asana/commands/add.rb +0 -4
- data/lib/abt/providers/asana/commands/branch_name.rb +0 -13
- data/lib/abt/providers/asana/commands/clear.rb +1 -1
- data/lib/abt/providers/asana/commands/current.rb +0 -18
- data/lib/abt/providers/asana/commands/finalize.rb +2 -4
- data/lib/abt/providers/asana/commands/pick.rb +11 -41
- data/lib/abt/providers/asana/commands/tasks.rb +2 -7
- data/lib/abt/providers/asana/commands/write_config.rb +73 -0
- data/lib/abt/providers/asana/configuration.rb +1 -1
- data/lib/abt/providers/asana/path.rb +2 -2
- data/lib/abt/providers/asana/services/project_picker.rb +54 -0
- data/lib/abt/providers/asana/services/task_picker.rb +83 -0
- data/lib/abt/providers/devops.rb +1 -0
- data/lib/abt/providers/devops/api.rb +27 -20
- data/lib/abt/providers/devops/base_command.rb +42 -25
- data/lib/abt/providers/devops/commands/branch_name.rb +8 -16
- data/lib/abt/providers/devops/commands/clear.rb +1 -1
- data/lib/abt/providers/devops/commands/current.rb +2 -21
- data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +8 -16
- data/lib/abt/providers/devops/commands/pick.rb +11 -60
- data/lib/abt/providers/devops/commands/work_items.rb +3 -7
- data/lib/abt/providers/devops/commands/write_config.rb +47 -0
- data/lib/abt/providers/devops/configuration.rb +1 -1
- data/lib/abt/providers/devops/path.rb +24 -8
- data/lib/abt/providers/devops/services/board_picker.rb +69 -0
- data/lib/abt/providers/devops/services/project_picker.rb +73 -0
- data/lib/abt/providers/devops/services/work_item_picker.rb +99 -0
- data/lib/abt/providers/harvest.rb +1 -0
- data/lib/abt/providers/harvest/base_command.rb +45 -3
- data/lib/abt/providers/harvest/commands/clear.rb +1 -1
- data/lib/abt/providers/harvest/commands/current.rb +0 -28
- data/lib/abt/providers/harvest/commands/pick.rb +12 -27
- data/lib/abt/providers/harvest/commands/projects.rb +2 -9
- data/lib/abt/providers/harvest/commands/tasks.rb +2 -19
- data/lib/abt/providers/harvest/commands/track.rb +72 -39
- data/lib/abt/providers/harvest/commands/write_config.rb +41 -0
- data/lib/abt/providers/harvest/configuration.rb +1 -1
- data/lib/abt/providers/harvest/harvest_helpers.rb +25 -0
- data/lib/abt/providers/harvest/path.rb +1 -1
- data/lib/abt/providers/harvest/services/project_picker.rb +53 -0
- data/lib/abt/providers/harvest/services/task_picker.rb +50 -0
- data/lib/abt/version.rb +1 -1
- metadata +13 -6
- data/lib/abt/providers/asana/commands/init.rb +0 -42
- data/lib/abt/providers/devops/commands/boards.rb +0 -34
- data/lib/abt/providers/devops/commands/init.rb +0 -79
- data/lib/abt/providers/harvest/commands/init.rb +0 -53
@@ -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.
|
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(
|
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
|
data/lib/abt/providers/devops.rb
CHANGED
@@ -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:,
|
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
|
68
|
+
"https://#{organization_name}.visualstudio.com"
|
62
69
|
end
|
63
70
|
|
64
|
-
def
|
65
|
-
"
|
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
|
69
|
-
"
|
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
|
73
|
-
|
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(
|
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, :
|
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 &&
|
27
|
+
return if board_name && organization_name && project_name && team_name
|
28
28
|
|
29
|
-
abort("No current/specified board. Did you
|
29
|
+
abort("No current/specified board. Did you forget to `pick`?")
|
30
30
|
end
|
31
31
|
|
32
32
|
def require_work_item!
|
33
|
-
|
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
|
36
|
+
abort("No current/specified work item. Did you forget to `pick`?")
|
40
37
|
end
|
41
38
|
|
42
|
-
def
|
43
|
-
|
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
|
-
|
46
|
-
|
47
|
-
"
|
48
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
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
|