sfctl 0.0.2 → 1.0.1

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