sfctl 0.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.
- checksums.yaml +7 -0
- data/.buildkite/hooks/pre-command +5 -0
- data/.buildkite/pipeline.yml +9 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +76 -0
- data/.tool-versions +1 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +40 -0
- data/Gemfile.lock +186 -0
- data/README.md +276 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/sfctl +18 -0
- data/lib/sfctl.rb +5 -0
- data/lib/sfctl/cli.rb +44 -0
- data/lib/sfctl/command.rb +166 -0
- data/lib/sfctl/commands/account.rb +34 -0
- data/lib/sfctl/commands/account/assignments.rb +52 -0
- data/lib/sfctl/commands/account/info.rb +40 -0
- data/lib/sfctl/commands/auth.rb +35 -0
- data/lib/sfctl/commands/auth/bye.rb +26 -0
- data/lib/sfctl/commands/auth/init.rb +53 -0
- data/lib/sfctl/commands/time.rb +52 -0
- data/lib/sfctl/commands/time/connections.rb +33 -0
- data/lib/sfctl/commands/time/connections/add.rb +84 -0
- data/lib/sfctl/commands/time/connections/get.rb +53 -0
- data/lib/sfctl/commands/time/init.rb +23 -0
- data/lib/sfctl/commands/time/providers.rb +44 -0
- data/lib/sfctl/commands/time/providers/get.rb +41 -0
- data/lib/sfctl/commands/time/providers/set.rb +58 -0
- data/lib/sfctl/commands/time/providers/unset.rb +43 -0
- data/lib/sfctl/commands/time/sync.rb +215 -0
- data/lib/sfctl/starfish.rb +51 -0
- data/lib/sfctl/templates/.gitkeep +1 -0
- data/lib/sfctl/toggl.rb +27 -0
- data/lib/sfctl/version.rb +3 -0
- data/scripts/test.sh +8 -0
- data/sfctl.gemspec +28 -0
- metadata +84 -0
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'pastel'
|
2
|
+
require 'tty-prompt'
|
3
|
+
require_relative '../../../command'
|
4
|
+
|
5
|
+
module Sfctl
|
6
|
+
module Commands
|
7
|
+
class Time
|
8
|
+
class Providers
|
9
|
+
class Unset < Sfctl::Command
|
10
|
+
def initialize(options)
|
11
|
+
@options = options
|
12
|
+
@pastel = Pastel.new(enabled: !@options['no-color'])
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(output: $stdout)
|
16
|
+
read_link_config
|
17
|
+
|
18
|
+
prompt = ::TTY::Prompt.new
|
19
|
+
provider = prompt.select('Unsetting:', PROVIDERS_LIST)
|
20
|
+
|
21
|
+
if config.fetch(:providers, provider).nil?
|
22
|
+
output.puts @pastel.yellow("[#{provider}] is already deleted from configuration.")
|
23
|
+
elsif prompt.yes?('Do you want to remove the delete the configuration?')
|
24
|
+
remove_provider!(provider, output)
|
25
|
+
end
|
26
|
+
rescue TTY::Config::ReadError
|
27
|
+
output.puts @pastel.yellow('Please initialize time before continue.')
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def remove_provider!(provider, output)
|
33
|
+
providers = config.fetch(:providers)
|
34
|
+
providers.delete(provider)
|
35
|
+
config.set(:providers, value: providers)
|
36
|
+
save_link_config!
|
37
|
+
output.puts @pastel.green("Configuration for provider [#{provider}] was successfully deleted.")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,215 @@
|
|
1
|
+
require 'date'
|
2
|
+
require 'pastel'
|
3
|
+
require 'tty-prompt'
|
4
|
+
require 'tty-spinner'
|
5
|
+
require 'tty-table'
|
6
|
+
require_relative '../../command'
|
7
|
+
require_relative '../../starfish'
|
8
|
+
require_relative '../../toggl'
|
9
|
+
|
10
|
+
module Sfctl
|
11
|
+
module Commands
|
12
|
+
class Time
|
13
|
+
class Sync < Sfctl::Command # rubocop:disable Metrics/ClassLength
|
14
|
+
def initialize(options)
|
15
|
+
@options = options
|
16
|
+
@pastel = Pastel.new(enabled: !@options['no-color'])
|
17
|
+
@prompt = ::TTY::Prompt.new
|
18
|
+
end
|
19
|
+
|
20
|
+
def execute(output: $stdout)
|
21
|
+
return if !config_present?(output) || !link_config_present?(output)
|
22
|
+
|
23
|
+
success, data = Starfish.account_assignments(@options['starfish-host'], @options['all'], access_token)
|
24
|
+
unless success
|
25
|
+
output.puts @pastel.red('Something went wrong. Unable to fetch assignments')
|
26
|
+
return
|
27
|
+
end
|
28
|
+
|
29
|
+
sync_assignments(output, assignments_to_sync(data['assignments']))
|
30
|
+
rescue ThreadError, JSON::ParserError
|
31
|
+
output.puts @pastel.red('Something went wrong.')
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def assignments_to_sync(assignments)
|
37
|
+
assignment_name = select_assignment(assignments)
|
38
|
+
|
39
|
+
return assignments if assignment_name == 'all'
|
40
|
+
|
41
|
+
assignments.select { |a| a['name'] == assignment_name }.to_a
|
42
|
+
end
|
43
|
+
|
44
|
+
def select_assignment(assignments)
|
45
|
+
@prompt.select('Which assignment do you want to sync?') do |menu|
|
46
|
+
assignments.each do |asmnt|
|
47
|
+
menu.choice name: "#{asmnt['name']} / #{asmnt['service']}", value: asmnt['name']
|
48
|
+
end
|
49
|
+
menu.choice name: 'All', value: 'all'
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def sync_assignments(output, list)
|
54
|
+
list.each do |assignment|
|
55
|
+
assignment_name = assignment['name']
|
56
|
+
connection = read_link_config['connections'].select { |c| c == assignment_name }
|
57
|
+
|
58
|
+
if connection.empty?
|
59
|
+
output.puts @pastel.red("Unable to find a connection for assignment \"#{assignment_name}\"")
|
60
|
+
next
|
61
|
+
end
|
62
|
+
|
63
|
+
sync(output, assignment, connection[assignment_name])
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def sync(output, assignment, connection)
|
68
|
+
case connection['provider']
|
69
|
+
when TOGGL_PROVIDER
|
70
|
+
sync_with_toggl!(output, assignment, connection)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def sync_with_toggl!(output, assignment, connection) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
75
|
+
output.puts "Synchronizing: #{@pastel.cyan("[#{assignment['name']} / #{assignment['service']}]")}"
|
76
|
+
|
77
|
+
success, next_report = Starfish.next_report(@options['starfish-host'], access_token, assignment['id'])
|
78
|
+
|
79
|
+
print_no_next_reporting_segment(output) && return if !success || next_report.empty?
|
80
|
+
|
81
|
+
time_entries = load_data_from_toggl(output, next_report, connection)
|
82
|
+
|
83
|
+
print_dry_run_enabled(output) && return if @options['dry_run']
|
84
|
+
|
85
|
+
print_report_contains_data(output, next_report) && return if touchy?(next_report)
|
86
|
+
|
87
|
+
uploading_to_starfish(output, assignment, time_entries)
|
88
|
+
end
|
89
|
+
|
90
|
+
def touchy?(next_report)
|
91
|
+
@options['touchy'] && next_report['data'] == 'present'
|
92
|
+
end
|
93
|
+
|
94
|
+
def report_name(next_report)
|
95
|
+
"[#{next_report['year']}-#{next_report['month']}]"
|
96
|
+
end
|
97
|
+
|
98
|
+
def print_no_next_reporting_segment(output)
|
99
|
+
message = <<~HEREDOC
|
100
|
+
No next reporting segment on Starfish that accepts time report data, the synchronization will be skipped.
|
101
|
+
HEREDOC
|
102
|
+
output.puts @pastel.red(message)
|
103
|
+
true
|
104
|
+
end
|
105
|
+
|
106
|
+
def print_dry_run_enabled(output)
|
107
|
+
output.puts @pastel.yellow('Dry run enabled. Skipping upload to starfish.team.')
|
108
|
+
output.puts
|
109
|
+
true
|
110
|
+
end
|
111
|
+
|
112
|
+
def print_report_contains_data(output, next_report)
|
113
|
+
output.puts @pastel.yellow(
|
114
|
+
"Report #{report_name(next_report)} contains data. Skipping upload to starfish.team."
|
115
|
+
)
|
116
|
+
output.puts
|
117
|
+
true
|
118
|
+
end
|
119
|
+
|
120
|
+
def load_data_from_toggl(output, next_report, connection)
|
121
|
+
output.puts "Next Report: #{@pastel.cyan(report_name(next_report))}"
|
122
|
+
|
123
|
+
spinner = TTY::Spinner.new("Loaded data from #{TOGGL_PROVIDER}: [:spinner]", format: :dots)
|
124
|
+
spinner.auto_spin
|
125
|
+
|
126
|
+
time_entries = get_toggle_time_entries(next_report, connection)
|
127
|
+
|
128
|
+
spinner.success(@pastel.green('Done'))
|
129
|
+
|
130
|
+
table = TTY::Table.new %w[Date Comment Time], time_entries_table_rows(time_entries)
|
131
|
+
output.puts
|
132
|
+
output.print table.render(:unicode, padding: [0, 1], alignments: %i[left left right])
|
133
|
+
output.puts
|
134
|
+
output.puts
|
135
|
+
|
136
|
+
time_entries
|
137
|
+
end
|
138
|
+
|
139
|
+
def time_entries_table_rows(time_entries)
|
140
|
+
rows = time_entries.map do |te|
|
141
|
+
[
|
142
|
+
Date.parse(te['start']).to_s,
|
143
|
+
te['description'],
|
144
|
+
"#{humanize_duration(te['duration'])}h"
|
145
|
+
]
|
146
|
+
end
|
147
|
+
rows.push(['Total:', '', "#{humanize_duration(time_entries.map { |te| te['duration'] }.sum)}h"])
|
148
|
+
rows
|
149
|
+
end
|
150
|
+
|
151
|
+
def get_toggle_time_entries(next_report, connection)
|
152
|
+
_success, time_entries = Toggl.time_entries(
|
153
|
+
read_link_config['providers'][TOGGL_PROVIDER]['access_token'], time_entries_params(next_report, connection)
|
154
|
+
)
|
155
|
+
unless connection['task_ids'].empty?
|
156
|
+
time_entries.delete_if { |te| !connection['task_ids'].include?(te['id'].to_s) }
|
157
|
+
end
|
158
|
+
|
159
|
+
time_entries
|
160
|
+
end
|
161
|
+
|
162
|
+
def time_entries_params(next_report, connection)
|
163
|
+
start_date = Date.parse("#{next_report['year']}-#{next_report['month']}-01")
|
164
|
+
end_date = start_date.next_month.prev_day
|
165
|
+
{
|
166
|
+
wid: connection['workspace_id'],
|
167
|
+
pid: connection['project_ids'],
|
168
|
+
start_date: start_date.to_datetime.to_s,
|
169
|
+
end_date: "#{end_date}T23:59:59+00:00"
|
170
|
+
}
|
171
|
+
end
|
172
|
+
|
173
|
+
def humanize_duration(seconds)
|
174
|
+
minutes = seconds / 60
|
175
|
+
int = (minutes / 60).ceil
|
176
|
+
dec = minutes % 60
|
177
|
+
amount = (dec * 100) / 60
|
178
|
+
amount = dec.zero? ? '' : ".#{amount}"
|
179
|
+
"#{int}#{amount}"
|
180
|
+
end
|
181
|
+
|
182
|
+
def assignment_items(time_entries)
|
183
|
+
time_entries.map do |te|
|
184
|
+
{
|
185
|
+
time: humanize_duration(te['duration']),
|
186
|
+
date: Date.parse(te['start']).to_s,
|
187
|
+
comment: te['description']
|
188
|
+
}
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def uploading_to_starfish(output, assignment, time_entries)
|
193
|
+
spinner = TTY::Spinner.new('Uploading to starfish.team: [:spinner]', format: :dots)
|
194
|
+
spinner.auto_spin
|
195
|
+
success = Starfish.update_next_report(
|
196
|
+
@options['starfish-host'], access_token, assignment['id'], assignment_items(time_entries)
|
197
|
+
)
|
198
|
+
print_upload_results(output, success, spinner)
|
199
|
+
end
|
200
|
+
|
201
|
+
def print_upload_results(output, success, spinner)
|
202
|
+
if success
|
203
|
+
spinner.success(@pastel.green('Done'))
|
204
|
+
else
|
205
|
+
spinner.error
|
206
|
+
output.puts @pastel.red('Something went wrong. Unable to upload time entries to starfish.team')
|
207
|
+
end
|
208
|
+
output.puts
|
209
|
+
output.puts
|
210
|
+
true
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Sfctl
|
5
|
+
module Starfish
|
6
|
+
def self.conn(endpoint, token)
|
7
|
+
raise 'Before continue please pass endpoint and token.' if endpoint.nil? || token.nil?
|
8
|
+
|
9
|
+
headers = {
|
10
|
+
'Content-Type' => 'application/json',
|
11
|
+
'X-Starfish-Auth' => token
|
12
|
+
}
|
13
|
+
Faraday.new(url: "#{endpoint}/api/v1", headers: headers) do |builder|
|
14
|
+
builder.request :retry
|
15
|
+
builder.adapter :net_http
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.parsed_response(response)
|
20
|
+
[response.status == 200, JSON.parse(response.body)]
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.check_authorization(endpoint, token)
|
24
|
+
response = conn(endpoint, token).get('profile')
|
25
|
+
response.status == 200
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.account_info(endpoint, token)
|
29
|
+
response = conn(endpoint, token).get('profile')
|
30
|
+
parsed_response(response)
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.account_assignments(endpoint, all, token)
|
34
|
+
api_conn = conn(endpoint, token)
|
35
|
+
response = all ? api_conn.get('assignments?all=1') : api_conn.get('assignments')
|
36
|
+
parsed_response(response)
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.next_report(endpoint, token, assignment_id)
|
40
|
+
api_conn = conn(endpoint, token)
|
41
|
+
response = api_conn.get("assignments/#{assignment_id}/next_report")
|
42
|
+
parsed_response(response)
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.update_next_report(endpoint, token, assignment_id, items)
|
46
|
+
api_conn = conn(endpoint, token)
|
47
|
+
response = api_conn.put("assignments/#{assignment_id}/next_report", JSON.generate(items: items))
|
48
|
+
response.status == 204
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
#
|
data/lib/sfctl/toggl.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Sfctl
|
5
|
+
module Toggl
|
6
|
+
def self.conn(token)
|
7
|
+
raise 'Please set toggl provider before continue.' if token.nil?
|
8
|
+
|
9
|
+
headers = {
|
10
|
+
'Content-Type' => 'application/json'
|
11
|
+
}
|
12
|
+
Faraday.new(url: "https://#{token}:api_token@www.toggl.com/api/v8/", headers: headers) do |builder|
|
13
|
+
builder.request :retry
|
14
|
+
builder.adapter :net_http
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.parsed_response(response)
|
19
|
+
[response.status == 200, JSON.parse(response.body)]
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.time_entries(token, params)
|
23
|
+
response = conn(token).get('time_entries', params)
|
24
|
+
parsed_response(response)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/scripts/test.sh
ADDED
data/sfctl.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
require_relative 'lib/sfctl/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = 'sfctl'
|
5
|
+
spec.license = 'MIT'
|
6
|
+
spec.version = Sfctl::VERSION
|
7
|
+
spec.authors = ['Serhii Rudik', 'Markus Kuhnt']
|
8
|
+
spec.email = ['serhii@starfish.team']
|
9
|
+
|
10
|
+
spec.summary = 'sfctl is a command line interface for the Starfish API.'
|
11
|
+
# spec.homepage = ''
|
12
|
+
spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
|
13
|
+
|
14
|
+
# spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'"
|
15
|
+
|
16
|
+
# spec.metadata['homepage_uri'] = spec.homepage
|
17
|
+
# spec.metadata['source_code_uri'] = ''
|
18
|
+
# spec.metadata['changelog_uri'] = ''
|
19
|
+
|
20
|
+
# Specify which files should be added to the gem when it is released.
|
21
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
22
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
23
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
24
|
+
end
|
25
|
+
spec.bindir = 'exe'
|
26
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
27
|
+
spec.require_paths = ['lib']
|
28
|
+
end
|
metadata
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sfctl
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Serhii Rudik
|
8
|
+
- Markus Kuhnt
|
9
|
+
autorequire:
|
10
|
+
bindir: exe
|
11
|
+
cert_chain: []
|
12
|
+
date: 2020-04-08 00:00:00.000000000 Z
|
13
|
+
dependencies: []
|
14
|
+
description:
|
15
|
+
email:
|
16
|
+
- serhii@starfish.team
|
17
|
+
executables:
|
18
|
+
- sfctl
|
19
|
+
extensions: []
|
20
|
+
extra_rdoc_files: []
|
21
|
+
files:
|
22
|
+
- ".buildkite/hooks/pre-command"
|
23
|
+
- ".buildkite/pipeline.yml"
|
24
|
+
- ".gitignore"
|
25
|
+
- ".rspec"
|
26
|
+
- ".rubocop.yml"
|
27
|
+
- ".tool-versions"
|
28
|
+
- CODE_OF_CONDUCT.md
|
29
|
+
- Gemfile
|
30
|
+
- Gemfile.lock
|
31
|
+
- README.md
|
32
|
+
- Rakefile
|
33
|
+
- bin/console
|
34
|
+
- bin/setup
|
35
|
+
- exe/sfctl
|
36
|
+
- lib/sfctl.rb
|
37
|
+
- lib/sfctl/cli.rb
|
38
|
+
- lib/sfctl/command.rb
|
39
|
+
- lib/sfctl/commands/account.rb
|
40
|
+
- lib/sfctl/commands/account/assignments.rb
|
41
|
+
- lib/sfctl/commands/account/info.rb
|
42
|
+
- lib/sfctl/commands/auth.rb
|
43
|
+
- lib/sfctl/commands/auth/bye.rb
|
44
|
+
- lib/sfctl/commands/auth/init.rb
|
45
|
+
- lib/sfctl/commands/time.rb
|
46
|
+
- lib/sfctl/commands/time/connections.rb
|
47
|
+
- lib/sfctl/commands/time/connections/add.rb
|
48
|
+
- lib/sfctl/commands/time/connections/get.rb
|
49
|
+
- lib/sfctl/commands/time/init.rb
|
50
|
+
- lib/sfctl/commands/time/providers.rb
|
51
|
+
- lib/sfctl/commands/time/providers/get.rb
|
52
|
+
- lib/sfctl/commands/time/providers/set.rb
|
53
|
+
- lib/sfctl/commands/time/providers/unset.rb
|
54
|
+
- lib/sfctl/commands/time/sync.rb
|
55
|
+
- lib/sfctl/starfish.rb
|
56
|
+
- lib/sfctl/templates/.gitkeep
|
57
|
+
- lib/sfctl/toggl.rb
|
58
|
+
- lib/sfctl/version.rb
|
59
|
+
- scripts/test.sh
|
60
|
+
- sfctl.gemspec
|
61
|
+
homepage:
|
62
|
+
licenses:
|
63
|
+
- MIT
|
64
|
+
metadata: {}
|
65
|
+
post_install_message:
|
66
|
+
rdoc_options: []
|
67
|
+
require_paths:
|
68
|
+
- lib
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: 2.3.0
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
75
|
+
requirements:
|
76
|
+
- - ">="
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: '0'
|
79
|
+
requirements: []
|
80
|
+
rubygems_version: 3.1.2
|
81
|
+
signing_key:
|
82
|
+
specification_version: 4
|
83
|
+
summary: sfctl is a command line interface for the Starfish API.
|
84
|
+
test_files: []
|