abt-cli 0.0.6 → 0.0.11
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 +1 -0
- data/lib/abt/cli.rb +5 -5
- data/lib/abt/cli/dialogs.rb +10 -7
- data/lib/abt/docs.rb +4 -0
- data/lib/abt/git_config.rb +13 -0
- data/lib/abt/providers/asana/commands/harvest_time_entry_data.rb +2 -4
- data/lib/abt/providers/asana/commands/init.rb +5 -0
- data/lib/abt/providers/asana/commands/pick.rb +16 -4
- data/lib/abt/providers/asana/configuration.rb +9 -3
- data/lib/abt/providers/devops.rb +19 -0
- data/lib/abt/providers/devops/api.rb +65 -0
- data/lib/abt/providers/devops/base_command.rb +81 -0
- data/lib/abt/providers/devops/commands/clear.rb +24 -0
- data/lib/abt/providers/devops/commands/clear_global.rb +24 -0
- data/lib/abt/providers/devops/commands/current.rb +97 -0
- data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +51 -0
- data/lib/abt/providers/devops/commands/init.rb +72 -0
- data/lib/abt/providers/devops/commands/pick.rb +76 -0
- data/lib/abt/providers/devops/commands/share.rb +32 -0
- data/lib/abt/providers/devops/configuration.rb +110 -0
- data/lib/abt/providers/harvest/commands/init.rb +11 -2
- data/lib/abt/providers/harvest/commands/start.rb +23 -57
- data/lib/abt/providers/harvest/commands/track.rb +73 -0
- data/lib/abt/providers/harvest/configuration.rb +4 -3
- data/lib/abt/version.rb +1 -1
- metadata +14 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0cc34f7579a887a49ae83db56c8b3808a0e00b09c55631f9b4224228477a0762
|
4
|
+
data.tar.gz: 9c26a06c3af53ebe55ca5e53f93b5d6d73854962daebef31d131000657be1c63
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4fe10297a130b747d93dc1398bdf404996d5c07987680deac59a5365d6893ab3d0466ebd764ef087f5eb2409b99a7733dc55a869432d81286f3460ec510d247d
|
7
|
+
data.tar.gz: 3684a1d3113f9df06637a6c7294f334b48e2c8cd5e54d4334c1ec1884571519f6e5aedeec327d8292f5364d1251453d6a7a8131074f91b2f94935b7a3586ddb9
|
data/bin/abt
CHANGED
data/lib/abt/cli.rb
CHANGED
@@ -20,7 +20,7 @@ module Abt
|
|
20
20
|
@output = output
|
21
21
|
@err_output = err_output
|
22
22
|
|
23
|
-
@args +=
|
23
|
+
@args += args_from_input unless input.isatty # Add piped arguments
|
24
24
|
end
|
25
25
|
|
26
26
|
def perform
|
@@ -57,13 +57,13 @@ module Abt
|
|
57
57
|
end
|
58
58
|
end
|
59
59
|
|
60
|
-
def
|
61
|
-
|
60
|
+
def args_from_input
|
61
|
+
input_string = input.read
|
62
62
|
|
63
|
-
|
63
|
+
abort 'No input from pipe' if input_string.nil? || input_string.empty?
|
64
64
|
|
65
65
|
# Exclude comment part of piped input lines
|
66
|
-
lines_without_comments =
|
66
|
+
lines_without_comments = input_string.lines.map do |line|
|
67
67
|
line.split(' # ').first
|
68
68
|
end
|
69
69
|
|
data/lib/abt/cli/dialogs.rb
CHANGED
@@ -25,13 +25,15 @@ module Abt
|
|
25
25
|
end
|
26
26
|
|
27
27
|
def prompt_choice(text, options, allow_back_option = false)
|
28
|
-
if options.one?
|
29
|
-
warn "Selected: #{options.first['name']}"
|
30
|
-
return options.first
|
31
|
-
end
|
32
|
-
|
33
28
|
warn "#{text}:"
|
34
29
|
|
30
|
+
if options.length.zero?
|
31
|
+
abort 'No available options' unless allow_back_option
|
32
|
+
|
33
|
+
warn 'No available options'
|
34
|
+
return nil
|
35
|
+
end
|
36
|
+
|
35
37
|
print_options(options)
|
36
38
|
select_options(options, allow_back_option)
|
37
39
|
end
|
@@ -45,11 +47,12 @@ module Abt
|
|
45
47
|
end
|
46
48
|
|
47
49
|
def select_options(options, allow_back_option)
|
48
|
-
|
50
|
+
loop do
|
51
|
+
number = read_option_number(options.length, allow_back_option)
|
49
52
|
if number.nil?
|
50
53
|
return nil if allow_back_option
|
51
54
|
|
52
|
-
|
55
|
+
next
|
53
56
|
end
|
54
57
|
|
55
58
|
option = options[number - 1]
|
data/lib/abt/docs.rb
CHANGED
@@ -17,6 +17,10 @@ module Abt
|
|
17
17
|
'abt start asana harvest' => 'Continue working, e.g. after a break',
|
18
18
|
'abt finalize asana' => 'Finalize the selected asana task'
|
19
19
|
},
|
20
|
+
'Tracking meetings (without changing the config):' => {
|
21
|
+
'abt tasks asana | grep -i standup | abt track harvest' => 'Track on asana meeting task without changing any configuration',
|
22
|
+
'abt tasks harvest | grep -i comment | abt track harvest' => 'Track on harvest "Comment"-task (will prompt for a comment)'
|
23
|
+
},
|
20
24
|
'Command output can be piped, e.g.:' => {
|
21
25
|
'abt tasks asana | grep -i <name of task>' => nil,
|
22
26
|
'abt tasks asana | grep -i <name of task> | abt start' => nil
|
data/lib/abt/git_config.rb
CHANGED
@@ -32,6 +32,19 @@ module Abt
|
|
32
32
|
set(key, value)
|
33
33
|
end
|
34
34
|
|
35
|
+
def full_keys
|
36
|
+
if scope == 'local' && !self.class.local_available?
|
37
|
+
raise StandardError, 'Local configuration is not available outside a git repository'
|
38
|
+
end
|
39
|
+
|
40
|
+
`git config --#{scope} --get-regexp --name-only ^#{namespace}`.lines.map(&:strip)
|
41
|
+
end
|
42
|
+
|
43
|
+
def keys
|
44
|
+
offset = namespace.length + 1
|
45
|
+
full_keys.map { |key| key[offset..-1] }
|
46
|
+
end
|
47
|
+
|
35
48
|
def local
|
36
49
|
@local ||= begin
|
37
50
|
if scope == 'local'
|
@@ -13,7 +13,7 @@ module Abt
|
|
13
13
|
'Print Harvest time entry data for Asana task as json. Used by harvest start script.'
|
14
14
|
end
|
15
15
|
|
16
|
-
def call
|
16
|
+
def call
|
17
17
|
ensure_current_is_valid!
|
18
18
|
|
19
19
|
body = {
|
@@ -21,9 +21,7 @@ module Abt
|
|
21
21
|
external_reference: {
|
22
22
|
id: task_gid.to_i,
|
23
23
|
group_id: project_gid.to_i,
|
24
|
-
permalink: task['permalink_url']
|
25
|
-
service: 'app.asana.com',
|
26
|
-
service_icon_url: 'https://proxy.harvestfiles.com/production_harvestapp_public/uploads/platform_icons/app.asana.com.png'
|
24
|
+
permalink: task['permalink_url']
|
27
25
|
}
|
28
26
|
}
|
29
27
|
|
@@ -13,6 +13,11 @@ module Abt
|
|
13
13
|
'Pick Asana project for current git repository'
|
14
14
|
end
|
15
15
|
|
16
|
+
def initialize(cli:, **)
|
17
|
+
@config = Configuration.new(cli: cli)
|
18
|
+
@cli = cli
|
19
|
+
end
|
20
|
+
|
16
21
|
def call
|
17
22
|
cli.abort 'Must be run inside a git repository' unless config.local_available?
|
18
23
|
|
@@ -18,7 +18,7 @@ module Abt
|
|
18
18
|
|
19
19
|
cli.warn project['name']
|
20
20
|
|
21
|
-
task =
|
21
|
+
task = select_task
|
22
22
|
|
23
23
|
config.project_gid = project_gid # We might have gotten the project ID as an argument
|
24
24
|
config.task_gid = task['gid']
|
@@ -32,14 +32,26 @@ module Abt
|
|
32
32
|
@project ||= api.get("projects/#{project_gid}")
|
33
33
|
end
|
34
34
|
|
35
|
-
def
|
36
|
-
|
35
|
+
def select_task
|
36
|
+
loop do
|
37
37
|
section = cli.prompt_choice 'Which section?', sections
|
38
38
|
cli.warn 'Fetching tasks...'
|
39
|
-
|
39
|
+
tasks = tasks_in_section(section)
|
40
|
+
|
41
|
+
if tasks.length.zero?
|
42
|
+
cli.warn 'Section is empty'
|
43
|
+
next
|
44
|
+
end
|
45
|
+
|
46
|
+
task = cli.prompt_choice 'Select a task', tasks, true
|
47
|
+
return task if task
|
40
48
|
end
|
41
49
|
end
|
42
50
|
|
51
|
+
def tasks_in_section(section)
|
52
|
+
api.get_paged('tasks', section: section['gid'], opt_fields: 'name,permalink_url')
|
53
|
+
end
|
54
|
+
|
43
55
|
def sections
|
44
56
|
@sections ||= begin
|
45
57
|
cli.warn 'Fetching sections...'
|
@@ -67,8 +67,10 @@ module Abt
|
|
67
67
|
end
|
68
68
|
|
69
69
|
def clear_global
|
70
|
-
git.global
|
71
|
-
|
70
|
+
git.global.keys.each do |key|
|
71
|
+
cli.puts 'Deleting configuration: ' + key
|
72
|
+
git.global[key] = nil
|
73
|
+
end
|
72
74
|
end
|
73
75
|
|
74
76
|
def access_token
|
@@ -109,9 +111,13 @@ module Abt
|
|
109
111
|
workspaces = api.get_paged('workspaces')
|
110
112
|
if workspaces.empty?
|
111
113
|
cli.abort 'Your asana access token does not have access to any workspaces'
|
114
|
+
elsif workspaces.one?
|
115
|
+
workspace = workspaces.first
|
116
|
+
cli.warn "Selected Asana workspace #{workspace['name']}"
|
117
|
+
else
|
118
|
+
workspace = cli.prompt_choice('Select Asana workspace', workspaces)
|
112
119
|
end
|
113
120
|
|
114
|
-
workspace = cli.prompt_choice('Select Asana workspace', workspaces)
|
115
121
|
git.global['workspaceGid'] = workspace['gid']
|
116
122
|
workspace
|
117
123
|
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Dir.glob("#{File.expand_path(__dir__)}/devops/*.rb").sort.each { |file| require file }
|
4
|
+
Dir.glob("#{File.expand_path(__dir__)}/devops/commands/*.rb").sort.each { |file| require file }
|
5
|
+
|
6
|
+
module Abt
|
7
|
+
module Providers
|
8
|
+
module Devops
|
9
|
+
def self.command_names
|
10
|
+
Commands.constants.sort.map { |constant_name| Helpers.const_to_command(constant_name) }
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.command_class(name)
|
14
|
+
const_name = Helpers.command_to_const(name)
|
15
|
+
Commands.const_get(const_name) if Commands.const_defined?(const_name)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
class Api
|
7
|
+
VERBS = %i[get post put].freeze
|
8
|
+
|
9
|
+
attr_reader :organization_name, :project_name, :username, :access_token
|
10
|
+
|
11
|
+
def initialize(organization_name:, project_name:, username:, access_token:)
|
12
|
+
@organization_name = organization_name
|
13
|
+
@project_name = project_name
|
14
|
+
@username = username
|
15
|
+
@access_token = access_token
|
16
|
+
end
|
17
|
+
|
18
|
+
VERBS.each do |verb|
|
19
|
+
define_method(verb) do |*args|
|
20
|
+
request(verb, *args)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def get_paged(path, query = {})
|
25
|
+
result = request(:get, path, query)
|
26
|
+
result['value']
|
27
|
+
|
28
|
+
# TODO: Loop if necessary
|
29
|
+
end
|
30
|
+
|
31
|
+
def request(*args)
|
32
|
+
response = connection.public_send(*args)
|
33
|
+
|
34
|
+
if response.success?
|
35
|
+
Oj.load(response.body)
|
36
|
+
else
|
37
|
+
error_class = Abt::HttpError.error_class_for_status(response.status)
|
38
|
+
encoded_response_body = response.body.force_encoding('utf-8')
|
39
|
+
raise error_class, "Code: #{response.status}, body: #{encoded_response_body}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def base_url
|
44
|
+
"https://#{organization_name}.visualstudio.com/#{project_name}"
|
45
|
+
end
|
46
|
+
|
47
|
+
def api_endpoint
|
48
|
+
"#{base_url}/_apis"
|
49
|
+
end
|
50
|
+
|
51
|
+
def url_for_work_item(work_item)
|
52
|
+
"#{base_url}/_workitems/edit/#{work_item['id']}"
|
53
|
+
end
|
54
|
+
|
55
|
+
def connection
|
56
|
+
@connection ||= Faraday.new(api_endpoint) do |connection|
|
57
|
+
connection.basic_auth username, access_token
|
58
|
+
connection.headers['Content-Type'] = 'application/json'
|
59
|
+
connection.headers['Accept'] = 'application/json; api-version=6.0'
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
class BaseCommand
|
7
|
+
attr_reader :arg_str, :organization_name, :project_name, :board_id, :work_item_id, :cli, :config
|
8
|
+
|
9
|
+
def initialize(arg_str:, cli:)
|
10
|
+
@arg_str = arg_str
|
11
|
+
|
12
|
+
@config = Configuration.new(cli: cli)
|
13
|
+
@cli = cli
|
14
|
+
|
15
|
+
if arg_str.nil?
|
16
|
+
use_current_args
|
17
|
+
else
|
18
|
+
use_arg_str(arg_str)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def sanitize_work_item(work_item)
|
25
|
+
return nil if work_item.nil?
|
26
|
+
|
27
|
+
work_item.merge(
|
28
|
+
'id' => work_item['id'].to_s,
|
29
|
+
'name' => work_item['fields']['System.Title'],
|
30
|
+
'url' => api.url_for_work_item(work_item)
|
31
|
+
)
|
32
|
+
end
|
33
|
+
|
34
|
+
def same_args_as_config?
|
35
|
+
organization_name == config.organization_name &&
|
36
|
+
project_name == config.project_name &&
|
37
|
+
board_id == config.board_id &&
|
38
|
+
work_item_id == config.work_item_id
|
39
|
+
end
|
40
|
+
|
41
|
+
def print_board(organization_name, project_name, board)
|
42
|
+
arg_str = "#{organization_name}/#{project_name}/#{board['id']}"
|
43
|
+
|
44
|
+
cli.print_provider_command('devops', arg_str, board['name'])
|
45
|
+
# cli.warn board['url'] if board.key?('url') && cli.output.isatty # TODO: Web URL
|
46
|
+
end
|
47
|
+
|
48
|
+
def print_work_item(organization, project, board, work_item)
|
49
|
+
arg_str = "#{organization}/#{project}/#{board['id']}/#{work_item['id']}"
|
50
|
+
|
51
|
+
cli.print_provider_command('devops', arg_str, work_item['name'])
|
52
|
+
cli.warn work_item['url'] if work_item.key?('url') && cli.output.isatty
|
53
|
+
end
|
54
|
+
|
55
|
+
def use_current_args
|
56
|
+
@organization_name = config.organization_name
|
57
|
+
@project_name = config.project_name
|
58
|
+
@board_id = config.board_id
|
59
|
+
@work_item_id = config.work_item_id
|
60
|
+
end
|
61
|
+
|
62
|
+
def use_arg_str(arg_str)
|
63
|
+
args = arg_str.to_s.split('/')
|
64
|
+
|
65
|
+
if args.length < 3
|
66
|
+
cli.abort 'Argument format is <organization>/<project>/<board-id>[/<work-item-id>]'
|
67
|
+
end
|
68
|
+
|
69
|
+
(@organization_name, @project_name, @board_id, @work_item_id) = args
|
70
|
+
end
|
71
|
+
|
72
|
+
def api
|
73
|
+
Abt::Providers::Devops::Api.new(organization_name: organization_name,
|
74
|
+
project_name: project_name,
|
75
|
+
username: config.username_for_organization(organization_name),
|
76
|
+
access_token: config.access_token_for_organization(organization_name))
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
module Commands
|
7
|
+
class Clear < BaseCommand
|
8
|
+
def self.command
|
9
|
+
'clear devops'
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.description
|
13
|
+
'Clear DevOps config for current git repository'
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
cli.warn 'Clearing configuration'
|
18
|
+
config.clear_local
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
module Commands
|
7
|
+
class ClearGlobal < BaseCommand
|
8
|
+
def self.command
|
9
|
+
'clear-global devops'
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.description
|
13
|
+
'Clear all global configuration'
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
cli.warn 'Clearing global DevOps configuration'
|
18
|
+
config.clear_global
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
module Commands
|
7
|
+
class Current < BaseCommand
|
8
|
+
def self.command
|
9
|
+
'current devops[:<organization-name>/<project-name>/<board-id>[/<work-item-id>]]'
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.description
|
13
|
+
'Get or set DevOps configuration for current git repository'
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
if same_args_as_config? || !config.local_available?
|
18
|
+
show_current_configuration
|
19
|
+
else
|
20
|
+
cli.warn 'Updating configuration'
|
21
|
+
update_configuration
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def show_current_configuration
|
28
|
+
if organization_name.nil?
|
29
|
+
cli.warn 'No organization selected'
|
30
|
+
elsif project_name.nil?
|
31
|
+
cli.warn 'No project selected'
|
32
|
+
elsif board_id.nil?
|
33
|
+
cli.warn 'No board selected'
|
34
|
+
elsif work_item_id.nil?
|
35
|
+
print_board(organization_name, project_name, board)
|
36
|
+
else
|
37
|
+
print_work_item(organization_name, project_name, board, work_item)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def update_configuration
|
42
|
+
ensure_board_is_valid!
|
43
|
+
|
44
|
+
if work_item_id.nil?
|
45
|
+
update_board_config
|
46
|
+
config.work_item_id = nil
|
47
|
+
|
48
|
+
print_board(organization_name, project_name, board)
|
49
|
+
else
|
50
|
+
ensure_work_item_is_valid!
|
51
|
+
|
52
|
+
update_board_config
|
53
|
+
config.work_item_id = work_item_id
|
54
|
+
|
55
|
+
print_work_item(organization_name, project_name, board, work_item)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def update_board_config
|
60
|
+
config.organization_name = organization_name
|
61
|
+
config.project_name = project_name
|
62
|
+
config.board_id = board_id
|
63
|
+
end
|
64
|
+
|
65
|
+
def ensure_board_is_valid!
|
66
|
+
if board.nil?
|
67
|
+
cli.abort 'Board could not be found, ensure that settings for organization, project, and board are correct'
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def ensure_work_item_is_valid!
|
72
|
+
cli.abort "No such work item: ##{work_item_id}" if work_item.nil?
|
73
|
+
end
|
74
|
+
|
75
|
+
def board
|
76
|
+
@board ||= begin
|
77
|
+
cli.warn 'Fetching board...'
|
78
|
+
api.get("work/boards/#{board_id}")
|
79
|
+
rescue HttpError::NotFoundError
|
80
|
+
nil
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def work_item
|
85
|
+
@work_item ||= begin
|
86
|
+
cli.warn 'Fetching work item...'
|
87
|
+
work_item = api.get_paged('wit/workitems', ids: work_item_id)[0]
|
88
|
+
sanitize_work_item(work_item)
|
89
|
+
rescue HttpError::NotFoundError
|
90
|
+
nil
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
module Commands
|
7
|
+
class HarvestTimeEntryData < BaseCommand
|
8
|
+
def self.command
|
9
|
+
'harvest-time-entry-data devops[:<organization-name>/<project-name>/<board-id>/<work-item-id>]'
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.description
|
13
|
+
'Print Harvest time entry data for DevOps work item as json. Used by harvest start script.'
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
body = {
|
18
|
+
notes: notes,
|
19
|
+
external_reference: {
|
20
|
+
id: work_item['id'],
|
21
|
+
group_id: 'AzureDevOpsWorkItem',
|
22
|
+
permalink: work_item['url']
|
23
|
+
}
|
24
|
+
}
|
25
|
+
|
26
|
+
cli.puts Oj.dump(body, mode: :json)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def notes
|
32
|
+
[
|
33
|
+
'Azure DevOps',
|
34
|
+
work_item['fields']['System.WorkItemType'],
|
35
|
+
"##{work_item['id']}",
|
36
|
+
'-',
|
37
|
+
work_item['name']
|
38
|
+
].join(' ')
|
39
|
+
end
|
40
|
+
|
41
|
+
def work_item
|
42
|
+
@work_item ||= begin
|
43
|
+
work_item = api.get_paged('wit/workitems', ids: work_item_id)[0]
|
44
|
+
sanitize_work_item(work_item)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
module Commands
|
7
|
+
class Init < BaseCommand
|
8
|
+
AZURE_DEV_URL_REGEX = %r{^https://dev\.azure\.com/(?<organization>[^/]+)/(?<project>[^/]+)}.freeze
|
9
|
+
VS_URL_REGEX = %r{^https://(?<organization>[^.]+)\.visualstudio\.com/(?<project>[^/]+)}.freeze
|
10
|
+
|
11
|
+
def self.command
|
12
|
+
'init devops'
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.description
|
16
|
+
'Pick DevOps board for current git repository'
|
17
|
+
end
|
18
|
+
|
19
|
+
def call
|
20
|
+
cli.abort 'Must be run inside a git repository' unless config.local_available?
|
21
|
+
|
22
|
+
@organization_name = config.organization_name = organization_name_from_url
|
23
|
+
@project_name = config.project_name = project_name_from_url
|
24
|
+
|
25
|
+
board = cli.prompt_choice 'Select a project work board', boards
|
26
|
+
|
27
|
+
config.board_id = board['id']
|
28
|
+
|
29
|
+
print_board(organization_name, project_name, board)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def boards
|
35
|
+
@boards ||= api.get_paged('work/boards')
|
36
|
+
end
|
37
|
+
|
38
|
+
def project_name_from_url
|
39
|
+
if (match = AZURE_DEV_URL_REGEX.match(project_url)) ||
|
40
|
+
(match = VS_URL_REGEX.match(project_url))
|
41
|
+
match[:project]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def organization_name_from_url
|
46
|
+
if (match = AZURE_DEV_URL_REGEX.match(project_url)) ||
|
47
|
+
(match = VS_URL_REGEX.match(project_url))
|
48
|
+
match[:organization]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def project_url
|
53
|
+
@project_url ||= begin
|
54
|
+
loop do
|
55
|
+
url = cli.prompt([
|
56
|
+
'Please provide the URL for the devops project',
|
57
|
+
'For instance https://{organization}.visualstudio.com/{project} or https://dev.azure.com/{organization}/{project}',
|
58
|
+
'',
|
59
|
+
'Enter URL'
|
60
|
+
].join("\n"))
|
61
|
+
|
62
|
+
break url if AZURE_DEV_URL_REGEX =~ url || VS_URL_REGEX =~ url
|
63
|
+
|
64
|
+
cli.warn 'Invalid URL'
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
module Commands
|
7
|
+
class Pick < BaseCommand
|
8
|
+
def self.command
|
9
|
+
'pick devops[:<organization-name>/<project-name>/<board-id>]'
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.description
|
13
|
+
'Pick work item for current git repository'
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
cli.abort 'Must be run inside a git repository' unless config.local_available?
|
18
|
+
|
19
|
+
cli.warn "#{project_name} - #{board['name']}"
|
20
|
+
|
21
|
+
work_item = select_work_item
|
22
|
+
|
23
|
+
# We might have gotten org, project, board as arg str
|
24
|
+
config.organization_name = organization_name
|
25
|
+
config.project_name = project_name
|
26
|
+
config.board_id = board_id
|
27
|
+
config.work_item_id = work_item['id']
|
28
|
+
|
29
|
+
print_work_item(organization_name, project_name, board, work_item)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def select_work_item
|
35
|
+
loop do
|
36
|
+
column = cli.prompt_choice 'Which column?', columns
|
37
|
+
cli.warn 'Fetching work items...'
|
38
|
+
work_items = work_items_in_column(column)
|
39
|
+
|
40
|
+
if work_items.length.zero?
|
41
|
+
cli.warn 'Section is empty'
|
42
|
+
next
|
43
|
+
end
|
44
|
+
|
45
|
+
work_item = cli.prompt_choice 'Select a work item', work_items, true
|
46
|
+
return work_item if work_item
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def work_items_in_column(column)
|
51
|
+
wiql = <<~WIQL
|
52
|
+
SELECT [System.Id]
|
53
|
+
FROM WorkItems
|
54
|
+
WHERE [System.BoardColumn] = '#{column['name']}'
|
55
|
+
ORDER BY [Microsoft.VSTS.Common.BacklogPriority] ASC
|
56
|
+
WIQL
|
57
|
+
|
58
|
+
response = api.post('wit/wiql', Oj.dump({ query: wiql }, mode: :json))
|
59
|
+
ids = response['workItems'].map { |work_item| work_item['id'] }
|
60
|
+
work_items = api.get_paged('wit/workitems', ids: ids.join(','))
|
61
|
+
|
62
|
+
work_items.map { |work_item| sanitize_work_item(work_item) }
|
63
|
+
end
|
64
|
+
|
65
|
+
def columns
|
66
|
+
board['columns']
|
67
|
+
end
|
68
|
+
|
69
|
+
def board
|
70
|
+
@board ||= api.get("work/boards/#{board_id}")
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
module Commands
|
7
|
+
class Share < BaseCommand
|
8
|
+
def self.command
|
9
|
+
'share devops[:<organization-name>/<project-name>/<board-id>[/<work-item-id>]]'
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.description
|
13
|
+
'Print DevOps config string'
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
if organization_name.nil?
|
18
|
+
cli.warn 'No organization selected'
|
19
|
+
elsif project_name.nil?
|
20
|
+
cli.warn 'No project selected'
|
21
|
+
elsif board_id.nil?
|
22
|
+
cli.warn 'No board selected'
|
23
|
+
else
|
24
|
+
args = [organization_name, project_name, board_id, work_item_id].compact
|
25
|
+
cli.print_provider_command('devops', args.join('/'))
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Devops
|
6
|
+
class Configuration
|
7
|
+
attr_accessor :cli
|
8
|
+
|
9
|
+
def initialize(cli:)
|
10
|
+
@cli = cli
|
11
|
+
@git = GitConfig.new(namespace: 'abt.devops')
|
12
|
+
end
|
13
|
+
|
14
|
+
def local_available?
|
15
|
+
GitConfig.local_available?
|
16
|
+
end
|
17
|
+
|
18
|
+
def organization_name
|
19
|
+
local_available? ? git['organizationName'] : nil
|
20
|
+
end
|
21
|
+
|
22
|
+
def project_name
|
23
|
+
local_available? ? git['projectName'] : nil
|
24
|
+
end
|
25
|
+
|
26
|
+
def board_id
|
27
|
+
local_available? ? git['boardId'] : nil
|
28
|
+
end
|
29
|
+
|
30
|
+
def work_item_id
|
31
|
+
local_available? ? git['workItemId'] : nil
|
32
|
+
end
|
33
|
+
|
34
|
+
def organization_name=(value)
|
35
|
+
return if organization_name == value
|
36
|
+
|
37
|
+
clear_local
|
38
|
+
git['organizationName'] = value unless value.nil?
|
39
|
+
end
|
40
|
+
|
41
|
+
def project_name=(value)
|
42
|
+
return if project_name == value
|
43
|
+
|
44
|
+
git['projectName'] = value unless value.nil?
|
45
|
+
git['boardId'] = nil
|
46
|
+
git['workItemId'] = nil
|
47
|
+
end
|
48
|
+
|
49
|
+
def board_id=(value)
|
50
|
+
return if board_id == value
|
51
|
+
|
52
|
+
git['boardId'] = value unless value.nil?
|
53
|
+
git['workItemId'] = nil
|
54
|
+
end
|
55
|
+
|
56
|
+
def work_item_id=(value)
|
57
|
+
git['workItemId'] = value
|
58
|
+
end
|
59
|
+
|
60
|
+
def clear_local
|
61
|
+
cli.abort 'No local configuration was found' unless local_available?
|
62
|
+
|
63
|
+
git['organizationName'] = nil
|
64
|
+
git['projectName'] = nil
|
65
|
+
git['boardId'] = nil
|
66
|
+
git['workItemId'] = nil
|
67
|
+
end
|
68
|
+
|
69
|
+
def clear_global
|
70
|
+
git.global.keys.each do |key|
|
71
|
+
cli.puts 'Deleting configuration: ' + key
|
72
|
+
git.global[key] = nil
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def username_for_organization(organization_name)
|
77
|
+
username_key = "organizations.#{organization_name}.username"
|
78
|
+
|
79
|
+
return git.global[username_key] unless git.global[username_key].nil?
|
80
|
+
|
81
|
+
git.global[username_key] = cli.prompt([
|
82
|
+
"Please provide your username for the DevOps organization (#{organization_name}).",
|
83
|
+
'',
|
84
|
+
'Enter username'
|
85
|
+
].join("\n"))
|
86
|
+
end
|
87
|
+
|
88
|
+
def access_token_for_organization(organization_name)
|
89
|
+
access_token_key = "organizations.#{organization_name}.accessToken"
|
90
|
+
|
91
|
+
return git.global[access_token_key] unless git.global[access_token_key].nil?
|
92
|
+
|
93
|
+
git.global[access_token_key] = cli.prompt([
|
94
|
+
"Please provide your personal access token for the DevOps organization (#{organization_name}).",
|
95
|
+
'If you don\'t have one, follow the guide here: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate',
|
96
|
+
'',
|
97
|
+
'The token MUST have "Read" permission for Work Items',
|
98
|
+
'Future features will likely require "Write" or "Manage"',
|
99
|
+
'',
|
100
|
+
'Enter access token'
|
101
|
+
].join("\n"))
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
attr_reader :git
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -39,14 +39,14 @@ module Abt
|
|
39
39
|
|
40
40
|
cli.warn 'Showing the 10 first matches' if matches.size > 10
|
41
41
|
choice = cli.prompt_choice 'Select a project', matches[0...10], true
|
42
|
-
break choice unless choice.nil?
|
42
|
+
break choice['project'] unless choice.nil?
|
43
43
|
end
|
44
44
|
end
|
45
45
|
|
46
46
|
def matches_for_string(string)
|
47
47
|
search_string = sanitize_string(string)
|
48
48
|
|
49
|
-
|
49
|
+
searchable_projects.select do |project|
|
50
50
|
sanitize_string(project['name']).include?(search_string)
|
51
51
|
end
|
52
52
|
end
|
@@ -55,6 +55,15 @@ module Abt
|
|
55
55
|
string.downcase.gsub(/[^\w]/, '')
|
56
56
|
end
|
57
57
|
|
58
|
+
def searchable_projects
|
59
|
+
@searchable_projects ||= projects.map do |project|
|
60
|
+
{
|
61
|
+
'name' => "#{project['client']['name']} > #{project['name']}",
|
62
|
+
'project' => project
|
63
|
+
}
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
58
67
|
def projects
|
59
68
|
@projects ||= begin
|
60
69
|
cli.warn 'Fetching projects...'
|
@@ -10,21 +10,16 @@ module Abt
|
|
10
10
|
end
|
11
11
|
|
12
12
|
def self.description
|
13
|
-
'
|
13
|
+
'As track, but also lets the user override the current task and triggers `start` commands for other providers ' # rubocop:disable Layout/LineLength
|
14
14
|
end
|
15
15
|
|
16
16
|
def call
|
17
|
-
|
17
|
+
track_output = call_track
|
18
|
+
puts track_output
|
18
19
|
|
19
|
-
|
20
|
-
|
21
|
-
print_task(project, task)
|
22
|
-
|
23
|
-
cli.abort('No task selected') if task_id.nil?
|
20
|
+
use_arg_str(arg_str_from_track_output(track_output))
|
24
21
|
|
25
|
-
|
26
|
-
|
27
|
-
cli.warn 'Tracker successfully started'
|
22
|
+
maybe_override_current_task
|
28
23
|
rescue Abt::HttpError::HttpError => e
|
29
24
|
cli.warn e
|
30
25
|
cli.abort 'Unable to start tracker'
|
@@ -32,59 +27,30 @@ module Abt
|
|
32
27
|
|
33
28
|
private
|
34
29
|
|
35
|
-
def
|
36
|
-
|
37
|
-
|
38
|
-
return unless config.local_available?
|
39
|
-
|
40
|
-
should_override = cli.prompt_boolean 'Set selected task as current?'
|
41
|
-
Current.new(arg_str: arg_str, cli: cli).call if should_override
|
30
|
+
def arg_str_from_track_output(output)
|
31
|
+
output = output.split(' # ').first
|
32
|
+
output.split(':')[1]
|
42
33
|
end
|
43
34
|
|
44
|
-
def
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
user_id: config.user_id,
|
49
|
-
spent_date: Date.today.iso8601
|
50
|
-
}.merge(external_link_data), mode: :json)
|
51
|
-
api.post('time_entries', body)
|
52
|
-
end
|
35
|
+
def call_track
|
36
|
+
input = StringIO.new(cli.args.join(' '))
|
37
|
+
output = StringIO.new
|
38
|
+
Abt::Cli.new(argv: ['track'], output: output, input: input).perform
|
53
39
|
|
54
|
-
|
55
|
-
|
40
|
+
output_str = output.string.strip
|
41
|
+
cli.abort 'No task provided' if output_str.empty?
|
42
|
+
output_str
|
56
43
|
end
|
57
44
|
|
58
|
-
def
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
def project_assignment
|
65
|
-
@project_assignment ||= begin
|
66
|
-
project_assignments.find { |pa| pa['project']['id'].to_s == project_id }
|
67
|
-
end
|
68
|
-
end
|
69
|
-
|
70
|
-
def project_assignments
|
71
|
-
@project_assignments ||= api.get_paged('users/me/project_assignments')
|
72
|
-
end
|
73
|
-
|
74
|
-
def external_link_data
|
75
|
-
@external_link_data ||= begin
|
76
|
-
arg_strs = cli.args.join(' ')
|
77
|
-
lines = `#{$PROGRAM_NAME} harvest-time-entry-data #{arg_strs}`.split("\n")
|
78
|
-
|
79
|
-
return {} if lines.empty?
|
80
|
-
|
81
|
-
# TODO: Make user choose which reference to use by printing the urls
|
82
|
-
if lines.length > 1
|
83
|
-
cli.abort('Multiple providers had harvest reference data, only one is supported at a time') # rubocop:disable Layout/LineLength
|
84
|
-
end
|
45
|
+
def maybe_override_current_task
|
46
|
+
return if arg_str.nil?
|
47
|
+
return if same_args_as_config?
|
48
|
+
return unless config.local_available?
|
49
|
+
return unless cli.prompt_boolean 'Set selected task as current?'
|
85
50
|
|
86
|
-
|
87
|
-
|
51
|
+
input = StringIO.new("harvest:#{project_id}/#{task_id}")
|
52
|
+
output = StringIO.new
|
53
|
+
Abt::Cli.new(argv: ['current'], output: output, input: input).perform
|
88
54
|
end
|
89
55
|
end
|
90
56
|
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Abt
|
4
|
+
module Providers
|
5
|
+
module Harvest
|
6
|
+
module Commands
|
7
|
+
class Track < BaseCommand
|
8
|
+
def self.command
|
9
|
+
'track harvest[:<project-id>/<task-id>]'
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.description
|
13
|
+
'Start tracker for current or specified task. Add a relevant provider to link the time entry: E.g. `abt start harvest asana`' # rubocop:disable Layout/LineLength
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
abort 'No current/provided task' if task_id.nil?
|
18
|
+
cli.abort('No task selected') if task_id.nil?
|
19
|
+
|
20
|
+
print_task(created_time_entry['project'], created_time_entry['task'])
|
21
|
+
|
22
|
+
cli.warn 'Tracker successfully started'
|
23
|
+
rescue Abt::HttpError::HttpError => e
|
24
|
+
cli.abort 'Invalid task'
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def created_time_entry
|
30
|
+
@created_time_entry ||= create_time_entry
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_time_entry
|
34
|
+
body = {
|
35
|
+
project_id: project_id,
|
36
|
+
task_id: task_id,
|
37
|
+
user_id: config.user_id,
|
38
|
+
spent_date: Date.today.iso8601
|
39
|
+
}
|
40
|
+
|
41
|
+
if external_link_data
|
42
|
+
body.merge! external_link_data
|
43
|
+
else
|
44
|
+
cli.warn 'No external link provided'
|
45
|
+
body[:notes] ||= cli.prompt('Fill in comment (optional)')
|
46
|
+
end
|
47
|
+
|
48
|
+
api.post('time_entries', Oj.dump(body, mode: :json))
|
49
|
+
end
|
50
|
+
|
51
|
+
def external_link_data
|
52
|
+
@external_link_data ||= begin
|
53
|
+
input = StringIO.new(cli.args.join(' '))
|
54
|
+
output = StringIO.new
|
55
|
+
Abt::Cli.new(argv: ['harvest-time-entry-data'], output: output, input: input).perform
|
56
|
+
|
57
|
+
lines = output.string.strip.lines
|
58
|
+
|
59
|
+
return if lines.empty?
|
60
|
+
|
61
|
+
# TODO: Make user choose which reference to use by printing the urls
|
62
|
+
if lines.length > 1
|
63
|
+
cli.abort('Multiple providers had harvest reference data, only one is supported at a time') # rubocop:disable Layout/LineLength
|
64
|
+
end
|
65
|
+
|
66
|
+
Oj.load(lines.first)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -44,9 +44,10 @@ module Abt
|
|
44
44
|
end
|
45
45
|
|
46
46
|
def clear_global
|
47
|
-
git.global
|
48
|
-
|
49
|
-
|
47
|
+
git.global.keys.each do |key|
|
48
|
+
cli.puts 'Deleting configuration: ' + key
|
49
|
+
git.global[key] = nil
|
50
|
+
end
|
50
51
|
end
|
51
52
|
|
52
53
|
def access_token
|
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.11
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jesper Sørensen
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-01-
|
11
|
+
date: 2021-01-29 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: dry-inflector
|
@@ -100,6 +100,17 @@ files:
|
|
100
100
|
- "./lib/abt/providers/asana/commands/start.rb"
|
101
101
|
- "./lib/abt/providers/asana/commands/tasks.rb"
|
102
102
|
- "./lib/abt/providers/asana/configuration.rb"
|
103
|
+
- "./lib/abt/providers/devops.rb"
|
104
|
+
- "./lib/abt/providers/devops/api.rb"
|
105
|
+
- "./lib/abt/providers/devops/base_command.rb"
|
106
|
+
- "./lib/abt/providers/devops/commands/clear.rb"
|
107
|
+
- "./lib/abt/providers/devops/commands/clear_global.rb"
|
108
|
+
- "./lib/abt/providers/devops/commands/current.rb"
|
109
|
+
- "./lib/abt/providers/devops/commands/harvest_time_entry_data.rb"
|
110
|
+
- "./lib/abt/providers/devops/commands/init.rb"
|
111
|
+
- "./lib/abt/providers/devops/commands/pick.rb"
|
112
|
+
- "./lib/abt/providers/devops/commands/share.rb"
|
113
|
+
- "./lib/abt/providers/devops/configuration.rb"
|
103
114
|
- "./lib/abt/providers/harvest.rb"
|
104
115
|
- "./lib/abt/providers/harvest/api.rb"
|
105
116
|
- "./lib/abt/providers/harvest/base_command.rb"
|
@@ -113,6 +124,7 @@ files:
|
|
113
124
|
- "./lib/abt/providers/harvest/commands/start.rb"
|
114
125
|
- "./lib/abt/providers/harvest/commands/stop.rb"
|
115
126
|
- "./lib/abt/providers/harvest/commands/tasks.rb"
|
127
|
+
- "./lib/abt/providers/harvest/commands/track.rb"
|
116
128
|
- "./lib/abt/providers/harvest/configuration.rb"
|
117
129
|
- "./lib/abt/version.rb"
|
118
130
|
- bin/abt
|