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 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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/pylonite"
4
+
5
+ Pylonite::CLI.run(ARGV)
@@ -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