abt-cli 0.0.22 → 0.0.27

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/bin/abt +2 -2
  3. data/lib/abt.rb +5 -0
  4. data/lib/abt/cli.rb +28 -9
  5. data/lib/abt/cli/prompt.rb +37 -53
  6. data/lib/abt/directory_config.rb +25 -0
  7. data/lib/abt/docs.rb +10 -6
  8. data/lib/abt/docs/markdown.rb +5 -2
  9. data/lib/abt/helpers.rb +26 -8
  10. data/lib/abt/providers/asana.rb +1 -0
  11. data/lib/abt/providers/asana/base_command.rb +37 -3
  12. data/lib/abt/providers/asana/commands/add.rb +0 -4
  13. data/lib/abt/providers/asana/commands/branch_name.rb +0 -13
  14. data/lib/abt/providers/asana/commands/current.rb +1 -20
  15. data/lib/abt/providers/asana/commands/finalize.rb +6 -2
  16. data/lib/abt/providers/asana/commands/harvest_time_entry_data.rb +7 -5
  17. data/lib/abt/providers/asana/commands/pick.rb +12 -46
  18. data/lib/abt/providers/asana/commands/start.rb +9 -3
  19. data/lib/abt/providers/asana/commands/tasks.rb +2 -7
  20. data/lib/abt/providers/asana/configuration.rb +28 -12
  21. data/lib/abt/providers/asana/path.rb +1 -1
  22. data/lib/abt/providers/asana/services/project_picker.rb +54 -0
  23. data/lib/abt/providers/asana/services/task_picker.rb +83 -0
  24. data/lib/abt/providers/devops.rb +1 -0
  25. data/lib/abt/providers/devops/api.rb +10 -0
  26. data/lib/abt/providers/devops/base_command.rb +38 -14
  27. data/lib/abt/providers/devops/commands/boards.rb +1 -2
  28. data/lib/abt/providers/devops/commands/branch_name.rb +10 -16
  29. data/lib/abt/providers/devops/commands/current.rb +1 -21
  30. data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +16 -20
  31. data/lib/abt/providers/devops/commands/pick.rb +18 -39
  32. data/lib/abt/providers/devops/commands/work_items.rb +3 -6
  33. data/lib/abt/providers/devops/configuration.rb +10 -14
  34. data/lib/abt/providers/devops/path.rb +4 -4
  35. data/lib/abt/providers/devops/services/board_picker.rb +54 -0
  36. data/lib/abt/providers/devops/services/project_picker.rb +79 -0
  37. data/lib/abt/providers/devops/services/work_item_picker.rb +93 -0
  38. data/lib/abt/providers/git/commands/branch.rb +7 -3
  39. data/lib/abt/providers/harvest.rb +1 -0
  40. data/lib/abt/providers/harvest/base_command.rb +49 -3
  41. data/lib/abt/providers/harvest/commands/current.rb +1 -30
  42. data/lib/abt/providers/harvest/commands/pick.rb +12 -23
  43. data/lib/abt/providers/harvest/commands/projects.rb +0 -5
  44. data/lib/abt/providers/harvest/commands/tasks.rb +1 -16
  45. data/lib/abt/providers/harvest/commands/track.rb +33 -19
  46. data/lib/abt/providers/harvest/configuration.rb +1 -1
  47. data/lib/abt/providers/harvest/path.rb +1 -1
  48. data/lib/abt/providers/harvest/services/project_picker.rb +53 -0
  49. data/lib/abt/providers/harvest/services/task_picker.rb +50 -0
  50. data/lib/abt/version.rb +1 -1
  51. metadata +10 -5
  52. data/lib/abt/providers/asana/commands/init.rb +0 -42
  53. data/lib/abt/providers/devops/commands/init.rb +0 -76
  54. data/lib/abt/providers/harvest/commands/init.rb +0 -54
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Devops
6
+ module Services
7
+ class ProjectPicker
8
+ class Result
9
+ attr_reader :board, :path
10
+
11
+ def initialize(path:)
12
+ @path = path
13
+ end
14
+ end
15
+
16
+ AZURE_DEV_URL_REGEX = %r{^https://dev\.azure\.com/(?<organization>[^/]+)/(?<project>[^/]+)}.freeze
17
+ VS_URL_REGEX = %r{^https://(?<organization>[^.]+)\.visualstudio\.com/(?<project>[^/]+)}.freeze
18
+
19
+ extend Forwardable
20
+
21
+ def self.call(**args)
22
+ new(**args).call
23
+ end
24
+
25
+ attr_reader :cli
26
+
27
+ def initialize(cli:)
28
+ @cli = cli
29
+ end
30
+
31
+ def call
32
+ Result.new(
33
+ path: Path.from_ids(organization_name: organization_name, project_name: project_name)
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ def project_name
40
+ @project_name ||= begin
41
+ project_url_match && project_url_match[:project]
42
+ end
43
+ end
44
+
45
+ def organization_name
46
+ @organization_name ||= begin
47
+ project_url_match && project_url_match[:organization]
48
+ end
49
+ end
50
+
51
+ def project_url_match
52
+ AZURE_DEV_URL_REGEX.match(project_url) || VS_URL_REGEX.match(project_url)
53
+ end
54
+
55
+ def project_url
56
+ @project_url ||= begin
57
+ loop do
58
+ url = prompt_url
59
+
60
+ break url if AZURE_DEV_URL_REGEX =~ url || VS_URL_REGEX =~ url
61
+
62
+ cli.warn("Invalid URL")
63
+ end
64
+ end
65
+ end
66
+
67
+ def prompt_url
68
+ cli.prompt.text(<<~TXT)
69
+ Please provide the URL for the devops project
70
+ For instance https://{organization}.visualstudio.com/{project} or https://dev.azure.com/{organization}/{project}
71
+
72
+ Enter URL
73
+ TXT
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Devops
6
+ module Services
7
+ class WorkItemPicker
8
+ class Result
9
+ attr_reader :work_item, :path
10
+
11
+ def initialize(work_item:, path:)
12
+ @work_item = work_item
13
+ @path = path
14
+ end
15
+ end
16
+
17
+ def self.call(**args)
18
+ new(**args).call
19
+ end
20
+
21
+ attr_reader :cli, :config, :path, :board
22
+
23
+ def initialize(cli:, path:, config:, board:)
24
+ @cli = cli
25
+ @config = config
26
+ @path = path
27
+ @board = board
28
+ end
29
+
30
+ def call
31
+ work_item = select_work_item
32
+
33
+ path_with_work_item = Path.new([path, work_item["id"]].join("/"))
34
+
35
+ Result.new(work_item: work_item, path: path_with_work_item)
36
+ end
37
+
38
+ def select_work_item
39
+ column = cli.prompt.choice("Which column in #{board['name']}?", columns)
40
+ cli.warn("Fetching work items...")
41
+ work_items = work_items_in_column(column)
42
+
43
+ if work_items.length.zero?
44
+ cli.warn("Section is empty")
45
+ select_work_item
46
+ else
47
+ prompt_work_item(work_items) || select_work_item
48
+ end
49
+ end
50
+
51
+ def prompt_work_item(work_items)
52
+ options = work_items.map do |work_item|
53
+ {
54
+ "id" => work_item["id"],
55
+ "name" => "##{work_item['id']} #{work_item['name']}"
56
+ }
57
+ end
58
+
59
+ choice = cli.prompt.choice("Select a work item", options, nil_option: true)
60
+ choice && work_items.find { |work_item| work_item["id"] == choice["id"] }
61
+ end
62
+
63
+ def work_items_in_column(column)
64
+ work_items = api.work_item_query(
65
+ <<~WIQL
66
+ SELECT [System.Id]
67
+ FROM WorkItems
68
+ WHERE [System.BoardColumn] = '#{column['name']}'
69
+ ORDER BY [Microsoft.VSTS.Common.BacklogPriority] ASC
70
+ WIQL
71
+ )
72
+
73
+ work_items.map { |work_item| api.sanitize_work_item(work_item) }
74
+ end
75
+
76
+ def columns
77
+ board["columns"] || api.get("work/boards/#{path.board_id}")["columns"]
78
+ end
79
+
80
+ private
81
+
82
+ def api
83
+ Abt::Providers::Devops::Api.new(organization_name: path.organization_name,
84
+ project_name: path.project_name,
85
+ username: config.username_for_organization(path.organization_name),
86
+ access_token: config.access_token_for_organization(path.organization_name),
87
+ cli: cli)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -30,7 +30,7 @@ module Abt
30
30
 
31
31
  def create_and_switch
32
32
  warn("No such branch: #{branch_name}")
33
- abort("Aborting") unless cli.prompt.boolean("Create branch?")
33
+ abort("Aborting") unless cli.prompt.boolean("Create branch?", default: true)
34
34
 
35
35
  Open3.popen3("git switch -c #{branch_name}") do |_i, _o, _e, thread|
36
36
  thread.value
@@ -61,7 +61,7 @@ module Abt
61
61
  end
62
62
 
63
63
  def branch_names_from_aris
64
- other_aris = cli.aris - [ari]
64
+ return @branch_names_from_aris if instance_variable_defined?(:@branch_names_from_aris)
65
65
 
66
66
  abort("You must provide an additional ARI that responds to: branch-name. E.g., asana") if other_aris.empty?
67
67
 
@@ -69,7 +69,11 @@ module Abt
69
69
  output = StringIO.new
70
70
  Abt::Cli.new(argv: ["branch-name"], output: output, input: input).perform
71
71
 
72
- output.string.lines.map(&:strip).compact
72
+ @branch_names_from_aris = output.string.lines.map(&:strip).compact
73
+ end
74
+
75
+ def other_aris
76
+ @other_aris ||= cli.aris - [ari]
73
77
  end
74
78
  end
75
79
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  Dir.glob("#{File.expand_path(__dir__)}/harvest/*.rb").sort.each { |file| require file }
4
4
  Dir.glob("#{File.expand_path(__dir__)}/harvest/commands/*.rb").sort.each { |file| require file }
5
+ Dir.glob("#{File.expand_path(__dir__)}/harvest/services/*.rb").sort.each { |file| require file }
5
6
 
6
7
  module Abt
7
8
  module Providers
@@ -19,16 +19,62 @@ module Abt
19
19
 
20
20
  private
21
21
 
22
+ def require_local_config!
23
+ abort("Must be run inside a git repository") unless config.local_available?
24
+ end
25
+
22
26
  def require_project!
23
27
  return if project_id
24
28
 
25
- abort("No current/specified project. Did you initialize Harvest?")
29
+ abort("No current/specified project. Did you forget to run `pick`?")
26
30
  end
27
31
 
28
32
  def require_task!
29
- abort("No current/specified project. Did you initialize Harvest and pick a task?") unless project_id
33
+ require_project!
34
+ return if task_id
35
+
36
+ abort("No current/specified task. Did you forget to run `pick`?")
37
+ end
38
+
39
+ def prompt_project!
40
+ result = Services::ProjectPicker.call(cli: cli, project_assignments: project_assignments)
41
+ @path = result.path
42
+ @project = result.project
43
+ end
44
+
45
+ def prompt_task!
46
+ result = Services::TaskPicker.call(cli: cli, path: path, project_assignment: project_assignment)
47
+ @path = result.path
48
+ @task = result.task
49
+ end
50
+
51
+ def task
52
+ return @task if instance_variable_defined?(:@task)
53
+
54
+ @task = if project_assignment
55
+ project_assignment["task_assignments"].map { |ta| ta["task"] }.find do |task|
56
+ task["id"].to_s == task_id
57
+ end
58
+ end
59
+ end
60
+
61
+ def project
62
+ return @project if instance_variable_defined?(:@project)
63
+
64
+ @project = if project_assignment
65
+ project_assignment["project"].merge("client" => project_assignment["client"])
66
+ end
67
+ end
68
+
69
+ def project_assignment
70
+ @project_assignment ||= project_assignments.find { |pa| pa["project"]["id"].to_s == path.project_id }
71
+ end
30
72
 
31
- abort("No current/specified task. Did you pick a Harvest task?") if task_id.nil?
73
+ def project_assignments
74
+ @project_assignments ||= begin
75
+ warn("Fetching Harvest data...")
76
+ api.get_paged("users/me/project_assignments")
77
+ end
32
78
  end
33
79
 
34
80
  def print_project(project)
@@ -14,8 +14,7 @@ module Abt
14
14
  end
15
15
 
16
16
  def perform
17
- abort("Must be run inside a git repository") unless config.local_available?
18
-
17
+ require_local_config!
19
18
  require_project!
20
19
  ensure_valid_configuration!
21
20
 
@@ -41,34 +40,6 @@ module Abt
41
40
  abort("Invalid project: #{project_id}") if project.nil?
42
41
  abort("Invalid task: #{task_id}") if task_id && task.nil?
43
42
  end
44
-
45
- def project
46
- return @project if instance_variable_defined?(:@project)
47
-
48
- @project = if project_assignment
49
- project_assignment["project"].merge("client" => project_assignment["client"])
50
- end
51
- end
52
-
53
- def task
54
- return @task if instance_variable_defined?(:@task)
55
-
56
- @task = if project_assignment
57
- project_assignment["task_assignments"].map { |ta| ta["task"] }.find do |task|
58
- task["id"].to_s == task_id
59
- end
60
- end
61
- end
62
-
63
- def project_assignment
64
- @project_assignment ||= begin
65
- project_assignments.find { |pa| pa["project"]["id"].to_s == project_id }
66
- end
67
- end
68
-
69
- def project_assignments
70
- @project_assignments ||= api.get_paged("users/me/project_assignments")
71
- end
72
43
  end
73
44
  end
74
45
  end
@@ -15,42 +15,31 @@ module Abt
15
15
 
16
16
  def self.flags
17
17
  [
18
- ["-d", "--dry-run", "Keep existing configuration"]
18
+ ["-d", "--dry-run", "Keep existing configuration"],
19
+ ["-c", "--clean", "Don't reuse project configuration"]
19
20
  ]
20
21
  end
21
22
 
22
23
  def perform
23
- abort("Must be run inside a git repository") unless config.local_available?
24
- require_project!
25
-
26
- warn(project["name"])
27
- task = cli.prompt.choice("Select a task", tasks)
24
+ pick!
28
25
 
29
26
  print_task(project, task)
30
27
 
31
28
  return if flags[:"dry-run"]
32
29
 
33
- config.path = Path.from_ids(project_id, task["id"])
34
- end
35
-
36
- private
37
-
38
- def project
39
- project_assignment["project"]
40
- end
30
+ unless config.local_available?
31
+ warn("No local configuration to update - will function as dry run")
32
+ return
33
+ end
41
34
 
42
- def tasks
43
- @tasks ||= project_assignment["task_assignments"].map { |ta| ta["task"] }
35
+ config.path = Path.from_ids(project_id: project["id"], task_id: task["id"])
44
36
  end
45
37
 
46
- def project_assignment
47
- @project_assignment ||= begin
48
- project_assignments.find { |pa| pa["project"]["id"].to_s == project_id }
49
- end
50
- end
38
+ private
51
39
 
52
- def project_assignments
53
- @project_assignments ||= api.get_paged("users/me/project_assignments")
40
+ def pick!
41
+ prompt_project! if project_id.nil? || flags[:clean]
42
+ prompt_task!
54
43
  end
55
44
  end
56
45
  end
@@ -23,16 +23,11 @@ module Abt
23
23
 
24
24
  def projects
25
25
  @projects ||= begin
26
- warn("Fetching projects...")
27
26
  project_assignments.map do |project_assignment|
28
27
  project_assignment["project"].merge("client" => project_assignment["client"])
29
28
  end
30
29
  end
31
30
  end
32
-
33
- def project_assignments
34
- @project_assignments ||= api.get_paged("users/me/project_assignments")
35
- end
36
31
  end
37
32
  end
38
33
  end
@@ -14,7 +14,7 @@ module Abt
14
14
  end
15
15
 
16
16
  def perform
17
- require_project!
17
+ prompt_project! unless project_id
18
18
 
19
19
  tasks.each do |task|
20
20
  print_task(project, task)
@@ -23,26 +23,11 @@ module Abt
23
23
 
24
24
  private
25
25
 
26
- def project
27
- project_assignment["project"]
28
- end
29
-
30
26
  def tasks
31
27
  @tasks ||= begin
32
- warn("Fetching tasks...")
33
28
  project_assignment["task_assignments"].map { |ta| ta["task"] }
34
29
  end
35
30
  end
36
-
37
- def project_assignment
38
- @project_assignment ||= begin
39
- project_assignments.find { |pa| pa["project"]["id"].to_s == project_id }
40
- end
41
- end
42
-
43
- def project_assignments
44
- @project_assignments ||= api.get_paged("users/me/project_assignments")
45
- end
46
31
  end
47
32
  end
48
33
  end
@@ -42,8 +42,7 @@ module Abt
42
42
  end
43
43
 
44
44
  def create_time_entry
45
- body = time_entry_base_data
46
- body[:hours] = flags[:time] if flags.key?(:time)
45
+ body = time_entry_data
47
46
 
48
47
  result = api.post("time_entries", Oj.dump(body, mode: :json))
49
48
 
@@ -52,47 +51,62 @@ module Abt
52
51
  result
53
52
  end
54
53
 
54
+ def time_entry_data
55
+ body = time_entry_base_data
56
+
57
+ maybe_add_external_link(body)
58
+ maybe_add_comment(body)
59
+ maybe_add_time(body)
60
+
61
+ body
62
+ end
63
+
55
64
  def time_entry_base_data
56
- body = {
65
+ {
57
66
  project_id: project_id,
58
67
  task_id: task_id,
59
68
  user_id: config.user_id,
60
69
  spent_date: Date.today.iso8601
61
70
  }
71
+ end
62
72
 
73
+ def maybe_add_external_link(body)
63
74
  if external_link_data
64
75
  warn(<<~TXT)
65
76
  Linking to:
66
- #{external_link_data[:notes]}
67
- #{external_link_data[:external_reference][:permalink]}
77
+ #{external_link_data[:notes]}
78
+ #{external_link_data[:external_reference][:permalink]}
68
79
  TXT
69
80
  body.merge!(external_link_data)
70
81
  else
71
82
  warn("No external link provided")
72
83
  end
84
+ end
73
85
 
86
+ def maybe_add_comment(body)
74
87
  body[:notes] = flags[:comment] if flags.key?(:comment)
75
88
  body[:notes] ||= cli.prompt.text("Fill in comment (optional)")
76
- body
89
+ end
90
+
91
+ def maybe_add_time(body)
92
+ body[:hours] = flags[:time] if flags.key?(:time)
77
93
  end
78
94
 
79
95
  def external_link_data
80
- @external_link_data ||= begin
81
- lines = call_harvest_time_entry_data_for_other_aris
82
-
83
- if lines.empty?
84
- nil
85
- else
86
- if lines.length > 1
87
- abort("Got reference data from multiple scheme providers, only one is supported at a time")
88
- end
89
-
90
- Oj.load(lines.first, symbol_keys: true)
91
- end
96
+ return @external_link_data if instance_variable_defined?(:@external_link_data)
97
+
98
+ lines = fetch_link_data_lines
99
+
100
+ return @external_link_data = nil if lines.empty?
101
+
102
+ if lines.length > 1
103
+ abort("Got reference data from multiple scheme providers, only one is supported at a time")
92
104
  end
105
+
106
+ @external_link_data = Oj.load(lines.first, symbol_keys: true)
93
107
  end
94
108
 
95
- def call_harvest_time_entry_data_for_other_aris
109
+ def fetch_link_data_lines
96
110
  other_aris = cli.aris - [ari]
97
111
  return [] if other_aris.empty?
98
112