ftg 2.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.
data/lib/ftg.rb ADDED
@@ -0,0 +1,316 @@
1
+ require_relative './colors'
2
+ require_relative './utils'
3
+ require_relative './ftg_options'
4
+ require_relative './ftg_logger'
5
+ require 'json'
6
+ require 'date'
7
+
8
+ class Ftg
9
+ include FtgOptions
10
+
11
+ def initialize
12
+ @commands = {
13
+ help: { fn: -> { help }, aliases: [] },
14
+ gtfo: { fn: -> {
15
+ gtfo(day_option, get_option(['--restore']), get_option(['--reset'])) # option union
16
+ }, aliases: [:recap, :leave, :wrap_up] },
17
+ status: { fn: -> { status }, aliases: [:stack] },
18
+ current: { fn: -> { current }, aliases: [] },
19
+ git_stats: { fn: -> { git_stats }, aliases: [] },
20
+ start: { fn: -> { start(ARGV[1]) }, aliases: [] },
21
+ stop: { fn: -> { stop(get_option(['--all'])) }, aliases: [:end, :pop] },
22
+ pause: { fn: -> { pause }, aliases: [] },
23
+ resume: { fn: -> { resume }, aliases: [] },
24
+ edit: { fn: -> {
25
+ edit(day_option, get_option(['--restore']), get_option(['--reset'])) # option union
26
+ }, aliases: [] },
27
+ list: { fn: -> { list(get_option(['--day', '-d'])) }, aliases: [:ls, :history, :recent] },
28
+ sync: { fn: -> { sync }, aliases: [] },
29
+ config: { fn: -> { config }, aliases: [] },
30
+ touch: { fn: -> { touch(ARGV[1]) }, aliases: [] },
31
+ delete: { fn: -> { delete(ARGV[1]) }, aliases: [:remove] },
32
+ email: { fn: -> { email(day_option) }, aliases: [:mail] },
33
+ migrate: { fn: -> { migrate }, aliases: [] },
34
+ console: { fn: -> { console }, aliases: [:shell] },
35
+ coffee: { fn: -> { coffee(get_option(['--big'])) } }
36
+ }
37
+
38
+ @ftg_dir = "#{ENV['HOME']}/.ftg"
39
+ private_config = JSON.parse(File.open("#{@ftg_dir}/config/private.json", 'r').read)
40
+ public_config = JSON.parse(File.open("#{@ftg_dir}/config/public.json", 'r').read)
41
+ @config = public_config.deep_merge(private_config)
42
+ @ftg_logger = FtgLogger.new(@ftg_dir)
43
+ end
44
+
45
+
46
+ def require_models
47
+ require 'active_record'
48
+ require_relative './models/task'
49
+ require_relative './migrations/create_tasks'
50
+ require_relative './task_formatter'
51
+
52
+ ActiveRecord::Base.establish_connection(
53
+ adapter: 'sqlite3',
54
+ database: 'db/ftg.sqlite3'
55
+ )
56
+ fail('Cannot open task connection') unless Task.connection
57
+ end
58
+
59
+ def run
60
+ help(1) if ARGV[0].nil?
61
+ cmd = get_command(ARGV[0])
62
+ fail("Unknown command #{ARGV[0]}") if cmd.nil?
63
+ cmd[1][:fn].call
64
+ end
65
+
66
+ #####################################################################################
67
+ ####################################### COMMANDS ####################################
68
+ #####################################################################################
69
+
70
+ def help(exit_code = 0)
71
+ help = <<-HELP
72
+ Usage: ftg <command> [arguments...]
73
+ By default, the day param is the current day.
74
+
75
+ Command list:
76
+ start, stop, pause, resume <task> Manage tasks
77
+ gtfo Executes: edit, sync, mail
78
+ edit <task> [-d <day>, --reset] Manually edit times
79
+ sync [-d <day>] Sync times with jira and toggl
80
+ mail Send an email
81
+ stats [-d <day>] Show time stats
82
+ current Show current task
83
+ pop Stop current task and resume previous one
84
+ touch <task> Start and end a task right away
85
+ remove <task> Delete a task
86
+ list List of tasks/meetings of the day
87
+ config Show config files
88
+ console Open a console
89
+ HELP
90
+ puts help
91
+ exit(exit_code)
92
+ end
93
+
94
+ def config
95
+ require 'ap'
96
+ puts 'Settings are in the ./config folder:'
97
+ puts ' public.json default settings. Do not edit manually. Added to git'
98
+ puts ' private.json personal settings. This will overwrite public.json. Ignored in git'
99
+
100
+ puts "\nCurrent config:\n"
101
+ ap @config
102
+ end
103
+
104
+ def start(task)
105
+ if task == 'auto' || task == 'current_branch'
106
+ task = `git rev-parse --abbrev-ref HEAD`.strip
107
+ end
108
+ if task.nil? || task == ''
109
+ fail('Enter a task. Eg: ftg start jt-1234')
110
+ end
111
+ if @ftg_logger.on_pause?
112
+ status
113
+ fail("\nCannot start a task while on pause. Use \"ftg resume\" first")
114
+ end
115
+ if @ftg_logger.get_unclosed_logs.find { |l| l[:task_name] == task }
116
+ status
117
+ fail("\nTask #{task} already started")
118
+ end
119
+ @ftg_logger.add_log('ftg_start', task)
120
+ status
121
+ @ftg_logger.update_current
122
+ end
123
+
124
+ def stop(all)
125
+ @ftg_logger.get_unclosed_logs.each do |log|
126
+ @ftg_logger.add_log('ftg_stop', log[:task_name])
127
+ break unless all
128
+ end
129
+ status
130
+ @ftg_logger.update_current
131
+ end
132
+
133
+ def pause
134
+ if @ftg_logger.on_pause?
135
+ status
136
+ fail("\nAlready on pause")
137
+ end
138
+ @ftg_logger.add_log('ftg_start', 'pause')
139
+ status
140
+ @ftg_logger.update_current
141
+ end
142
+
143
+ def resume
144
+ @ftg_logger.add_log('ftg_stop', 'pause')
145
+ status
146
+ @ftg_logger.update_current
147
+ end
148
+
149
+ def touch(task)
150
+ @ftg_logger.add_log('ftg_start', task)
151
+ @ftg_logger.add_log('ftg_stop', task)
152
+ status
153
+ end
154
+
155
+ def delete(task)
156
+ if task == '--all'
157
+ @ftg_logger.remove_all_logs
158
+ end
159
+ @ftg_logger.remove_logs(task)
160
+ status
161
+ @ftg_logger.update_current
162
+ end
163
+
164
+ def gtfo(day, restore, reset)
165
+ edit(day, restore, reset)
166
+ email(day)
167
+ puts "sync soon..."
168
+ end
169
+
170
+ def status
171
+ current_logs = @ftg_logger.get_unclosed_logs
172
+ if current_logs.empty?
173
+ puts 'No current task'
174
+ else
175
+ task_name = current_logs[0][:task_name]
176
+ puts(task_name == 'pause' ? 'On pause' : "Now working on: [#{task_name.cyan}]")
177
+ unless current_logs[1..-1].empty?
178
+ puts "next tasks: #{current_logs[1..-1].map { |l| l[:task_name].light_blue }.join(', ')}"
179
+ end
180
+ end
181
+ end
182
+
183
+ def current
184
+ puts `cat #{@ftg_dir}/current.txt`
185
+ end
186
+
187
+ def edit(day, restore, reset)
188
+ require_relative './interactive'
189
+ require_relative './ftg_stats'
190
+ require_models
191
+
192
+ ftg_stats = FtgStats.new(day == Time.now.strftime('%F'))
193
+ tasks = []
194
+ Hash[ftg_stats.stats][day].each do |branch, by_branch|
195
+ next if branch == 'unknown'
196
+ by_idle = Hash[by_branch]
197
+ scope = Task.where(day: day).where(name: branch)
198
+ scope.delete_all if reset
199
+ task = scope.first_or_create
200
+ task.duration = task.edited_at ? task.duration : by_idle[false].to_i
201
+ task.save
202
+ tasks << task if restore || !task.deleted_at
203
+ end
204
+
205
+ deleted_tasks = Interactive.new.interactive_edit(tasks)
206
+ tasks.each do |task|
207
+ task.deleted_at = nil if restore
208
+ task.save if restore || task.changed.include?('edited_at')
209
+ end
210
+ deleted_tasks.each do |task|
211
+ task.save if task.changed.include?('deleted_at')
212
+ end
213
+ end
214
+
215
+ def sync
216
+ require_relative './ftg_sync'
217
+ abort('todo')
218
+ end
219
+
220
+ def git_stats
221
+ require_relative './ftg_stats'
222
+ FtgStats.new(false).run
223
+ end
224
+
225
+ def list(days)
226
+ days ||= 14
227
+ begin_time = Time.now.to_i - (days.to_i * 24 * 3600)
228
+
229
+ # Date.parse(day).to_time.to_i
230
+ git_branches_raw = `git for-each-ref --sort=-committerdate --format='%(refname:short) | %(committerdate:iso)' refs/heads/` rescue nil
231
+
232
+ git_branches = []
233
+ git_branches_raw.split("\n").map do |b|
234
+ parts = b.split(' | ')
235
+ next if parts.count != 2
236
+ timestamp = DateTime.parse(parts[1]).to_time.to_i
237
+ if timestamp > begin_time
238
+ git_branches << [timestamp, parts[0]]
239
+ end
240
+ end
241
+
242
+ commands_log_path = "#{@ftg_dir}/log/commands.log"
243
+ history_branches = []
244
+ `tail -n #{days * 500} #{commands_log_path}`.split("\n").each do |command|
245
+ parts = command.split("\t")
246
+ time = parts[5].to_i
247
+ branch = parts[4]
248
+ if time > begin_time && branch != 'no_branch'
249
+ history_branches << [time, branch]
250
+ end
251
+ end
252
+ history_branches = history_branches.group_by { |e| e[1] }.map { |k, v| [v.last[0], k] }
253
+
254
+ ftg_log_path = "#{@ftg_dir}/log/ftg.log"
255
+ ftg_tasks = []
256
+ `tail -n #{days * 100} #{ftg_log_path}`.split("\n").each do |log|
257
+ parts = log.split("\t")
258
+ task = parts[1]
259
+ time = parts[2].to_i
260
+ if time > begin_time
261
+ ftg_tasks << [time, task]
262
+ end
263
+ end
264
+ ftg_tasks = ftg_tasks.group_by { |e| e[1] }.map { |k, v| [v.last[0], k] }
265
+
266
+ all_tasks = git_branches + history_branches + ftg_tasks
267
+ all_tasks = all_tasks.sort_by { |e| -e[0] }.group_by { |e| e[1] }.map { |task, times| task }
268
+ puts all_tasks.join("\n")
269
+ end
270
+
271
+ def migrate
272
+ require_models
273
+ CreateTasks.new.up
274
+ end
275
+
276
+ def render_email(day, tasks)
277
+ max_len = TaskFormatter.max_length(tasks)
278
+ content = "Salut,\n\n<Expliquer ici pourquoi le sprint ne sera pas fini à temps>\n\n#{day}\n"
279
+ content += tasks.map do |task|
280
+ TaskFormatter.new.format(task, max_len).line_for_email
281
+ end.join("\n")
282
+ content
283
+ end
284
+
285
+ def email(day)
286
+ require_models
287
+ email = @config['ftg']['recap_mailto'].join(', ')
288
+ week_days_fr = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche']
289
+ week_day_fr = week_days_fr[Date.parse(day).strftime('%u').to_i - 1]
290
+ week_day_en = Time.now.strftime('%A').downcase
291
+ greeting = @config['ftg']['greetings'][week_day_en] || nil
292
+ subject = "Recap #{week_day_fr} #{day}"
293
+
294
+ body = [render_email(day, Task.where(day: day).where(deleted_at: nil)), greeting].compact.join("\n\n")
295
+ system('open', "mailto: #{email}?subject=#{subject}&body=#{body}")
296
+ end
297
+
298
+ def console
299
+ require 'pry'
300
+ require_relative './interactive'
301
+ require_relative './ftg_stats'
302
+ require_models
303
+ binding.pry
304
+ end
305
+
306
+ def coffee(big = false)
307
+ require_relative './coffee'
308
+ puts(big ? Coffee.coffee2 : Coffee.coffee1)
309
+ puts "\nHave a nice coffee !"
310
+ puts '=========================================='
311
+ pause
312
+ end
313
+ end
314
+
315
+
316
+ # Ftg.new.run
data/lib/ftg_logger.rb ADDED
@@ -0,0 +1,67 @@
1
+ class FtgLogger
2
+
3
+ def initialize(ftg_dir)
4
+ @ftg_dir = ftg_dir
5
+ @log_file = "#{ftg_dir}/log/ftg.log"
6
+ end
7
+
8
+ def add_log(command, task)
9
+ lines = [command, task, Time.now.getutc.to_i]
10
+ `echo "#{lines.join('\t')}" >> #{@log_file}`
11
+ end
12
+
13
+ def remove_all_logs
14
+ `echo "" > #{@log_file}`
15
+ end
16
+
17
+ def remove_logs(name)
18
+ count = 0
19
+ logs = get_logs
20
+ logs.keep_if do |log|
21
+ cond = log[:task_name] != name || log[:timestamp].to_i <= Time.now.to_i - 24*3600
22
+ count += 1 unless cond
23
+ cond
24
+ end
25
+
26
+ File.open(@log_file, 'w') do |f|
27
+ f.write(logs.map{|l| l.values.join("\t")}.join("\n") + "\n")
28
+ end
29
+
30
+ puts "Removed #{count} entries"
31
+ end
32
+
33
+ def get_logs
34
+ File.open(@log_file, File::RDONLY|File::CREAT) do |file|
35
+ file.read.split("\n").map do |e|
36
+ parts = e.split("\t")
37
+ { command: parts[0], task_name: parts[1], timestamp: parts[2] }
38
+ end
39
+ end
40
+ end
41
+
42
+ def on_pause?
43
+ unclosed_logs = get_unclosed_logs
44
+ unclosed_logs[0] && unclosed_logs[0][:task_name] == 'pause'
45
+ end
46
+
47
+ def get_unclosed_logs
48
+ unclosed_logs = []
49
+ closed = {}
50
+ get_logs.reverse.each do |log|
51
+ if log[:command] == 'ftg_stop'
52
+ closed[log[:task_name]] = true
53
+ end
54
+ if log[:command] == 'ftg_start' && !closed[log[:task_name]]
55
+ unclosed_logs << log
56
+ end
57
+ end
58
+ unclosed_logs
59
+ end
60
+
61
+ def update_current
62
+ current = ''
63
+ current_logs = get_unclosed_logs
64
+ current = current_logs[0][:task_name] unless current_logs.empty?
65
+ `echo "#{current}" > #{@ftg_dir}/current.txt`
66
+ end
67
+ end
@@ -0,0 +1,28 @@
1
+ module FtgOptions
2
+ def get_option(names)
3
+ ARGV.each_with_index do |opt_name, i|
4
+ return (ARGV[i + 1] || 1) if names.include?(opt_name)
5
+ end
6
+ nil
7
+ end
8
+
9
+ # day, not gay
10
+ def day_option
11
+ day_option = get_option(['-d', '--day'])
12
+ day_option ||= '0'
13
+
14
+ Utils.is_integer?(day_option) ?
15
+ Time.at(Time.now.to_i - day_option.to_i * 86400).strftime('%F') :
16
+ Date.parse(day_option).strftime('%F')
17
+ end
18
+
19
+ def get_command(name)
20
+ @commands.find { |cmd_name, _| cmd_name.to_s.start_with?(name) } ||
21
+ @commands.find { |_, cmd| cmd[:aliases] && cmd[:aliases].any? { |a| a.to_s.start_with?(name) } }
22
+ end
23
+
24
+ def fail(message = nil)
25
+ STDERR.puts message if message
26
+ exit(1)
27
+ end
28
+ end
data/lib/ftg_stats.rb ADDED
@@ -0,0 +1,135 @@
1
+ class FtgStats
2
+ IDLE_THRESHOLD = 5 * 60
3
+
4
+ attr_accessor :stats
5
+
6
+ def initialize(only_last_day)
7
+ load_data(only_last_day)
8
+ crunch
9
+ group
10
+ end
11
+
12
+ def run
13
+
14
+ display
15
+ # sync_toggl
16
+ end
17
+
18
+ def search_idle_key(timestamp)
19
+ (0..10).each do |k|
20
+ key = timestamp + k
21
+ return key if @idle_parts[key]
22
+ end
23
+ # puts("not found #{Utils.format_time(timestamp)}")
24
+ nil
25
+ end
26
+
27
+ def load_data(only_last_day)
28
+ home = `echo $HOME`.strip
29
+ ftg_dir = "#{home}/.ftg"
30
+ commands_log_path = "#{ftg_dir}/log/commands.log"
31
+ idle_log_path = "#{ftg_dir}/log/idle.log"
32
+ records_to_load = only_last_day ? 24 * 360 : 0
33
+ @commands = {}
34
+ @idle_parts = {}
35
+
36
+ # sample row:
37
+ # pinouchon fg no_alias /Users/pinouchon/.ftg no_branch 1438867098
38
+ (only_last_day ?
39
+ `tail -n #{records_to_load} #{commands_log_path}`.split("\n") :
40
+ File.foreach(commands_log_path)).each do |line|
41
+ parts = line.split("\t")
42
+ next if !parts[5] || parts[5].empty?
43
+ @commands[parts[5].strip.to_i] = { :user => parts[0],
44
+ :command => parts[1],
45
+ :alias => parts[2], :dir => parts[3], :branch => parts[4] }
46
+ end
47
+
48
+ (only_last_day ?
49
+ `tail -n #{records_to_load} #{idle_log_path}`.split("\n") :
50
+ File.foreach(idle_log_path)).each do |line|
51
+ parts = line.split("\t")
52
+ next if !parts[1] || parts[1].empty?
53
+ @idle_parts[parts[1].strip.to_i] = { :time_elapsed => parts[0] }
54
+ end
55
+ end
56
+
57
+ def crunch
58
+ # tagging branches in idle_parts
59
+ @commands.each do |timestamp, command_info|
60
+ if (key = search_idle_key(timestamp))
61
+ @idle_parts[key][:branch] = command_info[:branch]
62
+ end
63
+ end
64
+
65
+ # filling branches in idle_parts
66
+ # tagging thresholds in idle_parts
67
+ last_branch = 'unknown'
68
+ @idle_parts.each do |timestamp, part|
69
+ if part[:branch] && part[:branch] != '' && part[:branch] != 'no_branch'
70
+ last_branch = part[:branch]
71
+ end
72
+ # puts "setting to #{last_branch} (#{Time.at(timestamp).strftime('%Y/%m/%d at %I:%M%p')})"
73
+ @idle_parts[timestamp][:branch] = last_branch
74
+ @idle_parts[timestamp][:idle] = part[:time_elapsed].to_i > IDLE_THRESHOLD
75
+ end
76
+ end
77
+
78
+ def group
79
+ @stats = @idle_parts.group_by { |ts, _| Time.at(ts).strftime('%F') }.map do |day, parts_by_day|
80
+ [
81
+ day,
82
+ parts_by_day.group_by { |_, v| v[:branch] }.map do |branch, parts_by_branch|
83
+ [
84
+ branch,
85
+ parts_by_branch.group_by { |_, v| v[:idle] }.map { |k, v| [k, v.count*10] }
86
+ ]
87
+ end
88
+ ]
89
+ end
90
+ end
91
+
92
+ def display
93
+ Hash[@stats].each do |day, by_day|
94
+ puts "#{day}:"
95
+ Hash[by_day].each do |branch, by_branch|
96
+ by_idle = Hash[by_branch]
97
+ idle_str = by_idle[true] ? "(and #{Utils.format_time(by_idle[true])} idle)" : ''
98
+ puts " #{branch}: #{Utils.format_time(by_idle[false]) || '00:00:00'} #{idle_str}"
99
+ end
100
+ end
101
+ end
102
+
103
+ def sync_toggl
104
+ require 'pry'
105
+ sync = FtgSync.new
106
+ i = 0
107
+
108
+ Hash[@stats].each do |day, by_day|
109
+ puts "#{day}:"
110
+ Hash[by_day].each do |branch, by_branch|
111
+ by_idle = Hash[by_branch]
112
+ idle_str = by_idle[true] ? "(and #{by_idle[true]} idle)" : ''
113
+ puts " #{branch}: #{by_idle[false] || '00:00:00'} #{idle_str}"
114
+
115
+ if branch =~ /jt-/ && by_idle[false]
116
+ ps = day.split('-')
117
+ time = Time.new(ps[0], ps[1], ps[2], 12,0,0)
118
+ begining_of_day = Time.new(ps[0], ps[1], ps[2], 0,0,0)
119
+ end_of_day = begining_of_day + (24*3600)
120
+
121
+ jt = branch[/(jt-[0-9]+)/]
122
+ duration_parts = by_idle[false].split(':')
123
+ duration = duration_parts[0].to_i * 3600 + duration_parts[1].to_i * 60 + duration_parts[2].to_i
124
+ type = sync.maintenance?(jt) ? :maintenance : :sprint
125
+ sync.create_entry("#{branch} [via FTG]", duration, time, type)
126
+ i += 1
127
+
128
+ puts "logging #{branch}: #{by_idle[false]}"
129
+ end
130
+ end
131
+ end
132
+ puts "total: #{i}"
133
+ end
134
+
135
+ end