time-tracker-cli 1.0.0

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.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/bin/tt +2 -0
  3. data/bin/tt.rb +274 -0
  4. metadata +46 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a7dc7b43cc2f01591b98eb3f77130bdaf11771f7d11e497cb0d4f8a278b263d5
4
+ data.tar.gz: 1c729c4a857f25f169c8f2015d4090d96f09bd94edf28b9e67fe4d11f1d185a2
5
+ SHA512:
6
+ metadata.gz: d8b965e28022406f347a19903dd949415bffe17c5278d337de26a8437e3d7382a94811385b06c61b2fbdcb69c94733d8e1673bc47720faa54b286fd36c65c99d
7
+ data.tar.gz: 30ed376a126479bde00d72c26937b8a410fddcfea5bced8a3b2d89fdd89a38a42510f45861285b1c1ce202220b48d531849824a3d090cc096c9b00ac9d963ac9
data/bin/tt ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative 'tt.rb'
data/bin/tt.rb ADDED
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # tt.rb - simple time tracker app on the command-line
4
+ #
5
+ # Copyright (c) 2022 Gabor Bata
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person
8
+ # obtaining a copy of this software and associated documentation files
9
+ # (the "Software"), to deal in the Software without restriction,
10
+ # including without limitation the rights to use, copy, modify, merge,
11
+ # publish, distribute, sublicense, and/or sell copies of the Software,
12
+ # and to permit persons to whom the Software is furnished to do so,
13
+ # subject to the following conditions:
14
+ #
15
+ # The above copyright notice and this permission notice shall be
16
+ # included in all copies or substantial portions of the Software.
17
+ #
18
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
22
+ # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
23
+ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
24
+ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ # SOFTWARE.
26
+
27
+ require 'time'
28
+ require 'net/http'
29
+ require 'json'
30
+ require 'base64'
31
+ require 'openssl'
32
+
33
+ class TimeTracker
34
+ TIME_TRACKER_FILE = "#{Dir.home}/time-tracker.csv"
35
+ SEPARATOR = ','
36
+ BREAK_AMOUNT = 0.0 # break included in work time
37
+ REPORT_DAYS = 7
38
+ LIST_ENTRIES = 20
39
+ DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
40
+ UTC_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%L%z'
41
+ TIME_FORMAT = '%H:%M:%S'
42
+ DAY_FORMAT = '%Y-%m-%d (%A)'
43
+
44
+ COLOR_CODES = {
45
+ black: 30,
46
+ red: 31,
47
+ green: 32,
48
+ yellow: 33,
49
+ blue: 34,
50
+ magenta: 35,
51
+ cyan: 36,
52
+ white: 37
53
+ }
54
+
55
+ JIRA = {
56
+ token: ENV['JIRA_API_TOKEN'],
57
+ user: ENV['JIRA_API_USER'],
58
+ host: ENV['JIRA_API_HOST'],
59
+ issue_pattern: /\w+-\d+/
60
+ }
61
+
62
+ def create_jira_worklog(issue, comment, date, duration)
63
+ begin
64
+ raise "Missing Jira environment variables" if JIRA[:token].nil? || JIRA[:user].nil? || JIRA[:host].nil?
65
+ worklog = {
66
+ timeSpentSeconds: duration.to_i,
67
+ comment: {
68
+ type: "doc",
69
+ version: 1,
70
+ content: [
71
+ {
72
+ type: "paragraph",
73
+ content: [
74
+ {
75
+ text: "#{comment.gsub('"', '\\"')}",
76
+ type: "text"
77
+ }
78
+ ]
79
+ }
80
+ ]
81
+ },
82
+ started: "#{Time.strptime(date, DATE_FORMAT).strftime(UTC_DATE_FORMAT)}"
83
+ }
84
+
85
+ url = URI("#{JIRA[:host]}/rest/api/3/issue/#{issue}/worklog")
86
+ puts colorize("POST #{url} [#{comment}, #{date}, #{duration}s]", :cyan)
87
+
88
+ http = Net::HTTP.new(url.host, url.port)
89
+ http.use_ssl = true
90
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
91
+
92
+ request = Net::HTTP::Post.new(url)
93
+ request['Accept'] = 'application/json'
94
+ request['Content-Type'] = 'application/json'
95
+ request['Authorization'] = 'Basic ' + Base64.strict_encode64("#{JIRA[:user]}:#{JIRA[:token]}")
96
+ request.body = JSON.generate(worklog)
97
+
98
+ response = http.request(request)
99
+ puts colorize("Status: #{response.code} - #{response.message}", response.kind_of?(Net::HTTPSuccess) ? :green : :red)
100
+ rescue => e
101
+ puts colorize("ERROR: #{e}", :red)
102
+ end
103
+ end
104
+
105
+ def list_active_jira_issues
106
+ begin
107
+ raise "Missing Jira environment variables" if JIRA[:token].nil? || JIRA[:user].nil? || JIRA[:host].nil?
108
+ url = URI("#{JIRA[:host]}/rest/api/3/search")
109
+ url.query = URI.encode_www_form({jql: 'assignee=currentuser() AND status!=done'})
110
+ puts colorize("GET #{url}", :cyan)
111
+
112
+ http = Net::HTTP.new(url.host, url.port)
113
+ http.use_ssl = true
114
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
115
+
116
+ request = Net::HTTP::Get.new(url)
117
+ request['Accept'] = 'application/json'
118
+ request['Authorization'] = 'Basic ' + Base64.strict_encode64("#{JIRA[:user]}:#{JIRA[:token]}")
119
+ response = http.request(request)
120
+ puts colorize("Status: #{response.code} - #{response.message}", response.kind_of?(Net::HTTPSuccess) ? :green : :red)
121
+
122
+ json = JSON.parse(response.body.force_encoding('ISO-8859-1').encode('UTF-8'))
123
+ puts colorize("Active issues (#{json['issues'].size()})", :yellow)
124
+ json['issues'].each do |issue|
125
+ puts "* #{issue['key']}: #{issue['fields']['summary']} [#{issue['fields']['status']['name']}]"
126
+ end
127
+ rescue => e
128
+ puts colorize("ERROR: #{e}", :red)
129
+ end
130
+ end
131
+
132
+ def read_entries(days = REPORT_DAYS)
133
+ entries = []
134
+ if File.exist?(TIME_TRACKER_FILE)
135
+ t = Time.now
136
+ report_context = (Time.new(t.year, t.month, t.day) - 60.0 ** 2 * 24.0 * days).to_s
137
+ File.open(TIME_TRACKER_FILE, 'r:UTF-8') do |file|
138
+ file.each_line do |line|
139
+ next if line.strip == ''
140
+ entry = line.strip.split(SEPARATOR)
141
+ entries.push(entry) if entry[0] >= report_context
142
+ end
143
+ end
144
+ end
145
+ entries
146
+ end
147
+
148
+ def colorize(text, color)
149
+ "\e[#{COLOR_CODES[color] || 37}m#{text}\e[0m"
150
+ end
151
+
152
+ def execute(command, message)
153
+ if command
154
+ command_array = command.split(SEPARATOR)
155
+ command = command_array.first.downcase.gsub(SEPARATOR, '_')
156
+ message = message.gsub(SEPARATOR, '_') if message
157
+ message = (command_array.drop(1).join(' ').gsub(SEPARATOR, '_') + ' ' + message.to_s).strip
158
+
159
+ # Create weekly report
160
+ if command == 'rep' || command == 'report'
161
+ report = {}
162
+ entries = read_entries
163
+ (0...entries.size).to_a.each do |idx|
164
+ date = Time.strptime(entries[idx][0], DATE_FORMAT).strftime(DAY_FORMAT)
165
+ activity = entries[idx][1]
166
+ report[date] = {} if report[date].nil?
167
+ if activity != 'stop'
168
+ report[date][activity] = 0.0 if report[date][activity].nil?
169
+ start_time = Time.strptime(entries[idx][0], DATE_FORMAT)
170
+ end_time = idx < entries.size - 1 ? Time.strptime(entries[idx + 1][0], DATE_FORMAT) : Time.now
171
+ report[date][activity] += ([end_time - start_time, 0.0].max) / 60.0 ** 2
172
+ end
173
+ end
174
+ puts colorize("#{' ' * 8}Report for the last #{REPORT_DAYS} days\n", :white)
175
+
176
+ week_total = 0.0
177
+ now = Date.today
178
+ week_start_day = (now - (now.wday - 1) % 7).strftime(DAY_FORMAT) # Monday
179
+ report.to_a.last(REPORT_DAYS).each do |day, report|
180
+ total = 0.0
181
+ correction = 0.0
182
+ puts colorize("#{' ' * 8}#{day}", day == week_start_day ? :red : :cyan)
183
+ report.each do |activity, hour|
184
+ total += hour
185
+ correction = [hour - BREAK_AMOUNT, 0].max if activity == 'break'
186
+ formatted = Time.at(hour * 60.0 ** 2).utc.strftime(TIME_FORMAT)
187
+ puts colorize("#{activity.rjust(20)}: #{formatted} (#{sprintf('%2.3f', hour)})", :white)
188
+ end
189
+ formatted = Time.at((total - correction) * 60.0 ** 2).utc.strftime(TIME_FORMAT)
190
+ puts colorize("#{'total'.rjust(20)}: #{formatted} (#{sprintf('%2.3f', total - correction)}) [excl. break #{sprintf('%2.3f', correction)}]\n", :yellow)
191
+ week_total = week_total + total - correction if day >= week_start_day
192
+ end
193
+ puts colorize("#{' ' * 10}Week total: #{sprintf('%2.3f', week_total)}\n", :green)
194
+
195
+ # List time tracker entries
196
+ elsif command == 'ls' || command == 'list'
197
+ puts colorize("List of the last #{LIST_ENTRIES} entries#{message.empty? ? '' : ' [filter: ' + message + ']'}\n", :cyan)
198
+ entries = read_entries.last(LIST_ENTRIES)
199
+ (0...entries.size).to_a.each do |idx|
200
+ date = entries[idx][0]
201
+ activity = entries[idx][1]
202
+ msg = entries[idx][2]
203
+ next if !activity.to_s.include?(message) && !msg.to_s.include?(message)
204
+ if activity != 'stop'
205
+ start_time = Time.strptime(entries[idx][0], DATE_FORMAT)
206
+ end_time = idx < entries.size - 1 ? Time.strptime(entries[idx + 1][0], DATE_FORMAT) : Time.now
207
+ puts colorize([
208
+ date, activity, msg || 'n/a', Time.at([end_time - start_time, 0.0].max).utc.strftime(TIME_FORMAT)
209
+ ].map { |e| e.ljust(30) }.join(' '), activity == 'break' ? :blue : :white)
210
+ else
211
+ puts colorize([
212
+ date, activity, msg || 'n/a', '-' * 8
213
+ ].map { |e| e.ljust(30) }.join(' '), :red)
214
+ end
215
+ end
216
+
217
+ # Edit entry with text editor
218
+ elsif command == 'edit'
219
+ system('nano', TIME_TRACKER_FILE)
220
+
221
+ # Show active jira issues
222
+ elsif command == 'active'
223
+ list_active_jira_issues
224
+
225
+ # Upload worklog to jira
226
+ elsif command == 'upload'
227
+ entries = read_entries(message.to_i)
228
+ (0...entries.size).to_a.each do |idx|
229
+ date = entries[idx][0]
230
+ activity = entries[idx][1]
231
+ msg = entries[idx][2]
232
+ if activity != 'stop'
233
+ start_time = Time.strptime(entries[idx][0], DATE_FORMAT)
234
+ end_time = idx < entries.size - 1 ? Time.strptime(entries[idx + 1][0], DATE_FORMAT) : Time.now
235
+ if activity.match(JIRA[:issue_pattern])
236
+ create_jira_worklog(activity, msg || 'n/a', date, [end_time - start_time, 0.0].max)
237
+ end
238
+ end
239
+ end
240
+
241
+ # Record an activity
242
+ else
243
+ puts colorize("Record activity [#{command}] with message [#{message.empty? ? 'n/a' : message}]", :green)
244
+ time = Time.now.strftime(DATE_FORMAT)
245
+ File.open(TIME_TRACKER_FILE, 'a:UTF-8') do |file|
246
+ file.write("#{time}#{SEPARATOR}#{command}#{ message ? SEPARATOR + message : ''}\n")
247
+ end
248
+ end
249
+
250
+ else
251
+ puts "
252
+ #{colorize('Usage:', :cyan)}
253
+ tt [command] execute the given command
254
+ tt [activity] [message] start tracking time of a given activity, with an optional message
255
+
256
+ #{colorize('Commands:', :cyan)}
257
+ rep|report show report for the last #{REPORT_DAYS} days, grouped by activity
258
+ ls|list [filter] list the last #{LIST_ENTRIES} entries
259
+ break [message] start break activity
260
+ stop [message] stop tracking time of the current activity
261
+ edit edit entries in text editor
262
+ active list active jira issues
263
+ upload [from day offset] upload worklog for jira issues (default offset = 0, which means only today)
264
+
265
+ To work with Jira, please provide JIRA_API_USER, JIRA_API_TOKEN, JIRA_API_HOST environment variables.
266
+ To create an API token please visit: https://id.atlassian.com/manage-profile/security/api-tokens
267
+
268
+ An activity is considered a Jira ticket if matches the #{JIRA[:issue_pattern].inspect} pattern.
269
+ "
270
+ end
271
+ end
272
+ end
273
+
274
+ TimeTracker.new.execute(ARGV[0], ARGV[1])
metadata ADDED
@@ -0,0 +1,46 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: time-tracker-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Gabor Bata
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-08-24 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ executables:
16
+ - tt.rb
17
+ - tt
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - bin/tt
22
+ - bin/tt.rb
23
+ homepage: https://github.com/gaborbata/tt
24
+ licenses:
25
+ - MIT
26
+ metadata: {}
27
+ post_install_message:
28
+ rdoc_options: []
29
+ require_paths:
30
+ - lib
31
+ required_ruby_version: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 2.6.0
36
+ required_rubygems_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ requirements: []
42
+ rubygems_version: 3.3.7
43
+ signing_key:
44
+ specification_version: 4
45
+ summary: simple time tracker app on the command-line
46
+ test_files: []