abt-cli 0.0.28 → 0.0.32

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5dd7a6d58fed695f167581c8c7b16578b1e0f29664319c1b3594235e07bfc179
4
- data.tar.gz: 48e88420bd4e7b7dd2c99ac82cfcc7ada1120637f9f7b08c39fa9abf128ebefe
3
+ metadata.gz: 38d3fa16692941ffa0e720a7a0331d91215e33e37092946df35053f040ee2af8
4
+ data.tar.gz: b2471d250f4580b9334f7005496e45ae1487eb8e979bcf3aedd975e3f418c79b
5
5
  SHA512:
6
- metadata.gz: d8e7534007c20d2f16099e453d8d7fdaae042f4f658ef111c4a7d2ec25dee13d9e6c7ed511de57ba14e249c1043129cb540742a5746ee32c6ed51c419470e756
7
- data.tar.gz: 28173fc0ea536fdfd3c787e18abf4ed27818f9a47d4e69bfa2c25c6f5a00ef12e586378dec0be4e21b61cb198e24ab8ba36c1289ccc0b90f0f009d88000ba5e3
6
+ metadata.gz: c5232fcb04eb5c3c35cae6b29a82586ef289316a3540eec50f63aa55b42a79226e4000d3c6cff37a318cf8b93d17f47df3cccd8a61f68d02e2fccbe0f9aacd18
7
+ data.tar.gz: 59cc3c70175c71420cbb2c0bc0b6e96d40b34c21a225cf4f2e3e310fa33f228a1792a31d6eeb9b4485ef140f75f88358679e08d301ef7adde736b5f45fe86ddf
@@ -22,7 +22,7 @@ module Abt
22
22
  end
23
23
 
24
24
  def perform
25
- abort("Flags --global and --all cannot be used at the same time") if flags[:global] && flags[:all]
25
+ abort("Flags --global and --all cannot be used together") if flags[:global] && flags[:all]
26
26
 
27
27
  config.clear_local unless flags[:global]
28
28
  config.clear_global if flags[:global] || flags[:all]
@@ -28,7 +28,7 @@ module Abt
28
28
  return if flags[:"dry-run"]
29
29
 
30
30
  if config.local_available?
31
- config.path = Path.from_gids(project_gid: project["gid"], task_gid: task["gid"])
31
+ config.path = path
32
32
  else
33
33
  warn("No local configuration to update - will function as dry run")
34
34
  end
@@ -15,7 +15,7 @@ module Abt
15
15
  end
16
16
 
17
17
  def path
18
- Path.new(local_available? && git["path"] || directory_config["path"] || "")
18
+ Path.new((local_available? && git["path"]) || directory_config["path"] || "")
19
19
  end
20
20
 
21
21
  def path=(new_path)
@@ -30,7 +30,7 @@ module Abt
30
30
  def call
31
31
  task = select_task
32
32
 
33
- path_with_task = Path.new([path, task["gid"]].join("/"))
33
+ path_with_task = Path.from_gids(project_gid: path.project_gid, task_gid: task["gid"])
34
34
 
35
35
  Result.new(task: task, path: path_with_task)
36
36
  end
@@ -45,7 +45,7 @@ module Abt
45
45
  cli.warn("Section is empty")
46
46
  select_task
47
47
  else
48
- cli.prompt.choice("Select a task", tasks, nil_option: true) || select_task
48
+ cli.prompt.choice("Select a task", options_for_tasks(tasks), nil_option: true) || select_task
49
49
  end
50
50
  end
51
51
 
@@ -53,12 +53,32 @@ module Abt
53
53
  cli.prompt.choice("Which section in #{project['name']}?", sections)
54
54
  end
55
55
 
56
+ def options_for_tasks(tasks)
57
+ tasks.map do |task|
58
+ formatted_name = [
59
+ task["name"],
60
+ formatted_assignee(task)
61
+ ].compact.join(" ")
62
+
63
+ task.merge("name" => formatted_name)
64
+ end
65
+ end
66
+
67
+ def formatted_assignee(task)
68
+ name = task.dig("assignee", "name")
69
+
70
+ return unless name
71
+
72
+ initials = name.split.map(&:chr).join.upcase
73
+ "(#{initials}👤)"
74
+ end
75
+
56
76
  def tasks_in_section(section)
57
77
  cli.warn("Fetching tasks...")
58
78
  tasks = api.get_paged(
59
79
  "tasks",
60
80
  section: section["gid"],
61
- opt_fields: "name,completed,permalink_url"
81
+ opt_fields: "name,completed,permalink_url,assignee.name"
62
82
  )
63
83
 
64
84
  # The below filtering is the best we can do with Asanas api, see this:
@@ -4,15 +4,22 @@ module Abt
4
4
  module Providers
5
5
  module Devops
6
6
  class Api
7
+ # Shamelessly copied from ERB::Util.url_encode
8
+ # https://apidock.com/ruby/ERB/Util/url_encode
9
+ def self.rfc_3986_encode_path_segment(string)
10
+ string.to_s.b.gsub(/[^a-zA-Z0-9_\-.~]/) do |match|
11
+ format("%%%02X", match.unpack1("C"))
12
+ end
13
+ end
14
+
7
15
  VERBS = [:get, :post, :put].freeze
8
16
 
9
17
  CONDITIONAL_ACCESS_POLICY_ERROR_CODE = "VS403463"
10
18
 
11
19
  attr_reader :organization_name, :project_name, :username, :access_token, :cli
12
20
 
13
- def initialize(organization_name:, project_name:, username:, access_token:, cli:)
21
+ def initialize(organization_name:, username:, access_token:, cli:)
14
22
  @organization_name = organization_name
15
- @project_name = project_name
16
23
  @username = username
17
24
  @access_token = access_token
18
25
  @cli = cli
@@ -32,12 +39,12 @@ module Abt
32
39
  end
33
40
 
34
41
  def work_item_query(wiql)
35
- response = post("wit/wiql", Oj.dump({ query: wiql }, mode: :json))
42
+ response = post("_apis/wit/wiql", Oj.dump({ query: wiql }, mode: :json))
36
43
  ids = response["workItems"].map { |work_item| work_item["id"] }
37
44
 
38
45
  work_items = []
39
46
  ids.each_slice(200) do |page_ids|
40
- work_items += get_paged("wit/workitems", ids: page_ids.join(","))
47
+ work_items += get_paged("_apis/wit/workitems", ids: page_ids.join(","))
41
48
  end
42
49
 
43
50
  work_items
@@ -58,19 +65,17 @@ module Abt
58
65
  end
59
66
 
60
67
  def base_url
61
- "https://#{organization_name}.visualstudio.com/#{project_name}"
62
- end
63
-
64
- def api_endpoint
65
- "#{base_url}/_apis"
68
+ "https://#{organization_name}.visualstudio.com"
66
69
  end
67
70
 
68
71
  def url_for_work_item(work_item)
69
- "#{base_url}/_workitems/edit/#{work_item['id']}"
72
+ project_name = self.class.rfc_3986_encode_path_segment(work_item["fields"]["System.TeamProject"])
73
+ "#{base_url}/#{project_name}/_workitems/edit/#{work_item['id']}"
70
74
  end
71
75
 
72
- def url_for_board(board)
73
- "#{base_url}/_boards/board/#{rfc_3986_encode_path_segment(board['name'])}"
76
+ def url_for_board(project_name, team_name, board)
77
+ board_name = self.class.rfc_3986_encode_path_segment(board["name"])
78
+ "#{base_url}/#{project_name}/_boards/board/t/#{team_name}/#{board_name}"
74
79
  end
75
80
 
76
81
  def sanitize_work_item(work_item)
@@ -84,7 +89,7 @@ module Abt
84
89
  end
85
90
 
86
91
  def connection
87
- @connection ||= Faraday.new(api_endpoint) do |connection|
92
+ @connection ||= Faraday.new(base_url) do |connection|
88
93
  connection.basic_auth(username, access_token)
89
94
  connection.headers["Content-Type"] = "application/json"
90
95
  connection.headers["Accept"] = "application/json; api-version=6.0"
@@ -93,14 +98,6 @@ module Abt
93
98
 
94
99
  private
95
100
 
96
- # Shamelessly copied from ERB::Util.url_encode
97
- # https://apidock.com/ruby/ERB/Util/url_encode
98
- def rfc_3986_encode_path_segment(string)
99
- string.to_s.b.gsub(/[^a-zA-Z0-9_\-.~]/) do |match|
100
- format("%%%02X", match.unpack1("C"))
101
- end
102
- end
103
-
104
101
  def handle_denied_by_conditional_access_policy!(exception)
105
102
  raise exception unless exception.message.include?(CONDITIONAL_ACCESS_POLICY_ERROR_CODE)
106
103
 
@@ -8,7 +8,7 @@ module Abt
8
8
 
9
9
  attr_reader :config, :path
10
10
 
11
- def_delegators(:@path, :organization_name, :project_name, :board_id, :work_item_id)
11
+ def_delegators(:@path, :organization_name, :project_name, :team_name, :board_name, :work_item_id)
12
12
 
13
13
  def initialize(ari:, cli:)
14
14
  super
@@ -24,7 +24,7 @@ module Abt
24
24
  end
25
25
 
26
26
  def require_board!
27
- return if board_id && organization_name && project_name
27
+ return if board_name && organization_name && project_name && team_name
28
28
 
29
29
  abort("No current/specified board. Did you forget to `pick`?")
30
30
  end
@@ -36,12 +36,8 @@ module Abt
36
36
  abort("No current/specified work item. Did you forget to `pick`?")
37
37
  end
38
38
 
39
- def prompt_project!
40
- @path = Services::ProjectPicker.call(cli: cli).path
41
- end
42
-
43
39
  def prompt_board!
44
- result = Services::BoardPicker.call(cli: cli, path: path, config: config)
40
+ result = Services::BoardPicker.call(cli: cli, config: config)
45
41
  @path = result.path
46
42
  @board = result.board
47
43
  end
@@ -54,7 +50,7 @@ module Abt
54
50
 
55
51
  def board
56
52
  @board ||= begin
57
- api.get("work/boards/#{board_id}")
53
+ api.get("#{project_name}/#{team_name}/_apis/work/boards/#{board_name}")
58
54
  rescue HttpError::NotFoundError
59
55
  nil
60
56
  end
@@ -62,33 +58,34 @@ module Abt
62
58
 
63
59
  def work_item
64
60
  @work_item ||= begin
65
- work_item = api.get_paged("wit/workitems", ids: work_item_id)[0]
61
+ work_item = api.get_paged("_apis/wit/workitems", ids: work_item_id)[0]
66
62
  api.sanitize_work_item(work_item)
67
63
  rescue HttpError::NotFoundError
68
64
  nil
69
65
  end
70
66
  end
71
67
 
72
- def print_board(organization_name, project_name, board)
73
- path = "#{organization_name}/#{project_name}/#{board['id']}"
68
+ def print_board(organization_name, project_name, team_name, board)
69
+ board_name = Api.rfc_3986_encode_path_segment(board["name"])
70
+ path = "#{organization_name}/#{project_name}/#{team_name}/#{board_name}"
74
71
 
75
72
  cli.print_ari("devops", path, board["name"])
76
- warn(api.url_for_board(board)) if cli.output.isatty
73
+ warn(api.url_for_board(project_name, team_name, board)) if cli.output.isatty
77
74
  end
78
75
 
79
- def print_work_item(organization, project, board, work_item)
80
- path = "#{organization}/#{project}/#{board['id']}/#{work_item['id']}"
76
+ def print_work_item(organization, project, team_name, board, work_item)
77
+ board_name = Api.rfc_3986_encode_path_segment(board["name"])
78
+ path = "#{organization}/#{project}/#{team_name}/#{board_name}/#{work_item['id']}"
81
79
 
82
80
  cli.print_ari("devops", path, work_item["name"])
83
81
  warn(work_item["url"]) if work_item.key?("url") && cli.output.isatty
84
82
  end
85
83
 
86
84
  def api
87
- Abt::Providers::Devops::Api.new(organization_name: organization_name,
88
- project_name: project_name,
89
- username: config.username_for_organization(organization_name),
90
- access_token: config.access_token_for_organization(organization_name),
91
- cli: cli)
85
+ Api.new(organization_name: organization_name,
86
+ username: config.username_for_organization(organization_name),
87
+ access_token: config.access_token_for_organization(organization_name),
88
+ cli: cli)
92
89
  end
93
90
  end
94
91
  end
@@ -19,11 +19,9 @@ module Abt
19
19
  if work_item
20
20
  puts name
21
21
  else
22
- args = [organization_name, project_name, board_id, work_item_id].compact
23
-
24
22
  abort(<<~TXT)
25
23
  Unable to find work item for configuration:
26
- devops:#{args.join('/')}
24
+ devops:#{path}
27
25
  TXT
28
26
  end
29
27
  end
@@ -22,7 +22,7 @@ module Abt
22
22
  end
23
23
 
24
24
  def perform
25
- abort("Flags --global and --all cannot be used at the same time") if flags[:global] && flags[:all]
25
+ abort("Flags --global and --all cannot be used together") if flags[:global] && flags[:all]
26
26
 
27
27
  config.clear_local unless flags[:global]
28
28
  config.clear_global if flags[:global] || flags[:all]
@@ -30,9 +30,9 @@ module Abt
30
30
 
31
31
  def print_configuration
32
32
  if work_item_id.nil?
33
- print_board(organization_name, project_name, board)
33
+ print_board(organization_name, project_name, team_name, board)
34
34
  else
35
- print_work_item(organization_name, project_name, board, work_item)
35
+ print_work_item(organization_name, project_name, team_name, board, work_item)
36
36
  end
37
37
  end
38
38
 
@@ -19,11 +19,9 @@ module Abt
19
19
  if work_item
20
20
  puts Oj.dump(body, mode: :json)
21
21
  else
22
- args = [organization_name, project_name, board_id, work_item_id].compact
23
-
24
22
  abort(<<~TXT)
25
23
  Unable to find work item for configuration:
26
- devops:#{args.join('/')}
24
+ devops:#{path}
27
25
  TXT
28
26
  end
29
27
  end
@@ -23,12 +23,12 @@ module Abt
23
23
  def perform
24
24
  pick!
25
25
 
26
- print_work_item(organization_name, project_name, board, work_item)
26
+ print_work_item(organization_name, project_name, team_name, board, work_item)
27
27
 
28
28
  return if flags[:"dry-run"]
29
29
 
30
30
  if config.local_available?
31
- update_config(work_item)
31
+ config.path = path
32
32
  else
33
33
  warn("No local configuration to update - will function as dry run")
34
34
  end
@@ -37,19 +37,9 @@ module Abt
37
37
  private
38
38
 
39
39
  def pick!
40
- prompt_project! if project_name.nil? || flags[:clean]
41
- prompt_board! if board_id.nil? || flags[:clean]
40
+ prompt_board! if board_name.nil? || flags[:clean]
42
41
  prompt_work_item!
43
42
  end
44
-
45
- def update_config(work_item)
46
- config.path = Path.from_ids(
47
- organization_name: organization_name,
48
- project_name: project_name,
49
- board_id: board_id,
50
- work_item_id: work_item["id"]
51
- )
52
- end
53
43
  end
54
44
  end
55
45
  end
@@ -14,11 +14,10 @@ module Abt
14
14
  end
15
15
 
16
16
  def perform
17
- prompt_project! unless project_name
18
- prompt_board! unless board_id
17
+ prompt_board! unless board_name
19
18
 
20
19
  work_items.each do |work_item|
21
- print_work_item(organization_name, project_name, board, work_item)
20
+ print_work_item(organization_name, project_name, team_name, board, work_item)
22
21
  end
23
22
  end
24
23
 
@@ -20,8 +20,7 @@ module Abt
20
20
  end
21
21
 
22
22
  def perform
23
- prompt_project! if project_name.nil? || flags[:clean]
24
- prompt_board! if board_id.nil? || flags[:clean]
23
+ prompt_board! if board_name.nil? || flags[:clean]
25
24
 
26
25
  update_directory_config!
27
26
 
@@ -35,7 +34,8 @@ module Abt
35
34
  "path" => Path.from_ids(
36
35
  organization_name: organization_name,
37
36
  project_name: project_name,
38
- board_id: board_id
37
+ team_name: team_name,
38
+ board_name: board_name
39
39
  ).to_s
40
40
  }
41
41
  cli.directory_config.save!
@@ -15,7 +15,7 @@ module Abt
15
15
  end
16
16
 
17
17
  def path
18
- Path.new(local_available? && git["path"] || cli.directory_config.dig("devops", "path") || "")
18
+ Path.new((local_available? && git["path"]) || cli.directory_config.dig("devops", "path") || "")
19
19
  end
20
20
 
21
21
  def path=(new_path)
@@ -6,16 +6,28 @@ module Abt
6
6
  class Path < String
7
7
  ORGANIZATION_NAME_REGEX = %r{(?<organization_name>[^/ ]+)}.freeze
8
8
  PROJECT_NAME_REGEX = %r{(?<project_name>[^/ ]+)}.freeze
9
- BOARD_ID_REGEX = /(?<board_id>[a-z0-9\-]+)/.freeze
9
+ TEAM_NAME_REGEX = %r{(?<team_name>[^/ ]+)}.freeze
10
+ BOARD_NAME_REGEX = %r{(?<board_name>[^/ ]+)}.freeze
10
11
  WORK_ITEM_ID_REGEX = /(?<work_item_id>\d+)/.freeze
11
12
 
12
13
  PATH_REGEX =
13
- %r{^(#{ORGANIZATION_NAME_REGEX}/#{PROJECT_NAME_REGEX}(/#{BOARD_ID_REGEX}(/#{WORK_ITEM_ID_REGEX})?)?)?}.freeze
14
+ %r{^(#{ORGANIZATION_NAME_REGEX}/#{PROJECT_NAME_REGEX}(/#{TEAM_NAME_REGEX}(/#{BOARD_NAME_REGEX}(/#{WORK_ITEM_ID_REGEX})?)?)?)?}.freeze # rubocop:disable Layout/LineLength
14
15
 
15
- def self.from_ids(organization_name: nil, project_name: nil, board_id: nil, work_item_id: nil)
16
+ def self.from_ids(organization_name: nil, project_name: nil, team_name: nil, board_name: nil, work_item_id: nil)
16
17
  return new unless organization_name && project_name
17
18
 
18
- new([organization_name, project_name, *board_id, *work_item_id].join("/"))
19
+ parts = [organization_name, project_name]
20
+
21
+ if team_name
22
+ parts << team_name
23
+
24
+ if board_name
25
+ parts << board_name
26
+ parts << work_item_id if work_item_id
27
+ end
28
+ end
29
+
30
+ new(parts.join("/"))
19
31
  end
20
32
 
21
33
  def initialize(path = "")
@@ -32,8 +44,12 @@ module Abt
32
44
  match[:project_name]
33
45
  end
34
46
 
35
- def board_id
36
- match[:board_id]
47
+ def team_name
48
+ match[:team_name]
49
+ end
50
+
51
+ def board_name
52
+ match[:board_name]
37
53
  end
38
54
 
39
55
  def work_item_id
@@ -20,32 +20,47 @@ module Abt
20
20
 
21
21
  attr_reader :cli, :config, :path
22
22
 
23
- def initialize(cli:, path:, config:)
23
+ def initialize(cli:, config:)
24
24
  @cli = cli
25
25
  @config = config
26
- @path = path
27
26
  end
28
27
 
29
28
  def call
29
+ @path = ProjectPicker.call(cli: cli).path
30
30
  board = cli.prompt.choice("Select a project work board", boards)
31
31
 
32
- path_with_board = Path.new([path, board["id"]].join("/"))
33
-
34
- Result.new(board: board, path: path_with_board)
32
+ Result.new(board: board, path: path_with_board(team, board))
35
33
  end
36
34
 
37
35
  private
38
36
 
37
+ def path_with_board(team, board)
38
+ Path.from_ids(
39
+ organization_name: path.organization_name,
40
+ project_name: path.project_name,
41
+ team_name: Api.rfc_3986_encode_path_segment(team["name"]),
42
+ board_name: Api.rfc_3986_encode_path_segment(board["name"])
43
+ )
44
+ end
45
+
46
+ def team
47
+ @team ||= cli.prompt.choice("Select a team", teams)
48
+ end
49
+
50
+ def teams
51
+ @teams ||= api.get_paged("/_apis/projects/#{path.project_name}/teams")
52
+ end
53
+
39
54
  def boards
40
- @boards ||= api.get_paged("work/boards")
55
+ team_name = Api.rfc_3986_encode_path_segment(team["name"])
56
+ @boards ||= api.get_paged("#{path.project_name}/#{team_name}/_apis/work/boards")
41
57
  end
42
58
 
43
59
  def api
44
- Abt::Providers::Devops::Api.new(organization_name: path.organization_name,
45
- project_name: path.project_name,
46
- username: config.username_for_organization(path.organization_name),
47
- access_token: config.access_token_for_organization(path.organization_name),
48
- cli: cli)
60
+ Api.new(organization_name: path.organization_name,
61
+ username: config.username_for_organization(path.organization_name),
62
+ access_token: config.access_token_for_organization(path.organization_name),
63
+ cli: cli)
49
64
  end
50
65
  end
51
66
  end
@@ -30,7 +30,13 @@ module Abt
30
30
  def call
31
31
  work_item = select_work_item
32
32
 
33
- path_with_work_item = Path.new([path, work_item["id"]].join("/"))
33
+ path_with_work_item = Path.from_ids(
34
+ organization_name: path.organization_name,
35
+ project_name: path.project_name,
36
+ team_name: path.team_name,
37
+ board_name: path.board_name,
38
+ work_item_id: work_item["id"]
39
+ )
34
40
 
35
41
  Result.new(work_item: work_item, path: path_with_work_item)
36
42
  end
@@ -74,14 +80,14 @@ module Abt
74
80
  end
75
81
 
76
82
  def columns
77
- board["columns"] || api.get("work/boards/#{path.board_id}")["columns"]
83
+ board["columns"] ||
84
+ api.get("#{path.project_name}/#{path.team_name}/_apis/work/boards/#{path.board_name}")["columns"]
78
85
  end
79
86
 
80
87
  private
81
88
 
82
89
  def api
83
90
  Abt::Providers::Devops::Api.new(organization_name: path.organization_name,
84
- project_name: path.project_name,
85
91
  username: config.username_for_organization(path.organization_name),
86
92
  access_token: config.access_token_for_organization(path.organization_name),
87
93
  cli: cli)
@@ -22,7 +22,7 @@ module Abt
22
22
  end
23
23
 
24
24
  def perform
25
- abort("Flags --global and --all cannot be used at the same time") if flags[:global] && flags[:all]
25
+ abort("Flags --global and --all cannot be used together") if flags[:global] && flags[:all]
26
26
 
27
27
  config.clear_local unless flags[:global]
28
28
  config.clear_global if flags[:global] || flags[:all]
@@ -32,7 +32,7 @@ module Abt
32
32
  return
33
33
  end
34
34
 
35
- config.path = Path.from_ids(project_id: project["id"], task_id: task["id"])
35
+ config.path = path
36
36
  end
37
37
 
38
38
  private
@@ -4,7 +4,7 @@ module Abt
4
4
  module Providers
5
5
  module Harvest
6
6
  module Commands
7
- class Track < BaseCommand
7
+ class Track < BaseCommand # rubocop:disable Metrics/ClassLength
8
8
  def self.usage
9
9
  "abt track harvest[:<project-id>/<task-id>] [options]"
10
10
  end
@@ -20,15 +20,21 @@ module Abt
20
20
  ["-s", "--set", "Set specified task as current"],
21
21
  ["-c", "--comment COMMENT", "Override comment"],
22
22
  ["-t", "--time HOURS",
23
- "Set hours. Creates a stopped entry unless used with --running"],
24
- ["-r", "--running", "Used with --time, starts the created time entry"]
23
+ "Track amount of hours, this will create a stopped entry."],
24
+ ["-i", "--since HH:MM",
25
+ "Start entry today at specified time. The computed duration will be deducted from the running entry if one exists."] # rubocop:disable Layout/LineLength
25
26
  ]
26
27
  end
27
28
 
28
29
  def perform
30
+ abort("Flags --time and --since cannot be used together") if flags[:time] && flags[:since]
31
+
29
32
  require_task!
30
33
 
31
- print_task(created_time_entry["project"], created_time_entry["task"])
34
+ maybe_adjust_previous_entry
35
+ entry = create_entry!
36
+
37
+ print_task(entry["project"], entry["task"])
32
38
 
33
39
  maybe_override_current_task
34
40
  rescue Abt::HttpError::HttpError => _e
@@ -37,31 +43,41 @@ module Abt
37
43
 
38
44
  private
39
45
 
40
- def created_time_entry
41
- @created_time_entry ||= create_time_entry
46
+ def create_entry!
47
+ result = api.post("time_entries", Oj.dump(entry_data, mode: :json))
48
+ api.patch("time_entries/#{result['id']}/restart") if flags.key?(:since)
49
+ result
42
50
  end
43
51
 
44
- def create_time_entry
45
- body = time_entry_data
52
+ def maybe_adjust_previous_entry
53
+ return unless flags.key?(:since)
54
+ return unless since_flag_duration # Ensure --since flag is valid before fetching data
55
+ return unless previous_entry
56
+
57
+ adjust_previous_entry
58
+ end
46
59
 
47
- result = api.post("time_entries", Oj.dump(body, mode: :json))
60
+ def adjust_previous_entry
61
+ updated_hours = previous_entry["hours"] - since_flag_duration
62
+ abort("Cannot adjust previous entry to a negative duration") if updated_hours <= 0
48
63
 
49
- api.patch("time_entries/#{result['id']}/restart") if flags.key?(:time) && flags[:running]
64
+ api.patch("time_entries/#{previous_entry['id']}", Oj.dump({ hours: updated_hours }, mode: :json))
50
65
 
51
- result
66
+ subtracted_minutes = (since_flag_duration * 60).round
67
+ warn("~#{subtracted_minutes} minute(s) subtracted from previous entry")
52
68
  end
53
69
 
54
- def time_entry_data
55
- body = time_entry_base_data
70
+ def entry_data
71
+ body = entry_base_data
56
72
 
57
73
  maybe_add_external_link(body)
58
74
  maybe_add_comment(body)
59
- maybe_add_time(body)
75
+ maybe_add_hours(body)
60
76
 
61
77
  body
62
78
  end
63
79
 
64
- def time_entry_base_data
80
+ def entry_base_data
65
81
  {
66
82
  project_id: project_id,
67
83
  task_id: task_id,
@@ -74,8 +90,8 @@ module Abt
74
90
  if external_link_data
75
91
  warn(<<~TXT)
76
92
  Linking to:
77
- #{external_link_data[:notes]}
78
- #{external_link_data[:external_reference][:permalink]}
93
+ #{external_link_data[:notes]}
94
+ #{external_link_data[:external_reference][:permalink]}
79
95
  TXT
80
96
  body.merge!(external_link_data)
81
97
  else
@@ -83,38 +99,40 @@ module Abt
83
99
  end
84
100
  end
85
101
 
86
- def maybe_add_comment(body)
87
- body[:notes] = flags[:comment] if flags.key?(:comment)
88
- body[:notes] ||= cli.prompt.text("Fill in comment (optional)")
89
- end
90
-
91
- def maybe_add_time(body)
92
- body[:hours] = flags[:time] if flags.key?(:time)
93
- end
94
-
95
102
  def external_link_data
96
103
  return @external_link_data if instance_variable_defined?(:@external_link_data)
104
+ return @external_link_data = nil if link_data_lines.empty?
97
105
 
98
- lines = fetch_link_data_lines
99
-
100
- return @external_link_data = nil if lines.empty?
101
-
102
- if lines.length > 1
106
+ if link_data_lines.length > 1
103
107
  abort("Got reference data from multiple scheme providers, only one is supported at a time")
104
108
  end
105
109
 
106
- @external_link_data = Oj.load(lines.first, symbol_keys: true)
110
+ @external_link_data = Oj.load(link_data_lines.first, symbol_keys: true)
107
111
  end
108
112
 
109
- def fetch_link_data_lines
110
- other_aris = cli.aris - [ari]
111
- return [] if other_aris.empty?
113
+ def link_data_lines
114
+ @link_data_lines ||= begin
115
+ other_aris = cli.aris - [ari]
116
+ other_aris.map do |other_ari|
117
+ input = StringIO.new(other_ari.to_s)
118
+ output = StringIO.new
119
+ Abt::Cli.new(argv: ["harvest-time-entry-data"], output: output, input: input).perform
120
+ output.string.chomp
121
+ end.reject(&:empty?)
122
+ end
123
+ end
112
124
 
113
- input = StringIO.new(other_aris.to_s)
114
- output = StringIO.new
115
- Abt::Cli.new(argv: ["harvest-time-entry-data"], output: output, input: input).perform
125
+ def maybe_add_comment(body)
126
+ body[:notes] = flags[:comment] if flags.key?(:comment)
127
+ body[:notes] ||= cli.prompt.text("Fill in comment (optional)")
128
+ end
116
129
 
117
- output.string.strip.lines
130
+ def maybe_add_hours(body)
131
+ if flags[:time]
132
+ body[:hours] = flags[:time]
133
+ elsif flags[:since]
134
+ body[:hours] = since_flag_duration
135
+ end
118
136
  end
119
137
 
120
138
  def maybe_override_current_task
@@ -125,6 +143,21 @@ module Abt
125
143
  config.path = path
126
144
  warn("Current task updated")
127
145
  end
146
+
147
+ def since_flag_duration
148
+ @since_flag_duration ||= begin
149
+ since_hours = HarvestHelpers.decimal_hours_from_string(flags[:since])
150
+ now_hours = HarvestHelpers.decimal_hours_from_string(Time.now.strftime("%T"))
151
+
152
+ abort("Specified \"since\" time (#{flags[:since]}) is in the future") if now_hours <= since_hours
153
+
154
+ now_hours - since_hours
155
+ end
156
+ end
157
+
158
+ def previous_entry
159
+ @previous_entry ||= api.get_paged("time_entries", is_running: true, user_id: config.user_id).first
160
+ end
128
161
  end
129
162
  end
130
163
  end
@@ -15,7 +15,7 @@ module Abt
15
15
  end
16
16
 
17
17
  def path
18
- Path.new(local_available? && git["path"] || cli.directory_config.dig("harvest", "path") || "")
18
+ Path.new((local_available? && git["path"]) || cli.directory_config.dig("harvest", "path") || "")
19
19
  end
20
20
 
21
21
  def path=(new_path)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Abt
4
+ module Providers
5
+ module Harvest
6
+ class HarvestHelpers
7
+ class << self
8
+ HOURS_REGEX = /(?<hours>\d+)/.freeze
9
+ MINUTES_REGEX = /(?<minutes>[0-5][0-9])/.freeze
10
+ SECONDS_REGEX = /(?<seconds>[0-5][0-9])/.freeze
11
+ TIME_REGEX = /^#{HOURS_REGEX}:#{MINUTES_REGEX}(?::#{SECONDS_REGEX})?$/.freeze
12
+
13
+ def decimal_hours_from_string(hh_mm_ss)
14
+ match = TIME_REGEX.match(hh_mm_ss)
15
+ raise Abt::Cli::Abort, "Invalid time: #{hh_mm_ss}, supported formats are: HH:MM, HH:MM:SS" if match.nil?
16
+
17
+ match[:hours].to_i +
18
+ (match[:minutes].to_i / 60.0) +
19
+ (match[:seconds].to_i / 60.0 / 60.0)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -29,7 +29,7 @@ module Abt
29
29
  def call
30
30
  task = cli.prompt.choice("Select a task from #{project['name']}", tasks)
31
31
 
32
- path_with_task = Path.new([path, task["id"]].join("/"))
32
+ path_with_task = Path.from_ids(project_id: path.project_id, task_id: task["id"])
33
33
 
34
34
  Result.new(task: task, path: path_with_task)
35
35
  end
data/lib/abt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Abt
4
- VERSION = "0.0.28"
4
+ VERSION = "0.0.32"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abt-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.28
4
+ version: 0.0.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jesper Sørensen
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-03-28 00:00:00.000000000 Z
11
+ date: 2021-12-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-inflector
@@ -66,7 +66,7 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.0'
69
- description:
69
+ description:
70
70
  email:
71
71
  - js@abtion.com
72
72
  executables:
@@ -118,7 +118,6 @@ files:
118
118
  - "./lib/abt/providers/devops.rb"
119
119
  - "./lib/abt/providers/devops/api.rb"
120
120
  - "./lib/abt/providers/devops/base_command.rb"
121
- - "./lib/abt/providers/devops/commands/boards.rb"
122
121
  - "./lib/abt/providers/devops/commands/branch_name.rb"
123
122
  - "./lib/abt/providers/devops/commands/clear.rb"
124
123
  - "./lib/abt/providers/devops/commands/current.rb"
@@ -148,6 +147,7 @@ files:
148
147
  - "./lib/abt/providers/harvest/commands/track.rb"
149
148
  - "./lib/abt/providers/harvest/commands/write_config.rb"
150
149
  - "./lib/abt/providers/harvest/configuration.rb"
150
+ - "./lib/abt/providers/harvest/harvest_helpers.rb"
151
151
  - "./lib/abt/providers/harvest/path.rb"
152
152
  - "./lib/abt/providers/harvest/services/project_picker.rb"
153
153
  - "./lib/abt/providers/harvest/services/task_picker.rb"
@@ -159,7 +159,8 @@ licenses:
159
159
  metadata:
160
160
  homepage_uri: https://github.com/abtion/abt
161
161
  source_code_uri: https://github.com/abtion/abt
162
- post_install_message:
162
+ rubygems_mfa_required: 'true'
163
+ post_install_message:
163
164
  rdoc_options: []
164
165
  require_paths:
165
166
  - lib
@@ -174,8 +175,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
174
175
  - !ruby/object:Gem::Version
175
176
  version: '0'
176
177
  requirements: []
177
- rubygems_version: 3.1.4
178
- signing_key:
178
+ rubygems_version: 3.2.15
179
+ signing_key:
179
180
  specification_version: 4
180
181
  summary: Versatile scripts
181
182
  test_files: []
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Abt
4
- module Providers
5
- module Devops
6
- module Commands
7
- class Boards < BaseCommand
8
- def self.usage
9
- "abt boards devops"
10
- end
11
-
12
- def self.description
13
- "List all boards - useful for piping into grep etc"
14
- end
15
-
16
- def perform
17
- prompt_project! unless project_name
18
-
19
- boards.map do |board|
20
- print_board(organization_name, project_name, board)
21
- end
22
- end
23
-
24
- private
25
-
26
- def boards
27
- @boards ||= api.get_paged("work/boards")
28
- end
29
- end
30
- end
31
- end
32
- end
33
- end