abt-cli 0.0.28 → 0.0.32
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/abt/providers/asana/commands/clear.rb +1 -1
- data/lib/abt/providers/asana/commands/pick.rb +1 -1
- data/lib/abt/providers/asana/configuration.rb +1 -1
- data/lib/abt/providers/asana/services/task_picker.rb +23 -3
- data/lib/abt/providers/devops/api.rb +18 -21
- data/lib/abt/providers/devops/base_command.rb +16 -19
- data/lib/abt/providers/devops/commands/branch_name.rb +1 -3
- data/lib/abt/providers/devops/commands/clear.rb +1 -1
- data/lib/abt/providers/devops/commands/current.rb +2 -2
- data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +1 -3
- data/lib/abt/providers/devops/commands/pick.rb +3 -13
- data/lib/abt/providers/devops/commands/work_items.rb +2 -3
- data/lib/abt/providers/devops/commands/write_config.rb +3 -3
- data/lib/abt/providers/devops/configuration.rb +1 -1
- data/lib/abt/providers/devops/path.rb +22 -6
- data/lib/abt/providers/devops/services/board_picker.rb +26 -11
- data/lib/abt/providers/devops/services/work_item_picker.rb +9 -3
- data/lib/abt/providers/harvest/commands/clear.rb +1 -1
- data/lib/abt/providers/harvest/commands/pick.rb +1 -1
- data/lib/abt/providers/harvest/commands/track.rb +72 -39
- data/lib/abt/providers/harvest/configuration.rb +1 -1
- data/lib/abt/providers/harvest/harvest_helpers.rb +25 -0
- data/lib/abt/providers/harvest/services/task_picker.rb +1 -1
- data/lib/abt/version.rb +1 -1
- metadata +9 -8
- data/lib/abt/providers/devops/commands/boards.rb +0 -33
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 38d3fa16692941ffa0e720a7a0331d91215e33e37092946df35053f040ee2af8
|
4
|
+
data.tar.gz: b2471d250f4580b9334f7005496e45ae1487eb8e979bcf3aedd975e3f418c79b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c5232fcb04eb5c3c35cae6b29a82586ef289316a3540eec50f63aa55b42a79226e4000d3c6cff37a318cf8b93d17f47df3cccd8a61f68d02e2fccbe0f9aacd18
|
7
|
+
data.tar.gz: 59cc3c70175c71420cbb2c0bc0b6e96d40b34c21a225cf4f2e3e310fa33f228a1792a31d6eeb9b4485ef140f75f88358679e08d301ef7adde736b5f45fe86ddf
|
@@ -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]
|
@@ -28,7 +28,7 @@ module Abt
|
|
28
28
|
return if flags[:"dry-run"]
|
29
29
|
|
30
30
|
if config.local_available?
|
31
|
-
config.path =
|
31
|
+
config.path = path
|
32
32
|
else
|
33
33
|
warn("No local configuration to update - will function as dry run")
|
34
34
|
end
|
@@ -30,7 +30,7 @@ module Abt
|
|
30
30
|
def call
|
31
31
|
task = select_task
|
32
32
|
|
33
|
-
path_with_task = Path.
|
33
|
+
path_with_task = Path.from_gids(project_gid: path.project_gid, task_gid: task["gid"])
|
34
34
|
|
35
35
|
Result.new(task: task, path: path_with_task)
|
36
36
|
end
|
@@ -45,7 +45,7 @@ module Abt
|
|
45
45
|
cli.warn("Section is empty")
|
46
46
|
select_task
|
47
47
|
else
|
48
|
-
cli.prompt.choice("Select a task", tasks, nil_option: true) || select_task
|
48
|
+
cli.prompt.choice("Select a task", options_for_tasks(tasks), nil_option: true) || select_task
|
49
49
|
end
|
50
50
|
end
|
51
51
|
|
@@ -53,12 +53,32 @@ module Abt
|
|
53
53
|
cli.prompt.choice("Which section in #{project['name']}?", sections)
|
54
54
|
end
|
55
55
|
|
56
|
+
def options_for_tasks(tasks)
|
57
|
+
tasks.map do |task|
|
58
|
+
formatted_name = [
|
59
|
+
task["name"],
|
60
|
+
formatted_assignee(task)
|
61
|
+
].compact.join(" ")
|
62
|
+
|
63
|
+
task.merge("name" => formatted_name)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def formatted_assignee(task)
|
68
|
+
name = task.dig("assignee", "name")
|
69
|
+
|
70
|
+
return unless name
|
71
|
+
|
72
|
+
initials = name.split.map(&:chr).join.upcase
|
73
|
+
"(#{initials}👤)"
|
74
|
+
end
|
75
|
+
|
56
76
|
def tasks_in_section(section)
|
57
77
|
cli.warn("Fetching tasks...")
|
58
78
|
tasks = api.get_paged(
|
59
79
|
"tasks",
|
60
80
|
section: section["gid"],
|
61
|
-
opt_fields: "name,completed,permalink_url"
|
81
|
+
opt_fields: "name,completed,permalink_url,assignee.name"
|
62
82
|
)
|
63
83
|
|
64
84
|
# The below filtering is the best we can do with Asanas api, see this:
|
@@ -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,19 +65,17 @@ module Abt
|
|
58
65
|
end
|
59
66
|
|
60
67
|
def base_url
|
61
|
-
"https://#{organization_name}.visualstudio.com
|
62
|
-
end
|
63
|
-
|
64
|
-
def api_endpoint
|
65
|
-
"#{base_url}/_apis"
|
68
|
+
"https://#{organization_name}.visualstudio.com"
|
66
69
|
end
|
67
70
|
|
68
71
|
def url_for_work_item(work_item)
|
69
|
-
|
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']}"
|
70
74
|
end
|
71
75
|
|
72
|
-
def url_for_board(board)
|
73
|
-
|
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}"
|
74
79
|
end
|
75
80
|
|
76
81
|
def sanitize_work_item(work_item)
|
@@ -84,7 +89,7 @@ module Abt
|
|
84
89
|
end
|
85
90
|
|
86
91
|
def connection
|
87
|
-
@connection ||= Faraday.new(
|
92
|
+
@connection ||= Faraday.new(base_url) do |connection|
|
88
93
|
connection.basic_auth(username, access_token)
|
89
94
|
connection.headers["Content-Type"] = "application/json"
|
90
95
|
connection.headers["Accept"] = "application/json; api-version=6.0"
|
@@ -93,14 +98,6 @@ module Abt
|
|
93
98
|
|
94
99
|
private
|
95
100
|
|
96
|
-
# Shamelessly copied from ERB::Util.url_encode
|
97
|
-
# https://apidock.com/ruby/ERB/Util/url_encode
|
98
|
-
def rfc_3986_encode_path_segment(string)
|
99
|
-
string.to_s.b.gsub(/[^a-zA-Z0-9_\-.~]/) do |match|
|
100
|
-
format("%%%02X", match.unpack1("C"))
|
101
|
-
end
|
102
|
-
end
|
103
|
-
|
104
101
|
def handle_denied_by_conditional_access_policy!(exception)
|
105
102
|
raise exception unless exception.message.include?(CONDITIONAL_ACCESS_POLICY_ERROR_CODE)
|
106
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,7 +24,7 @@ module Abt
|
|
24
24
|
end
|
25
25
|
|
26
26
|
def require_board!
|
27
|
-
return if
|
27
|
+
return if board_name && organization_name && project_name && team_name
|
28
28
|
|
29
29
|
abort("No current/specified board. Did you forget to `pick`?")
|
30
30
|
end
|
@@ -36,12 +36,8 @@ module Abt
|
|
36
36
|
abort("No current/specified work item. Did you forget to `pick`?")
|
37
37
|
end
|
38
38
|
|
39
|
-
def prompt_project!
|
40
|
-
@path = Services::ProjectPicker.call(cli: cli).path
|
41
|
-
end
|
42
|
-
|
43
39
|
def prompt_board!
|
44
|
-
result = Services::BoardPicker.call(cli: cli,
|
40
|
+
result = Services::BoardPicker.call(cli: cli, config: config)
|
45
41
|
@path = result.path
|
46
42
|
@board = result.board
|
47
43
|
end
|
@@ -54,7 +50,7 @@ module Abt
|
|
54
50
|
|
55
51
|
def board
|
56
52
|
@board ||= begin
|
57
|
-
api.get("work/boards/#{
|
53
|
+
api.get("#{project_name}/#{team_name}/_apis/work/boards/#{board_name}")
|
58
54
|
rescue HttpError::NotFoundError
|
59
55
|
nil
|
60
56
|
end
|
@@ -62,33 +58,34 @@ module Abt
|
|
62
58
|
|
63
59
|
def work_item
|
64
60
|
@work_item ||= begin
|
65
|
-
work_item = api.get_paged("wit/workitems", ids: work_item_id)[0]
|
61
|
+
work_item = api.get_paged("_apis/wit/workitems", ids: work_item_id)[0]
|
66
62
|
api.sanitize_work_item(work_item)
|
67
63
|
rescue HttpError::NotFoundError
|
68
64
|
nil
|
69
65
|
end
|
70
66
|
end
|
71
67
|
|
72
|
-
def print_board(organization_name, project_name, board)
|
73
|
-
|
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}"
|
74
71
|
|
75
72
|
cli.print_ari("devops", path, board["name"])
|
76
|
-
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
|
77
74
|
end
|
78
75
|
|
79
|
-
def print_work_item(organization, project, board, work_item)
|
80
|
-
|
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']}"
|
81
79
|
|
82
80
|
cli.print_ari("devops", path, work_item["name"])
|
83
81
|
warn(work_item["url"]) if work_item.key?("url") && cli.output.isatty
|
84
82
|
end
|
85
83
|
|
86
84
|
def api
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
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)
|
92
89
|
end
|
93
90
|
end
|
94
91
|
end
|
@@ -19,11 +19,9 @@ module Abt
|
|
19
19
|
if work_item
|
20
20
|
puts name
|
21
21
|
else
|
22
|
-
args = [organization_name, project_name, board_id, work_item_id].compact
|
23
|
-
|
24
22
|
abort(<<~TXT)
|
25
23
|
Unable to find work item for configuration:
|
26
|
-
devops:#{
|
24
|
+
devops:#{path}
|
27
25
|
TXT
|
28
26
|
end
|
29
27
|
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
|
|
@@ -19,11 +19,9 @@ module Abt
|
|
19
19
|
if work_item
|
20
20
|
puts Oj.dump(body, mode: :json)
|
21
21
|
else
|
22
|
-
args = [organization_name, project_name, board_id, work_item_id].compact
|
23
|
-
|
24
22
|
abort(<<~TXT)
|
25
23
|
Unable to find work item for configuration:
|
26
|
-
devops:#{
|
24
|
+
devops:#{path}
|
27
25
|
TXT
|
28
26
|
end
|
29
27
|
end
|
@@ -23,12 +23,12 @@ module Abt
|
|
23
23
|
def perform
|
24
24
|
pick!
|
25
25
|
|
26
|
-
print_work_item(organization_name, project_name, board, work_item)
|
26
|
+
print_work_item(organization_name, project_name, team_name, board, work_item)
|
27
27
|
|
28
28
|
return if flags[:"dry-run"]
|
29
29
|
|
30
30
|
if config.local_available?
|
31
|
-
|
31
|
+
config.path = path
|
32
32
|
else
|
33
33
|
warn("No local configuration to update - will function as dry run")
|
34
34
|
end
|
@@ -37,19 +37,9 @@ module Abt
|
|
37
37
|
private
|
38
38
|
|
39
39
|
def pick!
|
40
|
-
|
41
|
-
prompt_board! if board_id.nil? || flags[:clean]
|
40
|
+
prompt_board! if board_name.nil? || flags[:clean]
|
42
41
|
prompt_work_item!
|
43
42
|
end
|
44
|
-
|
45
|
-
def update_config(work_item)
|
46
|
-
config.path = Path.from_ids(
|
47
|
-
organization_name: organization_name,
|
48
|
-
project_name: project_name,
|
49
|
-
board_id: board_id,
|
50
|
-
work_item_id: work_item["id"]
|
51
|
-
)
|
52
|
-
end
|
53
43
|
end
|
54
44
|
end
|
55
45
|
end
|
@@ -14,11 +14,10 @@ module Abt
|
|
14
14
|
end
|
15
15
|
|
16
16
|
def perform
|
17
|
-
|
18
|
-
prompt_board! unless board_id
|
17
|
+
prompt_board! unless board_name
|
19
18
|
|
20
19
|
work_items.each do |work_item|
|
21
|
-
print_work_item(organization_name, project_name, board, work_item)
|
20
|
+
print_work_item(organization_name, project_name, team_name, board, work_item)
|
22
21
|
end
|
23
22
|
end
|
24
23
|
|
@@ -20,8 +20,7 @@ module Abt
|
|
20
20
|
end
|
21
21
|
|
22
22
|
def perform
|
23
|
-
|
24
|
-
prompt_board! if board_id.nil? || flags[:clean]
|
23
|
+
prompt_board! if board_name.nil? || flags[:clean]
|
25
24
|
|
26
25
|
update_directory_config!
|
27
26
|
|
@@ -35,7 +34,8 @@ module Abt
|
|
35
34
|
"path" => Path.from_ids(
|
36
35
|
organization_name: organization_name,
|
37
36
|
project_name: project_name,
|
38
|
-
|
37
|
+
team_name: team_name,
|
38
|
+
board_name: board_name
|
39
39
|
).to_s
|
40
40
|
}
|
41
41
|
cli.directory_config.save!
|
@@ -15,7 +15,7 @@ module Abt
|
|
15
15
|
end
|
16
16
|
|
17
17
|
def path
|
18
|
-
Path.new(local_available? && git["path"] || cli.directory_config.dig("devops", "path") || "")
|
18
|
+
Path.new((local_available? && git["path"]) || cli.directory_config.dig("devops", "path") || "")
|
19
19
|
end
|
20
20
|
|
21
21
|
def path=(new_path)
|
@@ -6,16 +6,28 @@ module Abt
|
|
6
6
|
class Path < String
|
7
7
|
ORGANIZATION_NAME_REGEX = %r{(?<organization_name>[^/ ]+)}.freeze
|
8
8
|
PROJECT_NAME_REGEX = %r{(?<project_name>[^/ ]+)}.freeze
|
9
|
-
|
9
|
+
TEAM_NAME_REGEX = %r{(?<team_name>[^/ ]+)}.freeze
|
10
|
+
BOARD_NAME_REGEX = %r{(?<board_name>[^/ ]+)}.freeze
|
10
11
|
WORK_ITEM_ID_REGEX = /(?<work_item_id>\d+)/.freeze
|
11
12
|
|
12
13
|
PATH_REGEX =
|
13
|
-
%r{^(#{ORGANIZATION_NAME_REGEX}/#{PROJECT_NAME_REGEX}(/#{
|
14
|
+
%r{^(#{ORGANIZATION_NAME_REGEX}/#{PROJECT_NAME_REGEX}(/#{TEAM_NAME_REGEX}(/#{BOARD_NAME_REGEX}(/#{WORK_ITEM_ID_REGEX})?)?)?)?}.freeze # rubocop:disable Layout/LineLength
|
14
15
|
|
15
|
-
def self.from_ids(organization_name: nil, project_name: nil,
|
16
|
+
def self.from_ids(organization_name: nil, project_name: nil, team_name: nil, board_name: nil, work_item_id: nil)
|
16
17
|
return new unless organization_name && project_name
|
17
18
|
|
18
|
-
|
19
|
+
parts = [organization_name, project_name]
|
20
|
+
|
21
|
+
if team_name
|
22
|
+
parts << team_name
|
23
|
+
|
24
|
+
if board_name
|
25
|
+
parts << board_name
|
26
|
+
parts << work_item_id if work_item_id
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
new(parts.join("/"))
|
19
31
|
end
|
20
32
|
|
21
33
|
def initialize(path = "")
|
@@ -32,8 +44,12 @@ module Abt
|
|
32
44
|
match[:project_name]
|
33
45
|
end
|
34
46
|
|
35
|
-
def
|
36
|
-
match[:
|
47
|
+
def team_name
|
48
|
+
match[:team_name]
|
49
|
+
end
|
50
|
+
|
51
|
+
def board_name
|
52
|
+
match[:board_name]
|
37
53
|
end
|
38
54
|
|
39
55
|
def work_item_id
|
@@ -20,32 +20,47 @@ module Abt
|
|
20
20
|
|
21
21
|
attr_reader :cli, :config, :path
|
22
22
|
|
23
|
-
def initialize(cli:,
|
23
|
+
def initialize(cli:, config:)
|
24
24
|
@cli = cli
|
25
25
|
@config = config
|
26
|
-
@path = path
|
27
26
|
end
|
28
27
|
|
29
28
|
def call
|
29
|
+
@path = ProjectPicker.call(cli: cli).path
|
30
30
|
board = cli.prompt.choice("Select a project work board", boards)
|
31
31
|
|
32
|
-
|
33
|
-
|
34
|
-
Result.new(board: board, path: path_with_board)
|
32
|
+
Result.new(board: board, path: path_with_board(team, board))
|
35
33
|
end
|
36
34
|
|
37
35
|
private
|
38
36
|
|
37
|
+
def path_with_board(team, board)
|
38
|
+
Path.from_ids(
|
39
|
+
organization_name: path.organization_name,
|
40
|
+
project_name: path.project_name,
|
41
|
+
team_name: Api.rfc_3986_encode_path_segment(team["name"]),
|
42
|
+
board_name: Api.rfc_3986_encode_path_segment(board["name"])
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
def team
|
47
|
+
@team ||= cli.prompt.choice("Select a team", teams)
|
48
|
+
end
|
49
|
+
|
50
|
+
def teams
|
51
|
+
@teams ||= api.get_paged("/_apis/projects/#{path.project_name}/teams")
|
52
|
+
end
|
53
|
+
|
39
54
|
def boards
|
40
|
-
|
55
|
+
team_name = Api.rfc_3986_encode_path_segment(team["name"])
|
56
|
+
@boards ||= api.get_paged("#{path.project_name}/#{team_name}/_apis/work/boards")
|
41
57
|
end
|
42
58
|
|
43
59
|
def api
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
cli: cli)
|
60
|
+
Api.new(organization_name: path.organization_name,
|
61
|
+
username: config.username_for_organization(path.organization_name),
|
62
|
+
access_token: config.access_token_for_organization(path.organization_name),
|
63
|
+
cli: cli)
|
49
64
|
end
|
50
65
|
end
|
51
66
|
end
|
@@ -30,7 +30,13 @@ module Abt
|
|
30
30
|
def call
|
31
31
|
work_item = select_work_item
|
32
32
|
|
33
|
-
path_with_work_item = Path.
|
33
|
+
path_with_work_item = Path.from_ids(
|
34
|
+
organization_name: path.organization_name,
|
35
|
+
project_name: path.project_name,
|
36
|
+
team_name: path.team_name,
|
37
|
+
board_name: path.board_name,
|
38
|
+
work_item_id: work_item["id"]
|
39
|
+
)
|
34
40
|
|
35
41
|
Result.new(work_item: work_item, path: path_with_work_item)
|
36
42
|
end
|
@@ -74,14 +80,14 @@ module Abt
|
|
74
80
|
end
|
75
81
|
|
76
82
|
def columns
|
77
|
-
board["columns"] ||
|
83
|
+
board["columns"] ||
|
84
|
+
api.get("#{path.project_name}/#{path.team_name}/_apis/work/boards/#{path.board_name}")["columns"]
|
78
85
|
end
|
79
86
|
|
80
87
|
private
|
81
88
|
|
82
89
|
def api
|
83
90
|
Abt::Providers::Devops::Api.new(organization_name: path.organization_name,
|
84
|
-
project_name: path.project_name,
|
85
91
|
username: config.username_for_organization(path.organization_name),
|
86
92
|
access_token: config.access_token_for_organization(path.organization_name),
|
87
93
|
cli: cli)
|
@@ -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]
|
@@ -4,7 +4,7 @@ module Abt
|
|
4
4
|
module Providers
|
5
5
|
module Harvest
|
6
6
|
module Commands
|
7
|
-
class Track < BaseCommand
|
7
|
+
class Track < BaseCommand # rubocop:disable Metrics/ClassLength
|
8
8
|
def self.usage
|
9
9
|
"abt track harvest[:<project-id>/<task-id>] [options]"
|
10
10
|
end
|
@@ -20,15 +20,21 @@ module Abt
|
|
20
20
|
["-s", "--set", "Set specified task as current"],
|
21
21
|
["-c", "--comment COMMENT", "Override comment"],
|
22
22
|
["-t", "--time HOURS",
|
23
|
-
"
|
24
|
-
["-
|
23
|
+
"Track amount of hours, this will create a stopped entry."],
|
24
|
+
["-i", "--since HH:MM",
|
25
|
+
"Start entry today at specified time. The computed duration will be deducted from the running entry if one exists."] # rubocop:disable Layout/LineLength
|
25
26
|
]
|
26
27
|
end
|
27
28
|
|
28
29
|
def perform
|
30
|
+
abort("Flags --time and --since cannot be used together") if flags[:time] && flags[:since]
|
31
|
+
|
29
32
|
require_task!
|
30
33
|
|
31
|
-
|
34
|
+
maybe_adjust_previous_entry
|
35
|
+
entry = create_entry!
|
36
|
+
|
37
|
+
print_task(entry["project"], entry["task"])
|
32
38
|
|
33
39
|
maybe_override_current_task
|
34
40
|
rescue Abt::HttpError::HttpError => _e
|
@@ -37,31 +43,41 @@ module Abt
|
|
37
43
|
|
38
44
|
private
|
39
45
|
|
40
|
-
def
|
41
|
-
|
46
|
+
def create_entry!
|
47
|
+
result = api.post("time_entries", Oj.dump(entry_data, mode: :json))
|
48
|
+
api.patch("time_entries/#{result['id']}/restart") if flags.key?(:since)
|
49
|
+
result
|
42
50
|
end
|
43
51
|
|
44
|
-
def
|
45
|
-
|
52
|
+
def maybe_adjust_previous_entry
|
53
|
+
return unless flags.key?(:since)
|
54
|
+
return unless since_flag_duration # Ensure --since flag is valid before fetching data
|
55
|
+
return unless previous_entry
|
56
|
+
|
57
|
+
adjust_previous_entry
|
58
|
+
end
|
46
59
|
|
47
|
-
|
60
|
+
def adjust_previous_entry
|
61
|
+
updated_hours = previous_entry["hours"] - since_flag_duration
|
62
|
+
abort("Cannot adjust previous entry to a negative duration") if updated_hours <= 0
|
48
63
|
|
49
|
-
api.patch("time_entries/#{
|
64
|
+
api.patch("time_entries/#{previous_entry['id']}", Oj.dump({ hours: updated_hours }, mode: :json))
|
50
65
|
|
51
|
-
|
66
|
+
subtracted_minutes = (since_flag_duration * 60).round
|
67
|
+
warn("~#{subtracted_minutes} minute(s) subtracted from previous entry")
|
52
68
|
end
|
53
69
|
|
54
|
-
def
|
55
|
-
body =
|
70
|
+
def entry_data
|
71
|
+
body = entry_base_data
|
56
72
|
|
57
73
|
maybe_add_external_link(body)
|
58
74
|
maybe_add_comment(body)
|
59
|
-
|
75
|
+
maybe_add_hours(body)
|
60
76
|
|
61
77
|
body
|
62
78
|
end
|
63
79
|
|
64
|
-
def
|
80
|
+
def entry_base_data
|
65
81
|
{
|
66
82
|
project_id: project_id,
|
67
83
|
task_id: task_id,
|
@@ -74,8 +90,8 @@ module Abt
|
|
74
90
|
if external_link_data
|
75
91
|
warn(<<~TXT)
|
76
92
|
Linking to:
|
77
|
-
|
78
|
-
|
93
|
+
#{external_link_data[:notes]}
|
94
|
+
#{external_link_data[:external_reference][:permalink]}
|
79
95
|
TXT
|
80
96
|
body.merge!(external_link_data)
|
81
97
|
else
|
@@ -83,38 +99,40 @@ module Abt
|
|
83
99
|
end
|
84
100
|
end
|
85
101
|
|
86
|
-
def maybe_add_comment(body)
|
87
|
-
body[:notes] = flags[:comment] if flags.key?(:comment)
|
88
|
-
body[:notes] ||= cli.prompt.text("Fill in comment (optional)")
|
89
|
-
end
|
90
|
-
|
91
|
-
def maybe_add_time(body)
|
92
|
-
body[:hours] = flags[:time] if flags.key?(:time)
|
93
|
-
end
|
94
|
-
|
95
102
|
def external_link_data
|
96
103
|
return @external_link_data if instance_variable_defined?(:@external_link_data)
|
104
|
+
return @external_link_data = nil if link_data_lines.empty?
|
97
105
|
|
98
|
-
|
99
|
-
|
100
|
-
return @external_link_data = nil if lines.empty?
|
101
|
-
|
102
|
-
if lines.length > 1
|
106
|
+
if link_data_lines.length > 1
|
103
107
|
abort("Got reference data from multiple scheme providers, only one is supported at a time")
|
104
108
|
end
|
105
109
|
|
106
|
-
@external_link_data = Oj.load(
|
110
|
+
@external_link_data = Oj.load(link_data_lines.first, symbol_keys: true)
|
107
111
|
end
|
108
112
|
|
109
|
-
def
|
110
|
-
|
111
|
-
|
113
|
+
def link_data_lines
|
114
|
+
@link_data_lines ||= begin
|
115
|
+
other_aris = cli.aris - [ari]
|
116
|
+
other_aris.map do |other_ari|
|
117
|
+
input = StringIO.new(other_ari.to_s)
|
118
|
+
output = StringIO.new
|
119
|
+
Abt::Cli.new(argv: ["harvest-time-entry-data"], output: output, input: input).perform
|
120
|
+
output.string.chomp
|
121
|
+
end.reject(&:empty?)
|
122
|
+
end
|
123
|
+
end
|
112
124
|
|
113
|
-
|
114
|
-
|
115
|
-
|
125
|
+
def maybe_add_comment(body)
|
126
|
+
body[:notes] = flags[:comment] if flags.key?(:comment)
|
127
|
+
body[:notes] ||= cli.prompt.text("Fill in comment (optional)")
|
128
|
+
end
|
116
129
|
|
117
|
-
|
130
|
+
def maybe_add_hours(body)
|
131
|
+
if flags[:time]
|
132
|
+
body[:hours] = flags[:time]
|
133
|
+
elsif flags[:since]
|
134
|
+
body[:hours] = since_flag_duration
|
135
|
+
end
|
118
136
|
end
|
119
137
|
|
120
138
|
def maybe_override_current_task
|
@@ -125,6 +143,21 @@ module Abt
|
|
125
143
|
config.path = path
|
126
144
|
warn("Current task updated")
|
127
145
|
end
|
146
|
+
|
147
|
+
def since_flag_duration
|
148
|
+
@since_flag_duration ||= begin
|
149
|
+
since_hours = HarvestHelpers.decimal_hours_from_string(flags[:since])
|
150
|
+
now_hours = HarvestHelpers.decimal_hours_from_string(Time.now.strftime("%T"))
|
151
|
+
|
152
|
+
abort("Specified \"since\" time (#{flags[:since]}) is in the future") if now_hours <= since_hours
|
153
|
+
|
154
|
+
now_hours - since_hours
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def previous_entry
|
159
|
+
@previous_entry ||= api.get_paged("time_entries", is_running: true, user_id: config.user_id).first
|
160
|
+
end
|
128
161
|
end
|
129
162
|
end
|
130
163
|
end
|
@@ -15,7 +15,7 @@ module Abt
|
|
15
15
|
end
|
16
16
|
|
17
17
|
def path
|
18
|
-
Path.new(local_available? && git["path"] || cli.directory_config.dig("harvest", "path") || "")
|
18
|
+
Path.new((local_available? && git["path"]) || cli.directory_config.dig("harvest", "path") || "")
|
19
19
|
end
|
20
20
|
|
21
21
|
def path=(new_path)
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Harvest
|
6
|
+
class HarvestHelpers
|
7
|
+
class << self
|
8
|
+
HOURS_REGEX = /(?<hours>\d+)/.freeze
|
9
|
+
MINUTES_REGEX = /(?<minutes>[0-5][0-9])/.freeze
|
10
|
+
SECONDS_REGEX = /(?<seconds>[0-5][0-9])/.freeze
|
11
|
+
TIME_REGEX = /^#{HOURS_REGEX}:#{MINUTES_REGEX}(?::#{SECONDS_REGEX})?$/.freeze
|
12
|
+
|
13
|
+
def decimal_hours_from_string(hh_mm_ss)
|
14
|
+
match = TIME_REGEX.match(hh_mm_ss)
|
15
|
+
raise Abt::Cli::Abort, "Invalid time: #{hh_mm_ss}, supported formats are: HH:MM, HH:MM:SS" if match.nil?
|
16
|
+
|
17
|
+
match[:hours].to_i +
|
18
|
+
(match[:minutes].to_i / 60.0) +
|
19
|
+
(match[:seconds].to_i / 60.0 / 60.0)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -29,7 +29,7 @@ module Abt
|
|
29
29
|
def call
|
30
30
|
task = cli.prompt.choice("Select a task from #{project['name']}", tasks)
|
31
31
|
|
32
|
-
path_with_task = Path.
|
32
|
+
path_with_task = Path.from_ids(project_id: path.project_id, task_id: task["id"])
|
33
33
|
|
34
34
|
Result.new(task: task, path: path_with_task)
|
35
35
|
end
|
data/lib/abt/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: abt-cli
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.32
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jesper Sørensen
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
11
|
+
date: 2021-12-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: dry-inflector
|
@@ -66,7 +66,7 @@ dependencies:
|
|
66
66
|
- - "~>"
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '2.0'
|
69
|
-
description:
|
69
|
+
description:
|
70
70
|
email:
|
71
71
|
- js@abtion.com
|
72
72
|
executables:
|
@@ -118,7 +118,6 @@ files:
|
|
118
118
|
- "./lib/abt/providers/devops.rb"
|
119
119
|
- "./lib/abt/providers/devops/api.rb"
|
120
120
|
- "./lib/abt/providers/devops/base_command.rb"
|
121
|
-
- "./lib/abt/providers/devops/commands/boards.rb"
|
122
121
|
- "./lib/abt/providers/devops/commands/branch_name.rb"
|
123
122
|
- "./lib/abt/providers/devops/commands/clear.rb"
|
124
123
|
- "./lib/abt/providers/devops/commands/current.rb"
|
@@ -148,6 +147,7 @@ files:
|
|
148
147
|
- "./lib/abt/providers/harvest/commands/track.rb"
|
149
148
|
- "./lib/abt/providers/harvest/commands/write_config.rb"
|
150
149
|
- "./lib/abt/providers/harvest/configuration.rb"
|
150
|
+
- "./lib/abt/providers/harvest/harvest_helpers.rb"
|
151
151
|
- "./lib/abt/providers/harvest/path.rb"
|
152
152
|
- "./lib/abt/providers/harvest/services/project_picker.rb"
|
153
153
|
- "./lib/abt/providers/harvest/services/task_picker.rb"
|
@@ -159,7 +159,8 @@ licenses:
|
|
159
159
|
metadata:
|
160
160
|
homepage_uri: https://github.com/abtion/abt
|
161
161
|
source_code_uri: https://github.com/abtion/abt
|
162
|
-
|
162
|
+
rubygems_mfa_required: 'true'
|
163
|
+
post_install_message:
|
163
164
|
rdoc_options: []
|
164
165
|
require_paths:
|
165
166
|
- lib
|
@@ -174,8 +175,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
174
175
|
- !ruby/object:Gem::Version
|
175
176
|
version: '0'
|
176
177
|
requirements: []
|
177
|
-
rubygems_version: 3.
|
178
|
-
signing_key:
|
178
|
+
rubygems_version: 3.2.15
|
179
|
+
signing_key:
|
179
180
|
specification_version: 4
|
180
181
|
summary: Versatile scripts
|
181
182
|
test_files: []
|
@@ -1,33 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Abt
|
4
|
-
module Providers
|
5
|
-
module Devops
|
6
|
-
module Commands
|
7
|
-
class Boards < BaseCommand
|
8
|
-
def self.usage
|
9
|
-
"abt boards devops"
|
10
|
-
end
|
11
|
-
|
12
|
-
def self.description
|
13
|
-
"List all boards - useful for piping into grep etc"
|
14
|
-
end
|
15
|
-
|
16
|
-
def perform
|
17
|
-
prompt_project! unless project_name
|
18
|
-
|
19
|
-
boards.map do |board|
|
20
|
-
print_board(organization_name, project_name, board)
|
21
|
-
end
|
22
|
-
end
|
23
|
-
|
24
|
-
private
|
25
|
-
|
26
|
-
def boards
|
27
|
-
@boards ||= api.get_paged("work/boards")
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|
33
|
-
end
|