abt-cli 0.0.7 → 0.0.12
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 +36 -14
- data/lib/abt/docs.rb +4 -0
- data/lib/abt/git_config.rb +13 -0
- data/lib/abt/providers/asana/base_command.rb +11 -0
- data/lib/abt/providers/asana/commands/add.rb +75 -0
- data/lib/abt/providers/asana/commands/current.rb +3 -3
- data/lib/abt/providers/asana/commands/finalize.rb +1 -1
- data/lib/abt/providers/asana/commands/harvest_time_entry_data.rb +3 -4
- data/lib/abt/providers/asana/commands/init.rb +5 -0
- data/lib/abt/providers/asana/commands/pick.rb +17 -4
- data/lib/abt/providers/asana/commands/share.rb +3 -3
- data/lib/abt/providers/asana/commands/start.rb +1 -1
- data/lib/abt/providers/asana/commands/tasks.rb +2 -0
- data/lib/abt/providers/asana/configuration.rb +9 -3
- data/lib/abt/providers/devops.rb +19 -0
- data/lib/abt/providers/devops/api.rb +77 -0
- data/lib/abt/providers/devops/base_command.rb +97 -0
- data/lib/abt/providers/devops/commands/boards.rb +34 -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 +93 -0
- data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +53 -0
- data/lib/abt/providers/devops/commands/init.rb +72 -0
- data/lib/abt/providers/devops/commands/pick.rb +78 -0
- data/lib/abt/providers/devops/commands/share.rb +26 -0
- data/lib/abt/providers/devops/commands/work-items.rb +46 -0
- data/lib/abt/providers/devops/configuration.rb +110 -0
- data/lib/abt/providers/harvest/base_command.rb +11 -0
- data/lib/abt/providers/harvest/commands/current.rb +3 -3
- data/lib/abt/providers/harvest/commands/pick.rb +1 -0
- data/lib/abt/providers/harvest/commands/start.rb +21 -65
- data/lib/abt/providers/harvest/commands/tasks.rb +2 -0
- data/lib/abt/providers/harvest/commands/track.rb +72 -0
- data/lib/abt/providers/harvest/configuration.rb +4 -3
- data/lib/abt/version.rb +1 -1
- metadata +17 -2
@@ -0,0 +1,77 @@
|
|
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 work_item_query(wiql)
|
32
|
+
response = post('wit/wiql', Oj.dump({ query: wiql }, mode: :json))
|
33
|
+
ids = response['workItems'].map { |work_item| work_item['id'] }
|
34
|
+
|
35
|
+
work_items = []
|
36
|
+
ids.each_slice(200) do |page_ids|
|
37
|
+
work_items += get_paged('wit/workitems', ids: page_ids.join(','))
|
38
|
+
end
|
39
|
+
|
40
|
+
work_items
|
41
|
+
end
|
42
|
+
|
43
|
+
def request(*args)
|
44
|
+
response = connection.public_send(*args)
|
45
|
+
|
46
|
+
if response.success?
|
47
|
+
Oj.load(response.body)
|
48
|
+
else
|
49
|
+
error_class = Abt::HttpError.error_class_for_status(response.status)
|
50
|
+
encoded_response_body = response.body.force_encoding('utf-8')
|
51
|
+
raise error_class, "Code: #{response.status}, body: #{encoded_response_body}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def base_url
|
56
|
+
"https://#{organization_name}.visualstudio.com/#{project_name}"
|
57
|
+
end
|
58
|
+
|
59
|
+
def api_endpoint
|
60
|
+
"#{base_url}/_apis"
|
61
|
+
end
|
62
|
+
|
63
|
+
def url_for_work_item(work_item)
|
64
|
+
"#{base_url}/_workitems/edit/#{work_item['id']}"
|
65
|
+
end
|
66
|
+
|
67
|
+
def connection
|
68
|
+
@connection ||= Faraday.new(api_endpoint) do |connection|
|
69
|
+
connection.basic_auth username, access_token
|
70
|
+
connection.headers['Content-Type'] = 'application/json'
|
71
|
+
connection.headers['Accept'] = 'application/json; api-version=6.0'
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,97 @@
|
|
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 require_board!
|
25
|
+
return if organization_name && project_name && board_id
|
26
|
+
|
27
|
+
cli.abort 'No current/specified board. Did you initialize DevOps?'
|
28
|
+
end
|
29
|
+
|
30
|
+
def require_work_item!
|
31
|
+
unless organization_name && project_name && board_id
|
32
|
+
cli.abort 'No current/specified board. Did you initialize DevOps and pick a work item?'
|
33
|
+
end
|
34
|
+
|
35
|
+
return if work_item_id
|
36
|
+
|
37
|
+
cli.abort 'No current/specified work item. Did you pick a DevOps work item?'
|
38
|
+
end
|
39
|
+
|
40
|
+
def sanitize_work_item(work_item)
|
41
|
+
return nil if work_item.nil?
|
42
|
+
|
43
|
+
work_item.merge(
|
44
|
+
'id' => work_item['id'].to_s,
|
45
|
+
'name' => work_item['fields']['System.Title'],
|
46
|
+
'url' => api.url_for_work_item(work_item)
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
def same_args_as_config?
|
51
|
+
organization_name == config.organization_name &&
|
52
|
+
project_name == config.project_name &&
|
53
|
+
board_id == config.board_id &&
|
54
|
+
work_item_id == config.work_item_id
|
55
|
+
end
|
56
|
+
|
57
|
+
def print_board(organization_name, project_name, board)
|
58
|
+
arg_str = "#{organization_name}/#{project_name}/#{board['id']}"
|
59
|
+
|
60
|
+
cli.print_provider_command('devops', arg_str, board['name'])
|
61
|
+
# cli.warn board['url'] if board.key?('url') && cli.output.isatty # TODO: Web URL
|
62
|
+
end
|
63
|
+
|
64
|
+
def print_work_item(organization, project, board, work_item)
|
65
|
+
arg_str = "#{organization}/#{project}/#{board['id']}/#{work_item['id']}"
|
66
|
+
|
67
|
+
cli.print_provider_command('devops', arg_str, work_item['name'])
|
68
|
+
cli.warn work_item['url'] if work_item.key?('url') && cli.output.isatty
|
69
|
+
end
|
70
|
+
|
71
|
+
def use_current_args
|
72
|
+
@organization_name = config.organization_name
|
73
|
+
@project_name = config.project_name
|
74
|
+
@board_id = config.board_id
|
75
|
+
@work_item_id = config.work_item_id
|
76
|
+
end
|
77
|
+
|
78
|
+
def use_arg_str(arg_str)
|
79
|
+
args = arg_str.to_s.split('/')
|
80
|
+
|
81
|
+
if args.length < 3
|
82
|
+
cli.abort 'Argument format is <organization>/<project>/<board-id>[/<work-item-id>]'
|
83
|
+
end
|
84
|
+
|
85
|
+
(@organization_name, @project_name, @board_id, @work_item_id) = args
|
86
|
+
end
|
87
|
+
|
88
|
+
def api
|
89
|
+
Abt::Providers::Devops::Api.new(organization_name: organization_name,
|
90
|
+
project_name: project_name,
|
91
|
+
username: config.username_for_organization(organization_name),
|
92
|
+
access_token: config.access_token_for_organization(organization_name))
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,34 @@
|
|
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.command
|
9
|
+
'boards devops'
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.description
|
13
|
+
'List all boards - useful for piping into grep etc'
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
cli.abort 'No organization selected. Did you initialize DevOps?' if organization_name.nil?
|
18
|
+
cli.abort 'No project selected. Did you initialize DevOps?' if project_name.nil?
|
19
|
+
|
20
|
+
boards.map do |board|
|
21
|
+
print_board(organization_name, project_name, board)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def boards
|
28
|
+
@boards ||= api.get_paged('work/boards')
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
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,93 @@
|
|
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
|
+
require_board!
|
18
|
+
|
19
|
+
if same_args_as_config? || !config.local_available?
|
20
|
+
show_current_configuration
|
21
|
+
else
|
22
|
+
cli.warn 'Updating configuration'
|
23
|
+
update_configuration
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def show_current_configuration
|
30
|
+
if work_item_id.nil?
|
31
|
+
print_board(organization_name, project_name, board)
|
32
|
+
else
|
33
|
+
print_work_item(organization_name, project_name, board, work_item)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def update_configuration
|
38
|
+
ensure_board_is_valid!
|
39
|
+
|
40
|
+
if work_item_id.nil?
|
41
|
+
update_board_config
|
42
|
+
config.work_item_id = nil
|
43
|
+
|
44
|
+
print_board(organization_name, project_name, board)
|
45
|
+
else
|
46
|
+
ensure_work_item_is_valid!
|
47
|
+
|
48
|
+
update_board_config
|
49
|
+
config.work_item_id = work_item_id
|
50
|
+
|
51
|
+
print_work_item(organization_name, project_name, board, work_item)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def update_board_config
|
56
|
+
config.organization_name = organization_name
|
57
|
+
config.project_name = project_name
|
58
|
+
config.board_id = board_id
|
59
|
+
end
|
60
|
+
|
61
|
+
def ensure_board_is_valid!
|
62
|
+
if board.nil?
|
63
|
+
cli.abort 'Board could not be found, ensure that settings for organization, project, and board are correct'
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def ensure_work_item_is_valid!
|
68
|
+
cli.abort "No such work item: ##{work_item_id}" if work_item.nil?
|
69
|
+
end
|
70
|
+
|
71
|
+
def board
|
72
|
+
@board ||= begin
|
73
|
+
cli.warn 'Fetching board...'
|
74
|
+
api.get("work/boards/#{board_id}")
|
75
|
+
rescue HttpError::NotFoundError
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def work_item
|
81
|
+
@work_item ||= begin
|
82
|
+
cli.warn 'Fetching work item...'
|
83
|
+
work_item = api.get_paged('wit/workitems', ids: work_item_id)[0]
|
84
|
+
sanitize_work_item(work_item)
|
85
|
+
rescue HttpError::NotFoundError
|
86
|
+
nil
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,53 @@
|
|
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
|
+
require_work_item!
|
18
|
+
|
19
|
+
body = {
|
20
|
+
notes: notes,
|
21
|
+
external_reference: {
|
22
|
+
id: work_item['id'],
|
23
|
+
group_id: 'AzureDevOpsWorkItem',
|
24
|
+
permalink: work_item['url']
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
cli.puts Oj.dump(body, mode: :json)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def notes
|
34
|
+
[
|
35
|
+
'Azure DevOps',
|
36
|
+
work_item['fields']['System.WorkItemType'],
|
37
|
+
"##{work_item['id']}",
|
38
|
+
'-',
|
39
|
+
work_item['name']
|
40
|
+
].join(' ')
|
41
|
+
end
|
42
|
+
|
43
|
+
def work_item
|
44
|
+
@work_item ||= begin
|
45
|
+
work_item = api.get_paged('wit/workitems', ids: work_item_id)[0]
|
46
|
+
sanitize_work_item(work_item)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
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
|