abt-cli 0.0.9 → 0.0.14

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/bin/abt +0 -6
  3. data/lib/abt.rb +8 -0
  4. data/lib/abt/cli.rb +5 -5
  5. data/lib/abt/cli/dialogs.rb +28 -9
  6. data/lib/abt/git_config.rb +28 -11
  7. data/lib/abt/helpers.rb +1 -1
  8. data/lib/abt/providers/asana/base_command.rb +11 -0
  9. data/lib/abt/providers/asana/commands/add.rb +75 -0
  10. data/lib/abt/providers/asana/commands/branch-name.rb +44 -0
  11. data/lib/abt/providers/asana/commands/current.rb +3 -3
  12. data/lib/abt/providers/asana/commands/finalize.rb +1 -1
  13. data/lib/abt/providers/asana/commands/harvest_time_entry_data.rb +3 -4
  14. data/lib/abt/providers/asana/commands/init.rb +5 -0
  15. data/lib/abt/providers/asana/commands/pick.rb +1 -0
  16. data/lib/abt/providers/asana/commands/share.rb +3 -3
  17. data/lib/abt/providers/asana/commands/start.rb +1 -1
  18. data/lib/abt/providers/asana/commands/tasks.rb +2 -0
  19. data/lib/abt/providers/asana/configuration.rb +4 -2
  20. data/lib/abt/providers/devops.rb +19 -0
  21. data/lib/abt/providers/devops/api.rb +95 -0
  22. data/lib/abt/providers/devops/base_command.rb +98 -0
  23. data/lib/abt/providers/devops/commands/boards.rb +34 -0
  24. data/lib/abt/providers/devops/commands/branch-name.rb +45 -0
  25. data/lib/abt/providers/devops/commands/clear.rb +24 -0
  26. data/lib/abt/providers/devops/commands/clear_global.rb +24 -0
  27. data/lib/abt/providers/devops/commands/current.rb +93 -0
  28. data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +53 -0
  29. data/lib/abt/providers/devops/commands/init.rb +72 -0
  30. data/lib/abt/providers/devops/commands/pick.rb +78 -0
  31. data/lib/abt/providers/devops/commands/share.rb +26 -0
  32. data/lib/abt/providers/devops/commands/work-items.rb +46 -0
  33. data/lib/abt/providers/devops/configuration.rb +110 -0
  34. data/lib/abt/providers/git.rb +19 -0
  35. data/lib/abt/providers/git/commands/branch.rb +80 -0
  36. data/lib/abt/providers/harvest/base_command.rb +11 -0
  37. data/lib/abt/providers/harvest/commands/current.rb +3 -3
  38. data/lib/abt/providers/harvest/commands/pick.rb +1 -0
  39. data/lib/abt/providers/harvest/commands/start.rb +10 -11
  40. data/lib/abt/providers/harvest/commands/tasks.rb +2 -0
  41. data/lib/abt/providers/harvest/commands/track.rb +6 -4
  42. data/lib/abt/providers/harvest/configuration.rb +4 -3
  43. data/lib/abt/version.rb +1 -1
  44. metadata +20 -2
@@ -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
@@ -0,0 +1,78 @@
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
+ require_board!
19
+
20
+ cli.warn "#{project_name} - #{board['name']}"
21
+
22
+ work_item = select_work_item
23
+
24
+ update_config!(work_item)
25
+
26
+ print_work_item(organization_name, project_name, board, work_item)
27
+ end
28
+
29
+ private
30
+
31
+ def update_config!(work_item)
32
+ config.organization_name = organization_name
33
+ config.project_name = project_name
34
+ config.board_id = board_id
35
+ config.work_item_id = work_item['id']
36
+ end
37
+
38
+ def select_work_item
39
+ loop do
40
+ column = cli.prompt_choice 'Which column?', columns
41
+ cli.warn 'Fetching work items...'
42
+ work_items = work_items_in_column(column)
43
+
44
+ if work_items.length.zero?
45
+ cli.warn 'Section is empty'
46
+ next
47
+ end
48
+
49
+ work_item = cli.prompt_choice 'Select a work item', work_items, true
50
+ return work_item if work_item
51
+ end
52
+ end
53
+
54
+ def work_items_in_column(column)
55
+ work_items = api.work_item_query(
56
+ <<~WIQL
57
+ SELECT [System.Id]
58
+ FROM WorkItems
59
+ WHERE [System.BoardColumn] = '#{column['name']}'
60
+ ORDER BY [Microsoft.VSTS.Common.BacklogPriority] ASC
61
+ WIQL
62
+ )
63
+
64
+ work_items.map { |work_item| sanitize_work_item(work_item) }
65
+ end
66
+
67
+ def columns
68
+ board['columns']
69
+ end
70
+
71
+ def board
72
+ @board ||= api.get("work/boards/#{board_id}")
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,26 @@
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
+ require_work_item!
18
+
19
+ args = [organization_name, project_name, board_id, work_item_id].compact
20
+ cli.print_provider_command('devops', args.join('/'))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Devops
6
+ module Commands
7
+ class WorkItems < BaseCommand
8
+ def self.command
9
+ 'work-items devops'
10
+ end
11
+
12
+ def self.description
13
+ 'List all work items on board - useful for piping into grep etc.'
14
+ end
15
+
16
+ def call
17
+ require_board!
18
+
19
+ work_items.each do |work_item|
20
+ print_work_item(organization_name, project_name, board, work_item)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def work_items
27
+ @work_items ||= begin
28
+ cli.warn 'Fetching work items...'
29
+ api.work_item_query(
30
+ <<~WIQL
31
+ SELECT [System.Id]
32
+ FROM WorkItems
33
+ ORDER BY [System.Title] ASC
34
+ WIQL
35
+ ).map { |work_item| sanitize_work_item(work_item) }
36
+ end
37
+ end
38
+
39
+ def board
40
+ @board ||= api.get("work/boards/#{board_id}")
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ 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
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob("#{File.expand_path(__dir__)}/git/*.rb").sort.each { |file| require file }
4
+ Dir.glob("#{File.expand_path(__dir__)}/git/commands/*.rb").sort.each { |file| require file }
5
+
6
+ module Abt
7
+ module Providers
8
+ module Git
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,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Git
6
+ module Commands
7
+ class Branch
8
+ attr_reader :cli
9
+
10
+ def self.command
11
+ 'branch git <provider>'
12
+ end
13
+
14
+ def self.description
15
+ 'Switch branch. Uses a compatible provider to generate the branch-name: E.g. `abt branch git asana`'
16
+ end
17
+
18
+ def initialize(cli:, **)
19
+ @cli = cli
20
+ end
21
+
22
+ def call
23
+ create_and_switch unless switch
24
+ cli.warn "Switched to #{branch_name}"
25
+ end
26
+
27
+ private
28
+
29
+ def switch
30
+ success = false
31
+ Open3.popen3("git switch #{branch_name}") do |_i, _o, _error_output, thread|
32
+ success = thread.value.success?
33
+ end
34
+ success
35
+ end
36
+
37
+ def create_and_switch
38
+ cli.warn "No such branch: #{branch_name}"
39
+ cli.abort('Aborting') unless cli.prompt_boolean 'Create branch?'
40
+
41
+ Open3.popen3("git switch -c #{branch_name}") do |_i, _o, _e, thread|
42
+ thread.value
43
+ end
44
+ end
45
+
46
+ def branch_name # rubocop:disable Metrics/MethodLength
47
+ @branch_name ||= begin
48
+ if branch_names_from_providers.empty?
49
+ cli.abort [
50
+ 'None of the specified providers responded to `branch-name`.',
51
+ 'Did you add compatible provider? e.g.:',
52
+ ' abt branch git asana',
53
+ ' abt branch git devops'
54
+ ].join("\n")
55
+ end
56
+
57
+ if branch_names_from_providers.length > 1
58
+ cli.abort [
59
+ 'Got branch names from multiple providers, only one is supported',
60
+ 'Branch names where:',
61
+ *branch_names_from_providers.map { |name| " #{name}" }
62
+ ].join("\n")
63
+ end
64
+
65
+ branch_names_from_providers.first
66
+ end
67
+ end
68
+
69
+ def branch_names_from_providers
70
+ input = StringIO.new(cli.args.join(' '))
71
+ output = StringIO.new
72
+ Abt::Cli.new(argv: ['branch-name'], output: output, input: input).perform
73
+
74
+ output.string.lines.map(&:strip).compact
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end