pylonite 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.
- checksums.yaml +7 -0
- data/bin/pylonite +5 -0
- data/lib/pylonite/cli.rb +325 -0
- data/lib/pylonite/database.rb +346 -0
- data/lib/pylonite/help.rb +133 -0
- data/lib/pylonite/tui.rb +638 -0
- data/lib/pylonite/version.rb +3 -0
- data/lib/pylonite.rb +8 -0
- data/pylonite.gemspec +20 -0
- metadata +63 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5262454e6d454011d45404697730c9992dfaf2a8a1ae0abba675e1b32f2ad8d1
|
|
4
|
+
data.tar.gz: 2de33c973493ffb5564ba2226eec960715a26dad13f79a230bf653c4b46588d8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 757a919ecaad96c2b920fad316c6598ca3126a7cf77435169097670807cbab693389e0a93b564e2edf78b99a05d35b6041f504218eaaa4741e97bc8d3e592657
|
|
7
|
+
data.tar.gz: d3e379a348b3d84a19a3bf1c001e0838ab0a2c2b174051e6dca22c347badb11606cfaf81f78472163014c9a804571858752e2502875e3b43b5191efd180e149b
|
data/bin/pylonite
ADDED
data/lib/pylonite/cli.rb
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
module Pylonite
|
|
2
|
+
module CLI
|
|
3
|
+
BOARD_COLORS = {
|
|
4
|
+
"backlog" => "\e[37m",
|
|
5
|
+
"todo" => "\e[33m",
|
|
6
|
+
"in_progress" => "\e[36m",
|
|
7
|
+
"done" => "\e[32m",
|
|
8
|
+
"archived" => "\e[90m"
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
RESET = "\e[0m"
|
|
12
|
+
BOLD = "\e[1m"
|
|
13
|
+
DIM = "\e[2m"
|
|
14
|
+
|
|
15
|
+
def self.run(args)
|
|
16
|
+
command = args.shift
|
|
17
|
+
case command
|
|
18
|
+
when "add" then cmd_add(args)
|
|
19
|
+
when "show" then cmd_show(args)
|
|
20
|
+
when "list" then cmd_list(args)
|
|
21
|
+
when "move" then cmd_move(args)
|
|
22
|
+
when "comment" then cmd_comment(args)
|
|
23
|
+
when "search" then cmd_search(args)
|
|
24
|
+
when "archive" then cmd_archive(args)
|
|
25
|
+
when "assign" then cmd_assign(args)
|
|
26
|
+
when "block" then cmd_block(args)
|
|
27
|
+
when "unblock" then cmd_unblock(args)
|
|
28
|
+
when "subtask" then cmd_subtask(args)
|
|
29
|
+
when "edit" then cmd_edit(args)
|
|
30
|
+
when "log" then cmd_log(args)
|
|
31
|
+
when "internal" then cmd_internal(args)
|
|
32
|
+
when "tui" then Pylonite::TUI.run
|
|
33
|
+
when "help", "--help", "-h", nil then Pylonite::Help.display
|
|
34
|
+
else
|
|
35
|
+
error("Unknown command: #{command}. Run 'pylonite help' for usage.")
|
|
36
|
+
end
|
|
37
|
+
rescue => e
|
|
38
|
+
error(e.message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# --- Commands ---
|
|
42
|
+
|
|
43
|
+
def self.cmd_add(args)
|
|
44
|
+
board = extract_option(args, "--board") || "backlog"
|
|
45
|
+
assignee = extract_option(args, "--assign")
|
|
46
|
+
description = extract_option(args, "--description") || extract_option(args, "-d")
|
|
47
|
+
title = args.first
|
|
48
|
+
error("Usage: pylonite add \"task title\" [--board BOARD] [--assign USER] [--description TEXT]") unless title
|
|
49
|
+
|
|
50
|
+
db = Database.new
|
|
51
|
+
id = db.add_task(title, board: board, assignee: assignee, description: description)
|
|
52
|
+
puts "Created task #{BOLD}##{id}#{RESET} in #{colorize_board(board)}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.cmd_show(args)
|
|
56
|
+
id = args.first&.to_i
|
|
57
|
+
error("Usage: pylonite show ID") unless id && id > 0
|
|
58
|
+
|
|
59
|
+
db = Database.new
|
|
60
|
+
task = db.get_task(id)
|
|
61
|
+
error("Task ##{id} not found") unless task
|
|
62
|
+
|
|
63
|
+
board = task["board"]
|
|
64
|
+
puts "#{BOLD}##{task["id"]}#{RESET} #{task["title"]}"
|
|
65
|
+
puts " Board: #{colorize_board(board)}"
|
|
66
|
+
puts " Author: #{task["author"]}" if task["author"]
|
|
67
|
+
puts " Assignee: #{task["assignee"]}" if task["assignee"]
|
|
68
|
+
puts " Created: #{task["created_at"]}"
|
|
69
|
+
puts " Updated: #{task["updated_at"]}"
|
|
70
|
+
|
|
71
|
+
if task["parent"]
|
|
72
|
+
puts " Parent: ##{task["parent"]["id"]} #{task["parent"]["title"]}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if task["description"] && !task["description"].empty?
|
|
76
|
+
puts ""
|
|
77
|
+
puts " #{DIM}Description:#{RESET}"
|
|
78
|
+
task["description"].each_line { |l| puts " #{l.rstrip}" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if task["blockers"] && !task["blockers"].empty?
|
|
82
|
+
puts ""
|
|
83
|
+
puts " #{DIM}Blocked by:#{RESET}"
|
|
84
|
+
task["blockers"].each do |b|
|
|
85
|
+
puts " ##{b["id"]} #{b["title"]} [#{b["board"]}]"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if task["blocked_by_this"] && !task["blocked_by_this"].empty?
|
|
90
|
+
puts ""
|
|
91
|
+
puts " #{DIM}Blocking:#{RESET}"
|
|
92
|
+
task["blocked_by_this"].each do |b|
|
|
93
|
+
puts " ##{b["id"]} #{b["title"]} [#{b["board"]}]"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if task["subtasks"] && !task["subtasks"].empty?
|
|
98
|
+
puts ""
|
|
99
|
+
puts " #{DIM}Subtasks:#{RESET}"
|
|
100
|
+
task["subtasks"].each do |s|
|
|
101
|
+
puts " ##{s["id"]} #{s["title"]} [#{s["board"]}]"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if task["comments"] && !task["comments"].empty?
|
|
106
|
+
puts ""
|
|
107
|
+
puts " #{DIM}Comments:#{RESET}"
|
|
108
|
+
task["comments"].each do |c|
|
|
109
|
+
puts " #{DIM}#{c["created_at"]} #{c["author"]}:#{RESET} #{c["text"]}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if task["history"] && !task["history"].empty?
|
|
114
|
+
puts ""
|
|
115
|
+
puts " #{DIM}History:#{RESET}"
|
|
116
|
+
task["history"].each do |h|
|
|
117
|
+
puts " #{DIM}#{h["created_at"]}#{RESET} #{h["actor"]}: #{h["detail"]}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.cmd_list(args)
|
|
123
|
+
board = extract_option(args, "--board")
|
|
124
|
+
include_all = args.delete("--all")
|
|
125
|
+
|
|
126
|
+
db = Database.new
|
|
127
|
+
tasks = db.list_tasks(board: board, include_archived: !!include_all)
|
|
128
|
+
|
|
129
|
+
if tasks.empty?
|
|
130
|
+
puts "No tasks found."
|
|
131
|
+
return
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
grouped = tasks.group_by { |t| t["board"] }
|
|
135
|
+
board_order = Database::BOARDS
|
|
136
|
+
|
|
137
|
+
board_order.each do |b|
|
|
138
|
+
next unless grouped[b]
|
|
139
|
+
color = BOARD_COLORS[b] || ""
|
|
140
|
+
puts "#{color}#{BOLD}#{b.upcase.tr("_", " ")}#{RESET}"
|
|
141
|
+
grouped[b].each do |t|
|
|
142
|
+
assignee_str = t["assignee"] ? " #{DIM}(#{t["assignee"]})#{RESET}" : ""
|
|
143
|
+
puts " #{color}##{t["id"]}#{RESET} #{t["title"]}#{assignee_str}"
|
|
144
|
+
end
|
|
145
|
+
puts ""
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.cmd_move(args)
|
|
150
|
+
id = args.shift&.to_i
|
|
151
|
+
board = args.shift
|
|
152
|
+
error("Usage: pylonite move ID BOARD") unless id && id > 0 && board
|
|
153
|
+
|
|
154
|
+
db = Database.new
|
|
155
|
+
db.move_task(id, board)
|
|
156
|
+
puts "Moved task #{BOLD}##{id}#{RESET} to #{colorize_board(board)}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.cmd_comment(args)
|
|
160
|
+
id = args.shift&.to_i
|
|
161
|
+
text = args.first
|
|
162
|
+
error("Usage: pylonite comment ID \"comment text\"") unless id && id > 0 && text
|
|
163
|
+
|
|
164
|
+
db = Database.new
|
|
165
|
+
db.add_comment(id, text)
|
|
166
|
+
puts "Comment added to task #{BOLD}##{id}#{RESET}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def self.cmd_search(args)
|
|
170
|
+
query = args.first
|
|
171
|
+
error("Usage: pylonite search \"query\"") unless query
|
|
172
|
+
|
|
173
|
+
db = Database.new
|
|
174
|
+
tasks = db.search_tasks(query)
|
|
175
|
+
|
|
176
|
+
if tasks.empty?
|
|
177
|
+
puts "No tasks matching \"#{query}\"."
|
|
178
|
+
return
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
tasks.each do |t|
|
|
182
|
+
color = BOARD_COLORS[t["board"]] || ""
|
|
183
|
+
assignee_str = t["assignee"] ? " #{DIM}(#{t["assignee"]})#{RESET}" : ""
|
|
184
|
+
puts "#{color}##{t["id"]}#{RESET} #{t["title"]} [#{t["board"]}]#{assignee_str}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.cmd_archive(args)
|
|
189
|
+
id = args.first&.to_i
|
|
190
|
+
error("Usage: pylonite archive ID") unless id && id > 0
|
|
191
|
+
|
|
192
|
+
db = Database.new
|
|
193
|
+
db.archive_task(id)
|
|
194
|
+
puts "Archived task #{BOLD}##{id}#{RESET}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.cmd_assign(args)
|
|
198
|
+
id = args.shift&.to_i
|
|
199
|
+
user = args.first
|
|
200
|
+
error("Usage: pylonite assign ID USER") unless id && id > 0 && user
|
|
201
|
+
|
|
202
|
+
db = Database.new
|
|
203
|
+
db.assign_task(id, user)
|
|
204
|
+
puts "Assigned task #{BOLD}##{id}#{RESET} to #{user}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.cmd_block(args)
|
|
208
|
+
id = args.shift&.to_i
|
|
209
|
+
blocker_id = args.first&.to_i
|
|
210
|
+
error("Usage: pylonite block ID BLOCKER_ID") unless id && id > 0 && blocker_id && blocker_id > 0
|
|
211
|
+
|
|
212
|
+
db = Database.new
|
|
213
|
+
db.add_blocker(id, blocker_id)
|
|
214
|
+
puts "Task #{BOLD}##{blocker_id}#{RESET} now blocks #{BOLD}##{id}#{RESET}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def self.cmd_unblock(args)
|
|
218
|
+
id = args.shift&.to_i
|
|
219
|
+
blocker_id = args.first&.to_i
|
|
220
|
+
error("Usage: pylonite unblock ID BLOCKER_ID") unless id && id > 0 && blocker_id && blocker_id > 0
|
|
221
|
+
|
|
222
|
+
db = Database.new
|
|
223
|
+
db.remove_blocker(id, blocker_id)
|
|
224
|
+
puts "Removed blocker #{BOLD}##{blocker_id}#{RESET} from #{BOLD}##{id}#{RESET}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def self.cmd_subtask(args)
|
|
228
|
+
parent_id = args.shift&.to_i
|
|
229
|
+
title = args.first
|
|
230
|
+
error("Usage: pylonite subtask PARENT_ID \"title\"") unless parent_id && parent_id > 0 && title
|
|
231
|
+
|
|
232
|
+
db = Database.new
|
|
233
|
+
id = db.add_subtask(parent_id, title)
|
|
234
|
+
puts "Created subtask #{BOLD}##{id}#{RESET} under #{BOLD}##{parent_id}#{RESET}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def self.cmd_edit(args)
|
|
238
|
+
id = args.shift&.to_i
|
|
239
|
+
error("Usage: pylonite edit ID [--title \"new title\"] [--description \"new desc\"]") unless id && id > 0
|
|
240
|
+
|
|
241
|
+
title = extract_option(args, "--title")
|
|
242
|
+
description = extract_option(args, "--description") || extract_option(args, "-d")
|
|
243
|
+
error("Nothing to update. Use --title or --description / -d") unless title || description
|
|
244
|
+
|
|
245
|
+
db = Database.new
|
|
246
|
+
db.update_task(id, title: title, description: description)
|
|
247
|
+
puts "Updated task #{BOLD}##{id}#{RESET}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.cmd_log(args)
|
|
251
|
+
db = Database.new
|
|
252
|
+
entries = db.activity_log
|
|
253
|
+
|
|
254
|
+
if entries.empty?
|
|
255
|
+
puts "No activity yet."
|
|
256
|
+
return
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
output = entries.map do |e|
|
|
260
|
+
"#{DIM}#{e["created_at"]}#{RESET} #{e["actor"]} #{format_action(e["action"])} #{BOLD}##{e["task_id"]}#{RESET} #{e["title"]}\n #{DIM}#{e["detail"]}#{RESET}"
|
|
261
|
+
end.join("\n\n") + "\n"
|
|
262
|
+
|
|
263
|
+
if $stdout.tty?
|
|
264
|
+
pager = ENV["PAGER"] || "less -R"
|
|
265
|
+
IO.popen(pager, "w") { |io| io.write(output) }
|
|
266
|
+
else
|
|
267
|
+
$stdout.write(output)
|
|
268
|
+
end
|
|
269
|
+
rescue Errno::EPIPE
|
|
270
|
+
# user quit pager early
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def self.format_action(action)
|
|
274
|
+
case action
|
|
275
|
+
when "created" then "created"
|
|
276
|
+
when "moved" then "moved"
|
|
277
|
+
when "assigned" then "assigned"
|
|
278
|
+
when "commented" then "commented on"
|
|
279
|
+
when "updated" then "updated"
|
|
280
|
+
when "blocker_added" then "added blocker to"
|
|
281
|
+
when "blocker_removed" then "removed blocker from"
|
|
282
|
+
when "subtask_added" then "added subtask to"
|
|
283
|
+
when "subtask_created" then "created subtask"
|
|
284
|
+
else action.tr("_", " ")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def self.cmd_internal(args)
|
|
289
|
+
subcommand = args.shift
|
|
290
|
+
case subcommand
|
|
291
|
+
when "appropriate"
|
|
292
|
+
old_path = args.first
|
|
293
|
+
error("Usage: pylonite internal appropriate OLD_DB_PATH") unless old_path
|
|
294
|
+
new_path = Database.appropriate(Dir.pwd, old_path)
|
|
295
|
+
puts "Database moved to #{new_path}"
|
|
296
|
+
else
|
|
297
|
+
error("Unknown internal command: #{subcommand}")
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# --- Helpers ---
|
|
302
|
+
|
|
303
|
+
def self.extract_option(args, flag)
|
|
304
|
+
idx = args.index(flag)
|
|
305
|
+
return nil unless idx
|
|
306
|
+
args.delete_at(idx)
|
|
307
|
+
args.delete_at(idx)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def self.colorize_board(board)
|
|
311
|
+
color = BOARD_COLORS[board] || ""
|
|
312
|
+
"#{color}#{board}#{RESET}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def self.error(message)
|
|
316
|
+
$stderr.puts "Error: #{message}"
|
|
317
|
+
exit(1)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private_class_method :extract_option, :colorize_board, :error, :format_action,
|
|
321
|
+
:cmd_add, :cmd_show, :cmd_list, :cmd_move, :cmd_comment,
|
|
322
|
+
:cmd_search, :cmd_archive, :cmd_assign, :cmd_block, :cmd_unblock,
|
|
323
|
+
:cmd_subtask, :cmd_edit, :cmd_log, :cmd_internal
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
require "sqlite3"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Pylonite
|
|
6
|
+
class Database
|
|
7
|
+
BOARDS = %w[backlog todo in_progress done archived].freeze
|
|
8
|
+
DEFAULT_DB_DIR = File.expand_path("~/.pylonite/dbs")
|
|
9
|
+
|
|
10
|
+
def self.db_dir
|
|
11
|
+
@db_dir || DEFAULT_DB_DIR
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.db_dir=(dir)
|
|
15
|
+
@db_dir = dir
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :db, :db_path
|
|
19
|
+
|
|
20
|
+
def initialize(project_path = Dir.pwd)
|
|
21
|
+
@db_path = self.class.db_path_for(project_path)
|
|
22
|
+
FileUtils.mkdir_p(File.dirname(@db_path))
|
|
23
|
+
@db = SQLite3::Database.new(@db_path)
|
|
24
|
+
@db.results_as_hash = true
|
|
25
|
+
@db.execute("PRAGMA journal_mode=WAL")
|
|
26
|
+
@db.execute("PRAGMA foreign_keys=ON")
|
|
27
|
+
migrate!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.db_name_for(project_path)
|
|
31
|
+
name = File.basename(project_path)
|
|
32
|
+
hash = Digest::SHA256.hexdigest(project_path)[0, 8]
|
|
33
|
+
"#{name}_#{hash}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.db_path_for(project_path)
|
|
37
|
+
File.join(db_dir, "#{db_name_for(project_path)}.sqlite3")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.appropriate(new_path, old_db_path)
|
|
41
|
+
raise "Database not found: #{old_db_path}" unless File.exist?(old_db_path)
|
|
42
|
+
|
|
43
|
+
new_db_path = db_path_for(new_path)
|
|
44
|
+
if File.expand_path(old_db_path) == File.expand_path(new_db_path)
|
|
45
|
+
return new_db_path
|
|
46
|
+
end
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(new_db_path))
|
|
48
|
+
FileUtils.mv(old_db_path, new_db_path)
|
|
49
|
+
new_db_path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- Tasks ---
|
|
53
|
+
|
|
54
|
+
def add_task(title, author: nil, board: "backlog", assignee: nil, description: nil)
|
|
55
|
+
author ||= current_user
|
|
56
|
+
now = Time.now.utc.iso8601
|
|
57
|
+
@db.execute(
|
|
58
|
+
"INSERT INTO tasks (title, description, board, author, assignee, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
59
|
+
[title, description, board, author, assignee, now, now]
|
|
60
|
+
)
|
|
61
|
+
task_id = @db.last_insert_row_id
|
|
62
|
+
record_history(task_id, author, "created", "Created task in #{board}")
|
|
63
|
+
task_id
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def get_task(task_id)
|
|
67
|
+
task = @db.get_first_row("SELECT * FROM tasks WHERE id = ?", [task_id])
|
|
68
|
+
return nil unless task
|
|
69
|
+
|
|
70
|
+
task["comments"] = get_comments(task_id)
|
|
71
|
+
task["history"] = get_history(task_id)
|
|
72
|
+
task["blockers"] = get_blockers(task_id)
|
|
73
|
+
task["blocked_by_this"] = get_blocked_by_this(task_id)
|
|
74
|
+
task["subtasks"] = get_subtasks(task_id)
|
|
75
|
+
task["parent"] = get_parent(task_id)
|
|
76
|
+
task
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def list_tasks(board: nil, include_archived: false)
|
|
80
|
+
if board
|
|
81
|
+
@db.execute("SELECT * FROM tasks WHERE board = ? ORDER BY updated_at DESC", [board])
|
|
82
|
+
elsif include_archived
|
|
83
|
+
@db.execute("SELECT * FROM tasks ORDER BY board, updated_at DESC")
|
|
84
|
+
else
|
|
85
|
+
@db.execute("SELECT * FROM tasks WHERE board != 'archived' ORDER BY board, updated_at DESC")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def move_task(task_id, new_board, actor: nil)
|
|
90
|
+
actor ||= current_user
|
|
91
|
+
raise "Invalid board: #{new_board}. Valid boards: #{BOARDS.join(', ')}" unless BOARDS.include?(new_board)
|
|
92
|
+
|
|
93
|
+
task = get_task(task_id)
|
|
94
|
+
raise "Task ##{task_id} not found" unless task
|
|
95
|
+
|
|
96
|
+
old_board = task["board"]
|
|
97
|
+
now = Time.now.utc.iso8601
|
|
98
|
+
@db.execute("UPDATE tasks SET board = ?, updated_at = ? WHERE id = ?", [new_board, now, task_id])
|
|
99
|
+
record_history(task_id, actor, "moved", "Moved from #{old_board} to #{new_board}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def archive_task(task_id, actor: nil)
|
|
103
|
+
move_task(task_id, "archived", actor: actor)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def assign_task(task_id, assignee, actor: nil)
|
|
107
|
+
actor ||= current_user
|
|
108
|
+
task = get_task(task_id)
|
|
109
|
+
raise "Task ##{task_id} not found" unless task
|
|
110
|
+
|
|
111
|
+
now = Time.now.utc.iso8601
|
|
112
|
+
@db.execute("UPDATE tasks SET assignee = ?, updated_at = ? WHERE id = ?", [assignee, now, task_id])
|
|
113
|
+
record_history(task_id, actor, "assigned", "Assigned to #{assignee}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def update_task(task_id, title: nil, description: nil, actor: nil)
|
|
117
|
+
actor ||= current_user
|
|
118
|
+
task = get_task(task_id)
|
|
119
|
+
raise "Task ##{task_id} not found" unless task
|
|
120
|
+
|
|
121
|
+
updates = []
|
|
122
|
+
params = []
|
|
123
|
+
if title
|
|
124
|
+
updates << "title = ?"
|
|
125
|
+
params << title
|
|
126
|
+
end
|
|
127
|
+
if description
|
|
128
|
+
updates << "description = ?"
|
|
129
|
+
params << description
|
|
130
|
+
end
|
|
131
|
+
return if updates.empty?
|
|
132
|
+
|
|
133
|
+
updates << "updated_at = ?"
|
|
134
|
+
params << Time.now.utc.iso8601
|
|
135
|
+
params << task_id
|
|
136
|
+
@db.execute("UPDATE tasks SET #{updates.join(', ')} WHERE id = ?", params)
|
|
137
|
+
record_history(task_id, actor, "updated", "Updated task details")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def search_tasks(query)
|
|
141
|
+
@db.execute(
|
|
142
|
+
"SELECT * FROM tasks WHERE title LIKE ? OR description LIKE ? ORDER BY updated_at DESC",
|
|
143
|
+
["%#{query}%", "%#{query}%"]
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# --- Comments ---
|
|
148
|
+
|
|
149
|
+
def add_comment(task_id, text, author: nil)
|
|
150
|
+
author ||= current_user
|
|
151
|
+
task = get_task(task_id)
|
|
152
|
+
raise "Task ##{task_id} not found" unless task
|
|
153
|
+
|
|
154
|
+
now = Time.now.utc.iso8601
|
|
155
|
+
@db.execute(
|
|
156
|
+
"INSERT INTO comments (task_id, author, text, created_at) VALUES (?, ?, ?, ?)",
|
|
157
|
+
[task_id, author, text, now]
|
|
158
|
+
)
|
|
159
|
+
@db.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", [now, task_id])
|
|
160
|
+
record_history(task_id, author, "commented", "Added a comment")
|
|
161
|
+
@db.last_insert_row_id
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def get_comments(task_id)
|
|
165
|
+
@db.execute("SELECT * FROM comments WHERE task_id = ? ORDER BY created_at ASC", [task_id])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --- Dependencies ---
|
|
169
|
+
|
|
170
|
+
def add_blocker(task_id, blocker_id, actor: nil)
|
|
171
|
+
actor ||= current_user
|
|
172
|
+
raise "Task cannot block itself" if task_id == blocker_id
|
|
173
|
+
raise "Task ##{task_id} not found" unless get_task_raw(task_id)
|
|
174
|
+
raise "Task ##{blocker_id} not found" unless get_task_raw(blocker_id)
|
|
175
|
+
|
|
176
|
+
@db.execute(
|
|
177
|
+
"INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_id, dependency_type) VALUES (?, ?, 'blocks')",
|
|
178
|
+
[task_id, blocker_id]
|
|
179
|
+
)
|
|
180
|
+
record_history(task_id, actor, "blocker_added", "Added blocker: task ##{blocker_id}")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def remove_blocker(task_id, blocker_id, actor: nil)
|
|
184
|
+
actor ||= current_user
|
|
185
|
+
@db.execute(
|
|
186
|
+
"DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_id = ? AND dependency_type = 'blocks'",
|
|
187
|
+
[task_id, blocker_id]
|
|
188
|
+
)
|
|
189
|
+
record_history(task_id, actor, "blocker_removed", "Removed blocker: task ##{blocker_id}")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def get_blockers(task_id)
|
|
193
|
+
@db.execute(
|
|
194
|
+
"SELECT t.* FROM tasks t JOIN task_dependencies d ON d.depends_on_id = t.id WHERE d.task_id = ? AND d.dependency_type = 'blocks'",
|
|
195
|
+
[task_id]
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def get_blocked_by_this(task_id)
|
|
200
|
+
@db.execute(
|
|
201
|
+
"SELECT t.* FROM tasks t JOIN task_dependencies d ON d.task_id = t.id WHERE d.depends_on_id = ? AND d.dependency_type = 'blocks'",
|
|
202
|
+
[task_id]
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# --- Subtasks ---
|
|
207
|
+
|
|
208
|
+
def add_subtask(parent_id, title, author: nil)
|
|
209
|
+
author ||= current_user
|
|
210
|
+
raise "Task ##{parent_id} not found" unless get_task_raw(parent_id)
|
|
211
|
+
|
|
212
|
+
task_id = add_task(title, author: author)
|
|
213
|
+
@db.execute(
|
|
214
|
+
"INSERT INTO task_dependencies (task_id, depends_on_id, dependency_type) VALUES (?, ?, 'subtask')",
|
|
215
|
+
[task_id, parent_id]
|
|
216
|
+
)
|
|
217
|
+
record_history(task_id, author, "subtask_created", "Created as subtask of ##{parent_id}")
|
|
218
|
+
record_history(parent_id, author, "subtask_added", "Added subtask ##{task_id}")
|
|
219
|
+
task_id
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def get_subtasks(task_id)
|
|
223
|
+
@db.execute(
|
|
224
|
+
"SELECT t.* FROM tasks t JOIN task_dependencies d ON d.task_id = t.id WHERE d.depends_on_id = ? AND d.dependency_type = 'subtask'",
|
|
225
|
+
[task_id]
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def get_parent(task_id)
|
|
230
|
+
@db.get_first_row(
|
|
231
|
+
"SELECT t.* FROM tasks t JOIN task_dependencies d ON d.depends_on_id = t.id WHERE d.task_id = ? AND d.dependency_type = 'subtask'",
|
|
232
|
+
[task_id]
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# --- History ---
|
|
237
|
+
|
|
238
|
+
def get_history(task_id)
|
|
239
|
+
@db.execute("SELECT * FROM task_history WHERE task_id = ? ORDER BY created_at ASC", [task_id])
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def activity_log
|
|
243
|
+
@db.execute(<<~SQL)
|
|
244
|
+
SELECT h.created_at, h.actor, h.action, h.detail, h.task_id, t.title
|
|
245
|
+
FROM task_history h
|
|
246
|
+
JOIN tasks t ON t.id = h.task_id
|
|
247
|
+
ORDER BY h.created_at DESC, h.id DESC
|
|
248
|
+
SQL
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def close
|
|
252
|
+
@db.close
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def schema_version
|
|
256
|
+
@db.get_first_value("PRAGMA user_version")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private
|
|
260
|
+
|
|
261
|
+
def get_task_raw(task_id)
|
|
262
|
+
@db.get_first_row("SELECT * FROM tasks WHERE id = ?", [task_id])
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def current_user
|
|
266
|
+
ENV["USER"] || ENV["USERNAME"] || "unspecified"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def record_history(task_id, actor, action, detail)
|
|
270
|
+
now = Time.now.utc.iso8601
|
|
271
|
+
@db.execute(
|
|
272
|
+
"INSERT INTO task_history (task_id, actor, action, detail, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
273
|
+
[task_id, actor, action, detail, now]
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
MIGRATIONS = [
|
|
278
|
+
# Version 1: Initial schema
|
|
279
|
+
<<~SQL
|
|
280
|
+
CREATE TABLE tasks (
|
|
281
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
282
|
+
title TEXT NOT NULL,
|
|
283
|
+
description TEXT,
|
|
284
|
+
board TEXT NOT NULL DEFAULT 'backlog',
|
|
285
|
+
author TEXT NOT NULL,
|
|
286
|
+
assignee TEXT,
|
|
287
|
+
created_at TEXT NOT NULL,
|
|
288
|
+
updated_at TEXT NOT NULL
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
CREATE TABLE comments (
|
|
292
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
293
|
+
task_id INTEGER NOT NULL,
|
|
294
|
+
author TEXT NOT NULL,
|
|
295
|
+
text TEXT NOT NULL,
|
|
296
|
+
created_at TEXT NOT NULL,
|
|
297
|
+
FOREIGN KEY (task_id) REFERENCES tasks(id)
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
CREATE TABLE task_history (
|
|
301
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
302
|
+
task_id INTEGER NOT NULL,
|
|
303
|
+
actor TEXT NOT NULL,
|
|
304
|
+
action TEXT NOT NULL,
|
|
305
|
+
detail TEXT,
|
|
306
|
+
created_at TEXT NOT NULL,
|
|
307
|
+
FOREIGN KEY (task_id) REFERENCES tasks(id)
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
CREATE TABLE task_dependencies (
|
|
311
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
312
|
+
task_id INTEGER NOT NULL,
|
|
313
|
+
depends_on_id INTEGER NOT NULL,
|
|
314
|
+
dependency_type TEXT NOT NULL,
|
|
315
|
+
FOREIGN KEY (task_id) REFERENCES tasks(id),
|
|
316
|
+
FOREIGN KEY (depends_on_id) REFERENCES tasks(id),
|
|
317
|
+
UNIQUE(task_id, depends_on_id, dependency_type)
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
CREATE INDEX idx_tasks_board ON tasks(board);
|
|
321
|
+
CREATE INDEX idx_comments_task_id ON comments(task_id);
|
|
322
|
+
CREATE INDEX idx_task_history_task_id ON task_history(task_id);
|
|
323
|
+
CREATE INDEX idx_task_dependencies_task_id ON task_dependencies(task_id);
|
|
324
|
+
CREATE INDEX idx_task_dependencies_depends_on ON task_dependencies(depends_on_id);
|
|
325
|
+
SQL
|
|
326
|
+
# To add a new migration:
|
|
327
|
+
# 1. Append a new SQL string to this array
|
|
328
|
+
# 2. It will run automatically on databases that haven't applied it yet
|
|
329
|
+
# 3. Never modify existing migrations -- always add new ones
|
|
330
|
+
].freeze
|
|
331
|
+
|
|
332
|
+
def migrate!
|
|
333
|
+
current = schema_version
|
|
334
|
+
|
|
335
|
+
MIGRATIONS.each_with_index do |sql, index|
|
|
336
|
+
version = index + 1
|
|
337
|
+
next if version <= current
|
|
338
|
+
|
|
339
|
+
@db.transaction do
|
|
340
|
+
@db.execute_batch(sql)
|
|
341
|
+
@db.execute("PRAGMA user_version = #{version}")
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|