abt-cli 0.0.28 → 0.0.32

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