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.
- checksums.yaml +4 -4
- data/bin/abt +3 -3
- data/lib/abt.rb +6 -6
- data/lib/abt/ari.rb +20 -0
- data/lib/abt/ari_list.rb +13 -0
- data/lib/abt/base_command.rb +63 -0
- data/lib/abt/cli.rb +51 -52
- data/lib/abt/cli/arguments_parser.rb +7 -26
- data/lib/abt/cli/global_commands.rb +23 -0
- data/lib/abt/cli/global_commands/commands.rb +23 -0
- data/lib/abt/cli/global_commands/examples.rb +23 -0
- data/lib/abt/cli/global_commands/help.rb +23 -0
- data/lib/abt/cli/global_commands/readme.rb +23 -0
- data/lib/abt/cli/global_commands/share.rb +36 -0
- data/lib/abt/cli/global_commands/version.rb +23 -0
- data/lib/abt/cli/prompt.rb +64 -51
- data/lib/abt/docs.rb +48 -25
- data/lib/abt/docs/cli.rb +3 -3
- data/lib/abt/docs/markdown.rb +11 -8
- data/lib/abt/git_config.rb +21 -39
- data/lib/abt/helpers.rb +26 -8
- data/lib/abt/providers/asana/api.rb +9 -9
- data/lib/abt/providers/asana/base_command.rb +20 -38
- data/lib/abt/providers/asana/commands/add.rb +18 -15
- data/lib/abt/providers/asana/commands/branch_name.rb +13 -8
- data/lib/abt/providers/asana/commands/clear.rb +8 -7
- data/lib/abt/providers/asana/commands/current.rb +22 -38
- data/lib/abt/providers/asana/commands/finalize.rb +17 -18
- data/lib/abt/providers/asana/commands/harvest_time_entry_data.rb +20 -13
- data/lib/abt/providers/asana/commands/init.rb +8 -41
- data/lib/abt/providers/asana/commands/pick.rb +27 -26
- data/lib/abt/providers/asana/commands/projects.rb +5 -5
- data/lib/abt/providers/asana/commands/share.rb +6 -8
- data/lib/abt/providers/asana/commands/start.rb +33 -24
- data/lib/abt/providers/asana/commands/tasks.rb +6 -5
- data/lib/abt/providers/asana/configuration.rb +46 -44
- data/lib/abt/providers/asana/path.rb +36 -0
- data/lib/abt/providers/devops/api.rb +23 -11
- data/lib/abt/providers/devops/base_command.rb +22 -43
- data/lib/abt/providers/devops/commands/boards.rb +5 -7
- data/lib/abt/providers/devops/commands/branch_name.rb +14 -10
- data/lib/abt/providers/devops/commands/clear.rb +8 -7
- data/lib/abt/providers/devops/commands/current.rb +24 -49
- data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +26 -16
- data/lib/abt/providers/devops/commands/init.rb +33 -26
- data/lib/abt/providers/devops/commands/pick.rb +23 -24
- data/lib/abt/providers/devops/commands/share.rb +7 -6
- data/lib/abt/providers/devops/commands/{work-items.rb → work_items.rb} +3 -3
- data/lib/abt/providers/devops/configuration.rb +27 -56
- data/lib/abt/providers/devops/path.rb +51 -0
- data/lib/abt/providers/git/commands/branch.rb +25 -19
- data/lib/abt/providers/harvest/api.rb +8 -8
- data/lib/abt/providers/harvest/base_command.rb +20 -36
- data/lib/abt/providers/harvest/commands/clear.rb +8 -7
- data/lib/abt/providers/harvest/commands/current.rb +27 -35
- data/lib/abt/providers/harvest/commands/init.rb +10 -40
- data/lib/abt/providers/harvest/commands/pick.rb +15 -12
- data/lib/abt/providers/harvest/commands/projects.rb +5 -5
- data/lib/abt/providers/harvest/commands/share.rb +6 -8
- data/lib/abt/providers/harvest/commands/start.rb +5 -3
- data/lib/abt/providers/harvest/commands/stop.rb +13 -13
- data/lib/abt/providers/harvest/commands/tasks.rb +9 -6
- data/lib/abt/providers/harvest/commands/track.rb +60 -38
- data/lib/abt/providers/harvest/configuration.rb +28 -37
- data/lib/abt/providers/harvest/path.rb +36 -0
- data/lib/abt/version.rb +1 -1
- metadata +18 -6
- 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
|
-
|
9
|
+
"abt tasks asana"
|
10
10
|
end
|
11
11
|
|
12
12
|
def self.description
|
13
|
-
|
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
|
-
|
35
|
-
api.get_paged(
|
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
|
-
|
14
|
+
git.available?
|
16
15
|
end
|
17
16
|
|
18
|
-
def
|
19
|
-
local_available?
|
17
|
+
def path
|
18
|
+
Path.new(local_available? && git["path"] || "")
|
20
19
|
end
|
21
20
|
|
22
|
-
def
|
23
|
-
|
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 =
|
27
|
+
current = git_global["workspaceGid"]
|
29
28
|
if current.nil?
|
30
|
-
|
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[
|
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[
|
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
|
-
|
53
|
+
git_global.clear(output: verbose ? cli.err_output : nil)
|
66
54
|
end
|
67
55
|
|
68
56
|
def access_token
|
69
|
-
return
|
57
|
+
return git_global["accessToken"] unless git_global["accessToken"].nil?
|
70
58
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
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
|
-
|
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[
|
79
|
+
git["finalizedSectionGid"] = section["gid"]
|
86
80
|
section
|
87
81
|
end
|
88
82
|
|
89
83
|
def prompt_wip_section
|
90
|
-
section = prompt_section(
|
91
|
-
git[
|
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
|
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
|
102
|
-
cli.
|
103
|
-
|
104
|
-
if workspaces.
|
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
|
100
|
+
cli.warn("Selected Asana workspace: #{workspace['name']}")
|
109
101
|
else
|
110
|
-
workspace =
|
102
|
+
workspace = pick_workspace
|
111
103
|
end
|
112
104
|
|
113
|
-
|
114
|
-
|
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 =
|
7
|
+
VERBS = [:get, :post, :put].freeze
|
8
8
|
|
9
|
-
CONDITIONAL_ACCESS_POLICY_ERROR_CODE =
|
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[
|
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(
|
36
|
-
ids = response[
|
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(
|
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(
|
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
|
75
|
-
connection.headers[
|
76
|
-
connection.headers[
|
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
|
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::
|
7
|
-
|
6
|
+
class BaseCommand < Abt::BaseCommand
|
7
|
+
extend Forwardable
|
8
8
|
|
9
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
44
|
-
|
45
|
-
|
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(
|
60
|
-
|
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(
|
67
|
-
|
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
|
-
|
9
|
+
"abt boards devops"
|
10
10
|
end
|
11
11
|
|
12
12
|
def self.description
|
13
|
-
|
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
|
-
|
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(
|
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
|
-
|
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
|
-
|
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
|
-
|
19
|
+
puts name
|
20
20
|
rescue HttpError::NotFoundError
|
21
21
|
args = [organization_name, project_name, board_id, work_item_id].compact
|
22
|
-
|
23
|
-
|
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[
|
30
|
-
str +=
|
31
|
-
str += work_item[
|
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(
|
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
|