abt-cli 0.0.11 → 0.0.16

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/bin/abt +1 -7
  3. data/lib/abt.rb +12 -3
  4. data/lib/abt/cli.rb +91 -53
  5. data/lib/abt/cli/arguments_parser.rb +70 -0
  6. data/lib/abt/cli/base_command.rb +61 -0
  7. data/lib/abt/cli/prompt.rb +124 -0
  8. data/lib/abt/docs.rb +24 -18
  9. data/lib/abt/docs/cli.rb +42 -11
  10. data/lib/abt/docs/markdown.rb +36 -10
  11. data/lib/abt/git_config.rb +34 -19
  12. data/lib/abt/helpers.rb +1 -1
  13. data/lib/abt/providers/asana/base_command.rb +24 -13
  14. data/lib/abt/providers/asana/commands/add.rb +75 -0
  15. data/lib/abt/providers/asana/commands/branch_name.rb +44 -0
  16. data/lib/abt/providers/asana/commands/clear.rb +17 -6
  17. data/lib/abt/providers/asana/commands/current.rb +6 -6
  18. data/lib/abt/providers/asana/commands/finalize.rb +4 -4
  19. data/lib/abt/providers/asana/commands/harvest_time_entry_data.rb +4 -3
  20. data/lib/abt/providers/asana/commands/init.rb +5 -5
  21. data/lib/abt/providers/asana/commands/pick.rb +16 -7
  22. data/lib/abt/providers/asana/commands/projects.rb +3 -3
  23. data/lib/abt/providers/asana/commands/share.rb +8 -8
  24. data/lib/abt/providers/asana/commands/start.rb +15 -9
  25. data/lib/abt/providers/asana/commands/tasks.rb +5 -3
  26. data/lib/abt/providers/asana/configuration.rb +8 -16
  27. data/lib/abt/providers/devops/api.rb +32 -2
  28. data/lib/abt/providers/devops/base_command.rb +32 -16
  29. data/lib/abt/providers/devops/commands/boards.rb +36 -0
  30. data/lib/abt/providers/devops/commands/branch_name.rb +45 -0
  31. data/lib/abt/providers/devops/commands/clear.rb +17 -6
  32. data/lib/abt/providers/devops/commands/current.rb +6 -10
  33. data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +5 -3
  34. data/lib/abt/providers/devops/commands/init.rb +5 -5
  35. data/lib/abt/providers/devops/commands/pick.rb +29 -20
  36. data/lib/abt/providers/devops/commands/share.rb +7 -13
  37. data/lib/abt/providers/devops/commands/work-items.rb +46 -0
  38. data/lib/abt/providers/devops/configuration.rb +7 -15
  39. data/lib/abt/providers/git.rb +19 -0
  40. data/lib/abt/providers/git/commands/branch.rb +74 -0
  41. data/lib/abt/providers/harvest/base_command.rb +24 -13
  42. data/lib/abt/providers/harvest/commands/clear.rb +17 -6
  43. data/lib/abt/providers/harvest/commands/current.rb +6 -6
  44. data/lib/abt/providers/harvest/commands/init.rb +5 -5
  45. data/lib/abt/providers/harvest/commands/pick.rb +15 -6
  46. data/lib/abt/providers/harvest/commands/projects.rb +3 -3
  47. data/lib/abt/providers/harvest/commands/share.rb +5 -5
  48. data/lib/abt/providers/harvest/commands/start.rb +6 -44
  49. data/lib/abt/providers/harvest/commands/stop.rb +3 -3
  50. data/lib/abt/providers/harvest/commands/tasks.rb +5 -3
  51. data/lib/abt/providers/harvest/commands/track.rb +50 -13
  52. data/lib/abt/providers/harvest/configuration.rb +7 -13
  53. data/lib/abt/version.rb +1 -1
  54. metadata +12 -7
  55. data/lib/abt/cli/dialogs.rb +0 -86
  56. data/lib/abt/cli/io.rb +0 -23
  57. data/lib/abt/providers/asana/commands/clear_global.rb +0 -24
  58. data/lib/abt/providers/devops/commands/clear_global.rb +0 -24
  59. data/lib/abt/providers/harvest/commands/clear_global.rb +0 -24
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Asana
6
+ module Commands
7
+ class BranchName < BaseCommand
8
+ def self.usage
9
+ 'abt branch-name asana[:<project-gid>/<task-gid>]'
10
+ end
11
+
12
+ def self.description
13
+ 'Suggest a git branch name for the current/specified task.'
14
+ end
15
+
16
+ def perform
17
+ require_task!
18
+ ensure_current_is_valid!
19
+
20
+ cli.puts name
21
+ end
22
+
23
+ private
24
+
25
+ def name
26
+ task['name'].downcase.gsub(/[^\w]+/, '-')
27
+ end
28
+
29
+ def ensure_current_is_valid!
30
+ cli.abort "Invalid task gid: #{task_gid}" if task.nil?
31
+
32
+ return if task['memberships'].any? { |m| m.dig('project', 'gid') == project_gid }
33
+
34
+ cli.abort "Invalid project gid: #{project_gid}"
35
+ end
36
+
37
+ def task
38
+ @task ||= api.get("tasks/#{task_gid}", opt_fields: 'name,memberships.project')
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -5,17 +5,28 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class Clear < BaseCommand
8
- def self.command
9
- 'clear asana'
8
+ def self.usage
9
+ 'abt clear asana'
10
10
  end
11
11
 
12
12
  def self.description
13
- 'Clear project/task for current git repository'
13
+ 'Clear asana configuration'
14
14
  end
15
15
 
16
- def call
17
- cli.warn 'Clearing Asana project configuration'
18
- config.clear_local
16
+ def self.flags
17
+ [
18
+ ['-g', '--global', 'Clear global instead of local asana configuration (credentials etc.)'],
19
+ ['-a', '--all', 'Clear all asana configuration']
20
+ ]
21
+ end
22
+
23
+ def perform
24
+ if flags[:global] && flags[:all]
25
+ abort('Flags --global and --all cannot be used at the same time')
26
+ end
27
+
28
+ config.clear_local unless flags[:global]
29
+ config.clear_global if flags[:global] || flags[:all]
19
30
  end
20
31
  end
21
32
  end
@@ -5,15 +5,17 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class Current < BaseCommand
8
- def self.command
9
- 'current asana[:<project-gid>[/<task-gid>]]'
8
+ def self.usage
9
+ 'abt current asana[:<project-gid>[/<task-gid>]]'
10
10
  end
11
11
 
12
12
  def self.description
13
13
  'Get or set project and or task for current git repository'
14
14
  end
15
15
 
16
- def call
16
+ def perform
17
+ require_project!
18
+
17
19
  if same_args_as_config? || !config.local_available?
18
20
  show_current_configuration
19
21
  else
@@ -25,9 +27,7 @@ module Abt
25
27
  private
26
28
 
27
29
  def show_current_configuration
28
- if project_gid.nil?
29
- cli.warn 'No project selected'
30
- elsif task_gid.nil?
30
+ if task_gid.nil?
31
31
  print_project(project)
32
32
  else
33
33
  print_task(project, task)
@@ -5,19 +5,19 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class Finalize < BaseCommand
8
- def self.command
9
- 'finalize asana[:<project-gid>/<task-gid>]'
8
+ def self.usage
9
+ 'abt finalize asana[:<project-gid>/<task-gid>]'
10
10
  end
11
11
 
12
12
  def self.description
13
13
  'Move current/specified task to section (column) for finalized tasks'
14
14
  end
15
15
 
16
- def call
16
+ def perform
17
17
  unless config.local_available?
18
18
  cli.abort 'This is a no-op for tasks outside the current project'
19
19
  end
20
- cli.abort 'No current or specified task' if task.nil?
20
+ require_task!
21
21
  print_task(project_gid, task)
22
22
 
23
23
  if task_already_in_finalized_section?
@@ -5,15 +5,16 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class HarvestTimeEntryData < BaseCommand
8
- def self.command
9
- 'harvest-time-entry-data asana[:<project-gid>/<task-gid>]'
8
+ def self.usage
9
+ 'abt harvest-time-entry-data asana[:<project-gid>/<task-gid>]'
10
10
  end
11
11
 
12
12
  def self.description
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 perform
17
+ require_task!
17
18
  ensure_current_is_valid!
18
19
 
19
20
  body = {
@@ -5,8 +5,8 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class Init < BaseCommand
8
- def self.command
9
- 'init asana'
8
+ def self.usage
9
+ 'abt init asana'
10
10
  end
11
11
 
12
12
  def self.description
@@ -18,7 +18,7 @@ module Abt
18
18
  @cli = cli
19
19
  end
20
20
 
21
- def call
21
+ def perform
22
22
  cli.abort 'Must be run inside a git repository' unless config.local_available?
23
23
 
24
24
  projects # Load projects up front to make it obvious that searches are instant
@@ -35,14 +35,14 @@ module Abt
35
35
  cli.warn 'Select a project'
36
36
 
37
37
  loop do
38
- matches = matches_for_string cli.prompt('Enter search')
38
+ matches = matches_for_string cli.prompt.text('Enter search')
39
39
  if matches.empty?
40
40
  cli.warn 'No matches'
41
41
  next
42
42
  end
43
43
 
44
44
  cli.warn 'Showing the 10 first matches' if matches.size > 10
45
- choice = cli.prompt_choice 'Select a project', matches[0...10], true
45
+ choice = cli.prompt.choice 'Select a project', matches[0...10], true
46
46
  break choice unless choice.nil?
47
47
  end
48
48
  end
@@ -5,25 +5,34 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class Pick < BaseCommand
8
- def self.command
9
- 'pick asana[:<project-gid>]'
8
+ def self.usage
9
+ 'abt pick asana[:<project-gid>]'
10
10
  end
11
11
 
12
12
  def self.description
13
13
  'Pick task for current git repository'
14
14
  end
15
15
 
16
- def call
16
+ def self.flags
17
+ [
18
+ ['-d', '--dry-run', 'Keep existing configuration']
19
+ ]
20
+ end
21
+
22
+ def perform
17
23
  cli.abort 'Must be run inside a git repository' unless config.local_available?
24
+ require_project!
18
25
 
19
26
  cli.warn project['name']
20
27
 
21
28
  task = select_task
22
29
 
30
+ print_task(project, task)
31
+
32
+ return if flags[:"dry-run"]
33
+
23
34
  config.project_gid = project_gid # We might have gotten the project ID as an argument
24
35
  config.task_gid = task['gid']
25
-
26
- print_task(project, task)
27
36
  end
28
37
 
29
38
  private
@@ -34,7 +43,7 @@ module Abt
34
43
 
35
44
  def select_task
36
45
  loop do
37
- section = cli.prompt_choice 'Which section?', sections
46
+ section = cli.prompt.choice 'Which section?', sections
38
47
  cli.warn 'Fetching tasks...'
39
48
  tasks = tasks_in_section(section)
40
49
 
@@ -43,7 +52,7 @@ module Abt
43
52
  next
44
53
  end
45
54
 
46
- task = cli.prompt_choice 'Select a task', tasks, true
55
+ task = cli.prompt.choice 'Select a task', tasks, true
47
56
  return task if task
48
57
  end
49
58
  end
@@ -5,15 +5,15 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class Projects < BaseCommand
8
- def self.command
9
- 'projects asana'
8
+ def self.usage
9
+ 'abt projects asana'
10
10
  end
11
11
 
12
12
  def self.description
13
13
  'List all available projects - useful for piping into grep etc.'
14
14
  end
15
15
 
16
- def call
16
+ def perform
17
17
  projects.map do |project|
18
18
  print_project(project)
19
19
  end
@@ -5,21 +5,21 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class Share < BaseCommand
8
- def self.command
9
- 'share asana[:<project-gid>[/<task-gid>]]'
8
+ def self.usage
9
+ 'abt share asana[:<project-gid>[/<task-gid>]]'
10
10
  end
11
11
 
12
12
  def self.description
13
13
  'Print project/task config string'
14
14
  end
15
15
 
16
- def call
17
- if project_gid.nil?
18
- cli.warn 'No project selected'
19
- elsif task_gid.nil?
20
- cli.print_provider_command('asana', project_gid)
16
+ def perform
17
+ require_project!
18
+
19
+ if task_gid.nil?
20
+ cli.print_scheme_argument('asana', project_gid)
21
21
  else
22
- cli.print_provider_command('asana', "#{project_gid}/#{task_gid}")
22
+ cli.print_scheme_argument('asana', "#{project_gid}/#{task_gid}")
23
23
  end
24
24
  end
25
25
  end
@@ -5,16 +5,22 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class Start < BaseCommand
8
- def self.command
9
- 'start asana[:<project-gid>/<task-gid>]'
8
+ def self.usage
9
+ 'abt start asana[:<project-gid>/<task-gid>]'
10
10
  end
11
11
 
12
12
  def self.description
13
- 'Set current task and move it to a section (column) of your choice'
13
+ 'Move current or specified task to WIP section (column) and assign it to you'
14
14
  end
15
15
 
16
- def call
17
- abort 'No current/provided task' if task_gid.nil?
16
+ def self.flags
17
+ [
18
+ ['-s', '--set', 'Set specified task as current']
19
+ ]
20
+ end
21
+
22
+ def perform
23
+ require_task!
18
24
 
19
25
  maybe_override_current_task
20
26
 
@@ -25,12 +31,12 @@ module Abt
25
31
  private
26
32
 
27
33
  def maybe_override_current_task
28
- return if arg_str.nil?
34
+ return unless flags[:set]
35
+ return if path.nil?
29
36
  return if same_args_as_config?
30
37
  return unless config.local_available?
31
38
 
32
- should_override = cli.prompt_boolean 'Set selected task as current?'
33
- Current.new(arg_str: arg_str, cli: cli).call if should_override
39
+ Current.new(path: path, cli: cli).call
34
40
  end
35
41
 
36
42
  def update_assignee_if_needed
@@ -41,7 +47,7 @@ module Abt
41
47
  update_assignee
42
48
  elsif current_assignee['gid'] == current_user['gid']
43
49
  cli.warn 'You are already assigned to this task'
44
- elsif cli.prompt_boolean "Task is assigned to: #{current_assignee['name']}, take over?"
50
+ elsif cli.prompt.boolean "Task is assigned to: #{current_assignee['name']}, take over?"
45
51
  cli.warn "Reassigning task to user: #{current_user['name']}"
46
52
  update_assignee
47
53
  end
@@ -5,15 +5,17 @@ module Abt
5
5
  module Asana
6
6
  module Commands
7
7
  class Tasks < BaseCommand
8
- def self.command
9
- 'tasks asana'
8
+ def self.usage
9
+ 'abt tasks asana'
10
10
  end
11
11
 
12
12
  def self.description
13
13
  'List available tasks on project - useful for piping into grep etc.'
14
14
  end
15
15
 
16
- def call
16
+ def perform
17
+ require_project!
18
+
17
19
  tasks.each do |task|
18
20
  print_task(project, task)
19
21
  end
@@ -49,7 +49,7 @@ module Abt
49
49
  def project_gid=(value)
50
50
  return if project_gid == value
51
51
 
52
- clear_local
52
+ clear_local(verbose: false)
53
53
  git['projectGid'] = value unless value.nil?
54
54
  end
55
55
 
@@ -57,26 +57,18 @@ module Abt
57
57
  git['taskGid'] = value
58
58
  end
59
59
 
60
- def clear_local
61
- cli.abort 'No local configuration was found' unless local_available?
62
-
63
- git['projectGid'] = nil
64
- git['taskGid'] = nil
65
- git['wipSectionGid'] = nil
66
- git['finalizedSectionGid'] = nil
60
+ def clear_local(verbose: true)
61
+ git.clear(output: verbose ? cli.err_output : nil)
67
62
  end
68
63
 
69
- def clear_global
70
- git.global.keys.each do |key|
71
- cli.puts 'Deleting configuration: ' + key
72
- git.global[key] = nil
73
- end
64
+ def clear_global(verbose: true)
65
+ git.global.clear(output: verbose ? cli.err_output : nil)
74
66
  end
75
67
 
76
68
  def access_token
77
69
  return git.global['accessToken'] unless git.global['accessToken'].nil?
78
70
 
79
- git.global['accessToken'] = cli.prompt([
71
+ git.global['accessToken'] = cli.prompt.text([
80
72
  'Please provide your personal access token for Asana.',
81
73
  'If you don\'t have one, create one here: https://app.asana.com/0/developer-console',
82
74
  '',
@@ -103,7 +95,7 @@ module Abt
103
95
  def prompt_section(message)
104
96
  cli.warn 'Fetching sections...'
105
97
  sections = api.get_paged("projects/#{project_gid}/sections")
106
- cli.prompt_choice(message, sections)
98
+ cli.prompt.choice(message, sections)
107
99
  end
108
100
 
109
101
  def prompt_workspace
@@ -115,7 +107,7 @@ module Abt
115
107
  workspace = workspaces.first
116
108
  cli.warn "Selected Asana workspace #{workspace['name']}"
117
109
  else
118
- workspace = cli.prompt_choice('Select Asana workspace', workspaces)
110
+ workspace = cli.prompt.choice('Select Asana workspace', workspaces)
119
111
  end
120
112
 
121
113
  git.global['workspaceGid'] = workspace['gid']
@@ -6,13 +6,16 @@ module Abt
6
6
  class Api
7
7
  VERBS = %i[get post put].freeze
8
8
 
9
- attr_reader :organization_name, :project_name, :username, :access_token
9
+ CONDITIONAL_ACCESS_POLICY_ERROR_CODE = 'VS403463'
10
10
 
11
- def initialize(organization_name:, project_name:, username:, access_token:)
11
+ attr_reader :organization_name, :project_name, :username, :access_token, :cli
12
+
13
+ def initialize(organization_name:, project_name:, username:, access_token:, cli:)
12
14
  @organization_name = organization_name
13
15
  @project_name = project_name
14
16
  @username = username
15
17
  @access_token = access_token
18
+ @cli = cli
16
19
  end
17
20
 
18
21
  VERBS.each do |verb|
@@ -28,6 +31,18 @@ module Abt
28
31
  # TODO: Loop if necessary
29
32
  end
30
33
 
34
+ def work_item_query(wiql)
35
+ response = post('wit/wiql', Oj.dump({ query: wiql }, mode: :json))
36
+ ids = response['workItems'].map { |work_item| work_item['id'] }
37
+
38
+ work_items = []
39
+ ids.each_slice(200) do |page_ids|
40
+ work_items += get_paged('wit/workitems', ids: page_ids.join(','))
41
+ end
42
+
43
+ work_items
44
+ end
45
+
31
46
  def request(*args)
32
47
  response = connection.public_send(*args)
33
48
 
@@ -38,6 +53,8 @@ module Abt
38
53
  encoded_response_body = response.body.force_encoding('utf-8')
39
54
  raise error_class, "Code: #{response.status}, body: #{encoded_response_body}"
40
55
  end
56
+ rescue Abt::HttpError::ForbiddenError => e
57
+ handle_denied_by_conditional_access_policy!(e)
41
58
  end
42
59
 
43
60
  def base_url
@@ -59,6 +76,19 @@ module Abt
59
76
  connection.headers['Accept'] = 'application/json; api-version=6.0'
60
77
  end
61
78
  end
79
+
80
+ private
81
+
82
+ def handle_denied_by_conditional_access_policy!(exception)
83
+ raise exception unless exception.message.include?(CONDITIONAL_ACCESS_POLICY_ERROR_CODE)
84
+
85
+ cli.abort <<~TXT
86
+ Access denied by conditional access policy.
87
+ Try logging into the board using the URL below, then retry the command.
88
+
89
+ #{base_url}
90
+ TXT
91
+ end
62
92
  end
63
93
  end
64
94
  end