abt-cli 0.0.22 → 0.0.27

Sign up to get free protection for your applications and to get access to all the features.
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