sfctl 0.0.2 → 1.0.1

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.
@@ -4,8 +4,9 @@ require 'tty-prompt'
4
4
  require 'tty-spinner'
5
5
  require 'tty-table'
6
6
  require_relative '../../command'
7
- require_relative '../../starfish'
8
- require_relative '../../toggl'
7
+ require_relative '../../starfish/client'
8
+ require_relative '../../toggl/sync'
9
+ require_relative '../../harvest/sync'
9
10
 
10
11
  module Sfctl
11
12
  module Commands
@@ -47,6 +48,9 @@ module Sfctl
47
48
 
48
49
  def assignments_to_sync
49
50
  assignments = assignments_from_connections
51
+
52
+ return assignments if @options['all']
53
+
50
54
  assignment_id = select_assignment(assignments)
51
55
 
52
56
  return assignments if assignment_id == 'all'
@@ -67,37 +71,46 @@ module Sfctl
67
71
  list.each do |assignment|
68
72
  assignment_id = assignment['id'].to_s
69
73
  connection = read_link_config['connections'].select { |c| c == assignment_id }
70
-
71
- if connection.empty?
72
- output.puts @pastel.red("Unable to find a connection for assignment \"#{assignment['name']}\"")
73
- next
74
- end
75
-
76
74
  sync(output, assignment, connection[assignment_id])
77
75
  end
78
76
  end
79
77
 
80
- def sync(output, assignment, connection)
81
- case connection['provider']
82
- when TOGGL_PROVIDER
83
- sync_with_toggl!(output, assignment, connection)
84
- end
85
- end
86
-
87
- def sync_with_toggl!(output, assignment, connection) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
78
+ def sync(output, assignment, connection) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
88
79
  output.puts "Synchronizing: #{@pastel.cyan("[#{assignment['name']} / #{assignment['service']}]")}"
89
80
 
90
- success, next_report = Starfish.next_report(@options['starfish-host'], access_token, assignment['id'])
81
+ success, next_report = Starfish::Client.next_report(@options['starfish-host'], access_token, assignment['id'])
91
82
 
92
83
  print_no_next_reporting_segment(output) && return if !success || next_report.empty?
93
84
 
94
- time_entries = load_data_from_toggl(output, next_report, connection)
85
+ time_entries = load_time_entries(output, next_report, connection)
95
86
 
96
87
  print_dry_run_enabled(output) && return if @options['dry_run']
97
88
 
98
89
  print_report_contains_data(output, next_report) && return if touchy?(next_report)
99
90
 
100
- uploading_to_starfish(output, assignment, time_entries)
91
+ uploading_to_starfish(output, assignment, time_entries, connection)
92
+ end
93
+
94
+ def report_interval(record)
95
+ start_date = Date.parse("#{record['year']}-#{record['month']}-01")
96
+ end_date = start_date.next_month.prev_day
97
+ [start_date, end_date]
98
+ end
99
+
100
+ def load_time_entries(output, next_report, connection)
101
+ output.puts "Next Report: #{@pastel.cyan(report_name(next_report))}"
102
+ next_report_interval = report_interval(next_report)
103
+
104
+ case connection['provider']
105
+ when TOGGL_PROVIDER
106
+ Toggl::Sync.load_data(
107
+ output, connection, read_link_config['providers'][TOGGL_PROVIDER], @pastel, next_report_interval
108
+ )
109
+ when HARVEST_PROVIDER
110
+ Harvest::Sync.load_data(
111
+ output, connection, read_link_config['providers'][HARVEST_PROVIDER], @pastel, next_report_interval
112
+ )
113
+ end
101
114
  end
102
115
 
103
116
  def touchy?(next_report)
@@ -130,93 +143,21 @@ module Sfctl
130
143
  true
131
144
  end
132
145
 
133
- def load_data_from_toggl(output, next_report, connection)
134
- output.puts "Next Report: #{@pastel.cyan(report_name(next_report))}"
135
-
136
- spinner = TTY::Spinner.new("Loaded data from #{TOGGL_PROVIDER}: [:spinner]", format: :dots)
137
- spinner.auto_spin
138
-
139
- time_entries = get_toggle_time_entries(next_report, connection)
140
-
141
- spinner.success(@pastel.green('Done'))
142
-
143
- table = TTY::Table.new %w[Date Comment Time], time_entries_table_rows(time_entries)
144
- output.puts
145
- output.print table.render(:unicode, padding: [0, 1], alignments: %i[left left right])
146
- output.puts
147
- output.puts
148
-
149
- time_entries['data']
150
- end
151
-
152
- def time_entries_table_rows(time_entries)
153
- rows = time_entries['data'].sort_by { |te| te['start'] }.map do |te|
154
- [
155
- Date.parse(te['start']).to_s,
156
- te['description'],
157
- "#{humanize_duration(te['dur'])}h"
158
- ]
159
- end
160
- rows.push(['Total:', '', "#{humanize_duration(time_entries['total_grand'])}h"])
161
- rows
162
- end
163
-
164
- def get_toggle_time_entries(next_report, connection)
165
- _success, data = Toggl.time_entries(
166
- read_link_config['providers'][TOGGL_PROVIDER]['access_token'], time_entries_params(next_report, connection)
167
- )
168
-
169
- data
170
- end
171
-
172
- def time_entries_params(next_report, connection)
173
- start_date = Date.parse("#{next_report['year']}-#{next_report['month']}-01")
174
- end_date = start_date.next_month.prev_day
175
- params = {
176
- workspace_id: connection['workspace_id'],
177
- project_ids: connection['project_ids'],
178
- billable: connection['billable'],
179
- rounding: connection['rounding'],
180
- since: start_date.to_s,
181
- until: end_date.to_s
182
- }
183
- params[:task_ids] = connection['task_ids'] if connection['task_ids'].length.positive?
184
- params
185
- end
186
-
187
- def humanize_duration(milliseconds)
188
- return '0' if milliseconds.nil?
189
-
190
- seconds = milliseconds / 1000
191
- minutes = seconds / 60
192
- int = (minutes / 60).ceil
193
- dec = minutes % 60
194
- amount = (dec * 100) / 60
195
- amount = if dec.zero?
196
- ''
197
- elsif amount.to_s.length == 1
198
- ".0#{amount}"
199
- else
200
- ".#{amount}"
201
- end
202
- "#{int}#{amount}"
203
- end
204
-
205
- def assignment_items(time_entries)
206
- time_entries.map do |te|
207
- {
208
- time: humanize_duration(te['dur']).to_f,
209
- date: Date.parse(te['start']).to_s,
210
- comment: te['description']
211
- }
146
+ def assignment_items(time_entries, connection)
147
+ case connection['provider']
148
+ when TOGGL_PROVIDER
149
+ Toggl::Sync.assignment_items(time_entries)
150
+ when HARVEST_PROVIDER
151
+ Harvest::Sync.assignment_items(time_entries, connection)
212
152
  end
213
153
  end
214
154
 
215
- def uploading_to_starfish(output, assignment, time_entries)
155
+ def uploading_to_starfish(output, assignment, time_entries, connection)
216
156
  spinner = TTY::Spinner.new('Uploading to starfish.team: [:spinner]', format: :dots)
217
157
  spinner.auto_spin
218
- success = Starfish.update_next_report(
219
- @options['starfish-host'], access_token, assignment['id'], assignment_items(time_entries)
158
+
159
+ success = Starfish::Client.update_next_report(
160
+ @options['starfish-host'], access_token, assignment['id'], assignment_items(time_entries, connection)
220
161
  )
221
162
  print_upload_results(output, success, spinner)
222
163
  end
@@ -0,0 +1,44 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module Sfctl
5
+ module Harvest
6
+ module Client
7
+ API_V2_PATH = 'api/v2/'.freeze
8
+
9
+ def self.conn(account_id, token)
10
+ raise 'Please set Harvest provider before continue.' if account_id.nil? || token.nil?
11
+
12
+ headers = {
13
+ 'Content-Type' => 'application/json',
14
+ 'Harvest-Account-ID' => account_id,
15
+ 'Authorization' => "Bearer #{token}"
16
+ }
17
+
18
+ Faraday.new(url: "https://api.harvestapp.com/#{API_V2_PATH}", headers: headers) do |builder|
19
+ builder.request :retry
20
+ builder.adapter :net_http
21
+ end
22
+ end
23
+
24
+ def self.parsed_response(response, key)
25
+ [response.status == 200, JSON.parse(response.body)[key]]
26
+ end
27
+
28
+ def self.projects(account_id, token)
29
+ response = conn(account_id, token).get('projects')
30
+ parsed_response(response, 'projects')
31
+ end
32
+
33
+ def self.tasks(account_id, token)
34
+ response = conn(account_id, token).get('tasks')
35
+ parsed_response(response, 'tasks')
36
+ end
37
+
38
+ def self.time_entries(account_id, token, params)
39
+ response = conn(account_id, token).get('time_entries', params)
40
+ parsed_response(response, 'time_entries')
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,80 @@
1
+ require 'tty-spinner'
2
+ require 'tty-table'
3
+ require_relative '../command'
4
+ require_relative './client'
5
+
6
+ module Sfctl
7
+ module Harvest
8
+ module Sync
9
+ def self.load_data(output, connection, harvest_config, pastel, report_interval)
10
+ spinner = TTY::Spinner.new("Loaded data from #{Sfctl::Command::HARVEST_PROVIDER}: [:spinner]", format: :dots)
11
+ spinner.auto_spin
12
+
13
+ time_entries = get_time_entries(connection, harvest_config, report_interval)
14
+
15
+ spinner.success(pastel.green('Done'))
16
+
17
+ table = TTY::Table.new %w[Date Comment Time], time_entries_table_rows(time_entries, connection)
18
+ output.puts
19
+ output.print table.render(:unicode, padding: [0, 1], alignments: %i[left left right])
20
+ output.puts
21
+ output.puts
22
+
23
+ time_entries
24
+ end
25
+
26
+ def self.get_time_entries(connection, harvest_config, report_interval)
27
+ _success, data = Harvest::Client.time_entries(
28
+ harvest_config['account_id'],
29
+ harvest_config['access_token'],
30
+ time_entries_params(connection, report_interval)
31
+ )
32
+
33
+ data
34
+ end
35
+
36
+ def self.hours_field(rounding)
37
+ return 'rounded_hours' if rounding == 'on'
38
+
39
+ 'hours'
40
+ end
41
+
42
+ def self.time_entries_table_rows(time_entries, connection)
43
+ hours_field = hours_field(connection['rounding'])
44
+ rows = time_entries.sort_by { |te| te['spent_date'] }.map do |te|
45
+ [
46
+ te['spent_date'],
47
+ te['notes'],
48
+ "#{te[hours_field]}h"
49
+ ]
50
+ end
51
+ total_grand = time_entries.map { |te| te[hours_field] }.sum
52
+ rows.push(['Total:', '', "#{total_grand}h"])
53
+ rows
54
+ end
55
+
56
+ def self.time_entries_params(connection, report_interval)
57
+ start_date, end_date = report_interval
58
+ params = {
59
+ project_id: connection['project_id'],
60
+ task_id: connection['task_id'],
61
+ from: start_date.to_s,
62
+ to: end_date.to_s
63
+ }
64
+ params[:is_billed] = connection['billable'] == 'yes' unless connection['billable'] == 'both'
65
+ params
66
+ end
67
+
68
+ def self.assignment_items(time_entries, connection)
69
+ hours_field = hours_field(connection['rounding'])
70
+ time_entries.map do |te|
71
+ {
72
+ time: te[hours_field],
73
+ date: te['spent_date'].to_s,
74
+ comment: te['notes']
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,53 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module Sfctl
5
+ module Starfish
6
+ module Client
7
+ def self.conn(endpoint, token)
8
+ raise 'Before continue please pass endpoint and token.' if endpoint.nil? || token.nil?
9
+
10
+ headers = {
11
+ 'Content-Type' => 'application/json',
12
+ 'X-Starfish-Auth' => token
13
+ }
14
+ Faraday.new(url: "#{endpoint}/api/v1", headers: headers) do |builder|
15
+ builder.request :retry
16
+ builder.adapter :net_http
17
+ end
18
+ end
19
+
20
+ def self.parsed_response(response)
21
+ [response.status == 200, JSON.parse(response.body)]
22
+ end
23
+
24
+ def self.check_authorization(endpoint, token)
25
+ response = conn(endpoint, token).get('profile')
26
+ response.status == 200
27
+ end
28
+
29
+ def self.account_info(endpoint, token)
30
+ response = conn(endpoint, token).get('profile')
31
+ parsed_response(response)
32
+ end
33
+
34
+ def self.account_assignments(endpoint, all, token)
35
+ api_conn = conn(endpoint, token)
36
+ response = all ? api_conn.get('assignments?all=1') : api_conn.get('assignments')
37
+ parsed_response(response)
38
+ end
39
+
40
+ def self.next_report(endpoint, token, assignment_id)
41
+ api_conn = conn(endpoint, token)
42
+ response = api_conn.get("assignments/#{assignment_id}/next_report")
43
+ parsed_response(response)
44
+ end
45
+
46
+ def self.update_next_report(endpoint, token, assignment_id, items)
47
+ api_conn = conn(endpoint, token)
48
+ response = api_conn.put("assignments/#{assignment_id}/next_report", JSON.generate(items: items))
49
+ response.status == 204
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,51 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module Sfctl
5
+ module Toggl
6
+ module Client
7
+ DEFAULT_API_PATH = 'api/v8/'.freeze
8
+ REPORTS_API_PATH = 'reports/api/v2/'.freeze
9
+
10
+ def self.conn(token, api = 'default')
11
+ raise 'Please set toggl provider before continue.' if token.nil?
12
+
13
+ api_path = api == 'reports' ? REPORTS_API_PATH : DEFAULT_API_PATH
14
+
15
+ headers = { 'Content-Type' => 'application/json' }
16
+ Faraday.new(url: "https://#{token}:api_token@www.toggl.com/#{api_path}", headers: headers) do |builder|
17
+ builder.request :retry
18
+ builder.adapter :net_http
19
+ end
20
+ end
21
+
22
+ def self.parsed_response(response)
23
+ [response.status == 200, JSON.parse(response.body)]
24
+ end
25
+
26
+ def self.workspaces(token)
27
+ response = conn(token).get('workspaces')
28
+ parsed_response(response)
29
+ end
30
+
31
+ def self.workspace_projects(token, workspace_id)
32
+ response = conn(token).get("workspaces/#{workspace_id}/projects")
33
+ parsed_response(response)
34
+ end
35
+
36
+ def self.project_tasks(token, project_id)
37
+ response = conn(token).get("workspaces/#{project_id}/tasks")
38
+
39
+ return [] if response.body.length.zero?
40
+
41
+ parsed_response(response)
42
+ end
43
+
44
+ def self.time_entries(token, params)
45
+ params[:user_agent] = 'api_test'
46
+ response = conn(token, 'reports').get('details', params)
47
+ parsed_response(response)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,105 @@
1
+ require 'tty-spinner'
2
+ require 'tty-table'
3
+ require_relative '../command'
4
+ require_relative './client'
5
+
6
+ module Sfctl
7
+ module Toggl
8
+ module Sync
9
+ def self.load_data(output, connection, toggl_config, pastel, report_interval)
10
+ spinner = TTY::Spinner.new("Loaded data from #{Sfctl::Command::TOGGL_PROVIDER}: [:spinner]", format: :dots)
11
+ spinner.auto_spin
12
+
13
+ time_entries = get_time_entries(connection, toggl_config, report_interval)
14
+
15
+ spinner.success(pastel.green('Done'))
16
+
17
+ table = TTY::Table.new %w[Date Comment Time], time_entries_table_rows(time_entries)
18
+ output.puts
19
+ output.print table.render(:unicode, padding: [0, 1], alignments: %i[left left right])
20
+ output.puts
21
+ output.puts
22
+
23
+ time_entries
24
+ end
25
+
26
+ def self.time_entries_table_rows(time_entries)
27
+ rows = time_entries.sort_by { |te| te['start'] }.map do |te|
28
+ [
29
+ Date.parse(te['start']).to_s,
30
+ te['description'],
31
+ "#{humanize_duration(te['dur'])}h"
32
+ ]
33
+ end
34
+ total_grand = time_entries.sum { |te| te['dur'] }
35
+ rows.push(['Total:', '', "#{humanize_duration(total_grand)}h"])
36
+ rows
37
+ end
38
+
39
+ def self.get_time_entries(connection, toggl_config, report_interval)
40
+ entries_list = []
41
+
42
+ page = 1
43
+ loop do
44
+ _success, body = Toggl::Client.time_entries(
45
+ toggl_config['access_token'],
46
+ time_entries_params(connection, report_interval, page)
47
+ )
48
+
49
+ entries_list << body['data']
50
+ entries_list.flatten!
51
+ entries_list.compact!
52
+
53
+ break if entries_list.length >= body['total_count']
54
+
55
+ page += 1
56
+ end
57
+
58
+ entries_list
59
+ end
60
+
61
+ def self.time_entries_params(connection, report_interval, page = 1)
62
+ start_date, end_date = report_interval
63
+ params = {
64
+ workspace_id: connection['workspace_id'],
65
+ project_ids: connection['project_ids'],
66
+ billable: connection['billable'],
67
+ rounding: connection['rounding'],
68
+ since: start_date.to_s,
69
+ until: end_date.to_s,
70
+ page: page
71
+ }
72
+ params[:task_ids] = connection['task_ids'] if connection['task_ids'].length.positive?
73
+ params
74
+ end
75
+
76
+ def self.humanize_duration(milliseconds)
77
+ return '0' if milliseconds.nil?
78
+
79
+ seconds = milliseconds / 1000
80
+ minutes = seconds / 60
81
+ int = (minutes / 60).ceil
82
+ dec = minutes % 60
83
+ amount = (dec * 100) / 60
84
+ amount = if dec.zero?
85
+ ''
86
+ elsif amount.to_s.length == 1
87
+ ".0#{amount}"
88
+ else
89
+ ".#{amount}"
90
+ end
91
+ "#{int}#{amount}"
92
+ end
93
+
94
+ def self.assignment_items(time_entries)
95
+ time_entries.map do |te|
96
+ {
97
+ time: humanize_duration(te['dur']).to_f,
98
+ date: Date.parse(te['start']).to_s,
99
+ comment: te['description']
100
+ }
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end