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.
- 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
|