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.
- checksums.yaml +4 -4
- data/bin/abt +2 -2
- data/lib/abt.rb +5 -0
- data/lib/abt/cli.rb +28 -9
- data/lib/abt/cli/prompt.rb +37 -53
- data/lib/abt/directory_config.rb +25 -0
- data/lib/abt/docs.rb +10 -6
- data/lib/abt/docs/markdown.rb +5 -2
- data/lib/abt/helpers.rb +26 -8
- data/lib/abt/providers/asana.rb +1 -0
- data/lib/abt/providers/asana/base_command.rb +37 -3
- data/lib/abt/providers/asana/commands/add.rb +0 -4
- data/lib/abt/providers/asana/commands/branch_name.rb +0 -13
- data/lib/abt/providers/asana/commands/current.rb +1 -20
- data/lib/abt/providers/asana/commands/finalize.rb +6 -2
- data/lib/abt/providers/asana/commands/harvest_time_entry_data.rb +7 -5
- data/lib/abt/providers/asana/commands/pick.rb +12 -46
- data/lib/abt/providers/asana/commands/start.rb +9 -3
- data/lib/abt/providers/asana/commands/tasks.rb +2 -7
- data/lib/abt/providers/asana/configuration.rb +28 -12
- data/lib/abt/providers/asana/path.rb +1 -1
- data/lib/abt/providers/asana/services/project_picker.rb +54 -0
- data/lib/abt/providers/asana/services/task_picker.rb +83 -0
- data/lib/abt/providers/devops.rb +1 -0
- data/lib/abt/providers/devops/api.rb +10 -0
- data/lib/abt/providers/devops/base_command.rb +38 -14
- data/lib/abt/providers/devops/commands/boards.rb +1 -2
- data/lib/abt/providers/devops/commands/branch_name.rb +10 -16
- data/lib/abt/providers/devops/commands/current.rb +1 -21
- data/lib/abt/providers/devops/commands/harvest_time_entry_data.rb +16 -20
- data/lib/abt/providers/devops/commands/pick.rb +18 -39
- data/lib/abt/providers/devops/commands/work_items.rb +3 -6
- data/lib/abt/providers/devops/configuration.rb +10 -14
- data/lib/abt/providers/devops/path.rb +4 -4
- data/lib/abt/providers/devops/services/board_picker.rb +54 -0
- data/lib/abt/providers/devops/services/project_picker.rb +79 -0
- data/lib/abt/providers/devops/services/work_item_picker.rb +93 -0
- data/lib/abt/providers/git/commands/branch.rb +7 -3
- data/lib/abt/providers/harvest.rb +1 -0
- data/lib/abt/providers/harvest/base_command.rb +49 -3
- data/lib/abt/providers/harvest/commands/current.rb +1 -30
- data/lib/abt/providers/harvest/commands/pick.rb +12 -23
- data/lib/abt/providers/harvest/commands/projects.rb +0 -5
- data/lib/abt/providers/harvest/commands/tasks.rb +1 -16
- data/lib/abt/providers/harvest/commands/track.rb +33 -19
- data/lib/abt/providers/harvest/configuration.rb +1 -1
- data/lib/abt/providers/harvest/path.rb +1 -1
- data/lib/abt/providers/harvest/services/project_picker.rb +53 -0
- data/lib/abt/providers/harvest/services/task_picker.rb +50 -0
- data/lib/abt/version.rb +1 -1
- metadata +10 -5
- data/lib/abt/providers/asana/commands/init.rb +0 -42
- data/lib/abt/providers/devops/commands/init.rb +0 -76
- 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
|
-
|
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
|
29
|
+
abort("No current/specified project. Did you forget to run `pick`?")
|
26
30
|
end
|
27
31
|
|
28
32
|
def require_task!
|
29
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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.
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
-
|
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
|
-
|
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
|
53
|
-
|
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
|
-
|
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 =
|
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
|
-
|
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
|
-
|
67
|
-
|
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
|
-
|
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
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
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
|
109
|
+
def fetch_link_data_lines
|
96
110
|
other_aris = cli.aris - [ari]
|
97
111
|
return [] if other_aris.empty?
|
98
112
|
|