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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48f55dc71fb2babaa1b1b6ce4aa99dfdc3eea8a31f0cdc8bd43eecd94cd220f0
4
- data.tar.gz: 94ece036845a7375e434bde005f388bf7e8e9a7f0f1b89c6b19ecfa7bbb8bb7a
3
+ metadata.gz: 0cc34f7579a887a49ae83db56c8b3808a0e00b09c55631f9b4224228477a0762
4
+ data.tar.gz: 9c26a06c3af53ebe55ca5e53f93b5d6d73854962daebef31d131000657be1c63
5
5
  SHA512:
6
- metadata.gz: 56ab38ab3607b95f420cb8178250d6bb83f338a92695011d753b9218a462292f2a0c95cb06d32a7b316142ace8def6a20be441df0e01f1dbb214384af239cf99
7
- data.tar.gz: 96927e87f95be0cb844dbabbceb855ee26f5d77ef0f44e3728166f3e9e84123a71d4a71f5eb38f9c8ebb099be7ba3eda46dd7ed8fd1e5e41ddf5bcc0f71c2061
6
+ metadata.gz: 4fe10297a130b747d93dc1398bdf404996d5c07987680deac59a5365d6893ab3d0466ebd764ef087f5eb2409b99a7733dc55a869432d81286f3460ec510d247d
7
+ data.tar.gz: 3684a1d3113f9df06637a6c7294f334b48e2c8cd5e54d4334c1ec1884571519f6e5aedeec327d8292f5364d1251453d6a7a8131074f91b2f94935b7a3586ddb9
data/bin/abt CHANGED
@@ -5,6 +5,7 @@ require 'dry-inflector'
5
5
  require 'faraday'
6
6
  require 'oj'
7
7
  require 'open3'
8
+ require 'stringio'
8
9
 
9
10
  require_relative '../lib/abt.rb'
10
11
 
@@ -20,7 +20,7 @@ module Abt
20
20
  @output = output
21
21
  @err_output = err_output
22
22
 
23
- @args += args_from_stdin unless input.isatty # Add piped arguments
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 args_from_stdin
61
- input = STDIN.read
60
+ def args_from_input
61
+ input_string = input.read
62
62
 
63
- return [] if input.nil?
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 = input.lines.map do |line|
66
+ lines_without_comments = input_string.lines.map do |line|
67
67
  line.split(' # ').first
68
68
  end
69
69
 
@@ -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
- while (number = read_option_number(options.length, allow_back_option))
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
- abort
55
+ next
53
56
  end
54
57
 
55
58
  option = options[number - 1]
@@ -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
@@ -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 # rubocop:disable Metrics/MethodLength
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 = cli.prompt_choice 'Select a task', tasks
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 tasks
36
- @tasks ||= begin
35
+ def select_task
36
+ loop do
37
37
  section = cli.prompt_choice 'Which section?', sections
38
38
  cli.warn 'Fetching tasks...'
39
- api.get_paged('tasks', section: section['gid'], opt_fields: 'name,permalink_url')
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['workspaceGid'] = nil
71
- git.global['accessToken'] = nil
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
- projects.select do |project|
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
- '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
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
- abort 'No current/provided task' if task_id.nil?
17
+ track_output = call_track
18
+ puts track_output
18
19
 
19
- maybe_override_current_task
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
- create_time_entry
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 maybe_override_current_task
36
- return if arg_str.nil?
37
- return if same_args_as_config?
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 create_time_entry
45
- body = Oj.dump({
46
- project_id: project_id,
47
- task_id: task_id,
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
- def project
55
- project_assignment['project']
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 task
59
- @task ||= project_assignment['task_assignments'].map { |ta| ta['task'] }.find do |task|
60
- task['id'].to_s == task_id
61
- end
62
- end
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
- Oj.load(lines.first)
87
- end
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['userId'] = nil
48
- git.global['accountId'] = nil
49
- git.global['accessToken'] = nil
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Abt
4
- VERSION = '0.0.6'
4
+ VERSION = '0.0.11'
5
5
  end
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.6
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-18 00:00:00.000000000 Z
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