@proletariat/cli 0.3.36 → 0.3.40

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 (64) hide show
  1. package/README.md +37 -2
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/branch/where.js +6 -17
  4. package/dist/commands/epic/ticket.js +7 -24
  5. package/dist/commands/execution/config.js +4 -14
  6. package/dist/commands/execution/logs.js +6 -0
  7. package/dist/commands/execution/view.js +8 -0
  8. package/dist/commands/mcp-server.js +2 -1
  9. package/dist/commands/pmo/init.js +12 -40
  10. package/dist/commands/qa/index.d.ts +54 -0
  11. package/dist/commands/qa/index.js +762 -0
  12. package/dist/commands/repo/view.js +2 -8
  13. package/dist/commands/session/attach.js +4 -4
  14. package/dist/commands/session/health.js +4 -4
  15. package/dist/commands/session/list.js +1 -19
  16. package/dist/commands/session/peek.js +6 -6
  17. package/dist/commands/session/poke.js +2 -2
  18. package/dist/commands/ticket/epic.js +17 -43
  19. package/dist/commands/work/spawn-all.js +1 -1
  20. package/dist/commands/work/spawn.js +15 -4
  21. package/dist/commands/work/start.js +17 -9
  22. package/dist/commands/work/watch.js +1 -1
  23. package/dist/commands/workspace/prune.js +3 -3
  24. package/dist/hooks/init.js +10 -2
  25. package/dist/lib/agents/commands.d.ts +5 -0
  26. package/dist/lib/agents/commands.js +143 -97
  27. package/dist/lib/database/drizzle-schema.d.ts +465 -0
  28. package/dist/lib/database/drizzle-schema.js +53 -0
  29. package/dist/lib/database/index.d.ts +47 -1
  30. package/dist/lib/database/index.js +138 -20
  31. package/dist/lib/execution/runners.d.ts +34 -0
  32. package/dist/lib/execution/runners.js +134 -7
  33. package/dist/lib/execution/session-utils.d.ts +5 -0
  34. package/dist/lib/execution/session-utils.js +45 -3
  35. package/dist/lib/execution/spawner.js +15 -2
  36. package/dist/lib/execution/storage.d.ts +1 -1
  37. package/dist/lib/execution/storage.js +17 -2
  38. package/dist/lib/execution/types.d.ts +1 -0
  39. package/dist/lib/mcp/tools/index.d.ts +1 -0
  40. package/dist/lib/mcp/tools/index.js +1 -0
  41. package/dist/lib/mcp/tools/tmux.d.ts +16 -0
  42. package/dist/lib/mcp/tools/tmux.js +182 -0
  43. package/dist/lib/mcp/tools/work.js +52 -0
  44. package/dist/lib/pmo/schema.d.ts +1 -1
  45. package/dist/lib/pmo/schema.js +1 -0
  46. package/dist/lib/pmo/storage/base.js +207 -0
  47. package/dist/lib/pmo/storage/dependencies.d.ts +1 -0
  48. package/dist/lib/pmo/storage/dependencies.js +11 -3
  49. package/dist/lib/pmo/storage/epics.js +1 -1
  50. package/dist/lib/pmo/storage/helpers.d.ts +4 -4
  51. package/dist/lib/pmo/storage/helpers.js +36 -26
  52. package/dist/lib/pmo/storage/projects.d.ts +2 -0
  53. package/dist/lib/pmo/storage/projects.js +207 -119
  54. package/dist/lib/pmo/storage/specs.d.ts +2 -0
  55. package/dist/lib/pmo/storage/specs.js +274 -188
  56. package/dist/lib/pmo/storage/tickets.d.ts +2 -0
  57. package/dist/lib/pmo/storage/tickets.js +350 -290
  58. package/dist/lib/pmo/storage/views.d.ts +2 -0
  59. package/dist/lib/pmo/storage/views.js +183 -130
  60. package/dist/lib/prompt-json.d.ts +5 -0
  61. package/dist/lib/prompt-json.js +9 -0
  62. package/oclif.manifest.json +3922 -3819
  63. package/package.json +11 -6
  64. package/LICENSE +0 -190
@@ -3,12 +3,14 @@
3
3
  * Tickets reference workflow statuses directly via status_id.
4
4
  * Tickets have a position column for force-ranked ordering within a status.
5
5
  * Positions use gapped integers (1000, 2000, 3000...) for stable reordering.
6
+ *
7
+ * This module uses Drizzle ORM for type-safe database queries.
6
8
  */
7
- import { PMO_TABLES } from '../schema.js';
9
+ import { eq, and, like, or, asc, sql, gt } from 'drizzle-orm';
10
+ import { pmoTickets, pmoProjects, pmoWorkflowStatuses, pmoSubtasks, pmoTicketMetadata, pmoCategories, pmoTicketLabels, pmoLabels, pmoLabelGroups, } from '../../database/drizzle-schema.js';
8
11
  import { PMOError } from '../types.js';
9
12
  import { slugify, generateEntityId } from '../utils.js';
10
13
  import { rowToTicket, wrapSqliteError } from './helpers.js';
11
- const T = PMO_TABLES;
12
14
  export class TicketStorage {
13
15
  ctx;
14
16
  constructor(ctx) {
@@ -21,15 +23,18 @@ export class TicketStorage {
21
23
  async validateCategory(category) {
22
24
  if (!category)
23
25
  return null;
24
- // Check if category exists in DB for ticket type
25
- const row = this.ctx.db.prepare(`
26
- SELECT name FROM ${T.categories} WHERE LOWER(name) = LOWER(?) AND type = 'ticket'
27
- `).get(category);
26
+ const row = this.ctx.drizzle
27
+ .select({ name: pmoCategories.name })
28
+ .from(pmoCategories)
29
+ .where(and(sql `LOWER(${pmoCategories.name}) = LOWER(${category})`, eq(pmoCategories.type, 'ticket')))
30
+ .get();
28
31
  if (!row) {
29
- // Get valid categories for error message
30
- const validCategories = this.ctx.db.prepare(`
31
- SELECT name FROM ${T.categories} WHERE type = 'ticket' ORDER BY position
32
- `).all();
32
+ const validCategories = this.ctx.drizzle
33
+ .select({ name: pmoCategories.name })
34
+ .from(pmoCategories)
35
+ .where(eq(pmoCategories.type, 'ticket'))
36
+ .orderBy(asc(pmoCategories.position))
37
+ .all();
33
38
  const validNames = validCategories.map(c => c.name).join(', ');
34
39
  throw new PMOError('INVALID', `Invalid category "${category}". Valid categories: ${validNames}`);
35
40
  }
@@ -48,33 +53,42 @@ export class TicketStorage {
48
53
  if (!identifier)
49
54
  return null;
50
55
  // 1. Exact ID match
51
- const exactMatch = this.ctx.db.prepare(`
52
- SELECT id FROM ${T.projects} WHERE id = ?
53
- `).get(identifier);
56
+ const exactMatch = this.ctx.drizzle
57
+ .select({ id: pmoProjects.id })
58
+ .from(pmoProjects)
59
+ .where(eq(pmoProjects.id, identifier))
60
+ .get();
54
61
  if (exactMatch)
55
62
  return exactMatch.id;
56
63
  // 2. Case-insensitive ID match
57
- const caseInsensitiveId = this.ctx.db.prepare(`
58
- SELECT id FROM ${T.projects} WHERE LOWER(id) = LOWER(?)
59
- `).get(identifier);
64
+ const caseInsensitiveId = this.ctx.drizzle
65
+ .select({ id: pmoProjects.id })
66
+ .from(pmoProjects)
67
+ .where(sql `LOWER(${pmoProjects.id}) = LOWER(${identifier})`)
68
+ .get();
60
69
  if (caseInsensitiveId)
61
70
  return caseInsensitiveId.id;
62
71
  // 3. Exact name match
63
- const nameMatch = this.ctx.db.prepare(`
64
- SELECT id FROM ${T.projects} WHERE name = ?
65
- `).get(identifier);
72
+ const nameMatch = this.ctx.drizzle
73
+ .select({ id: pmoProjects.id })
74
+ .from(pmoProjects)
75
+ .where(eq(pmoProjects.name, identifier))
76
+ .get();
66
77
  if (nameMatch)
67
78
  return nameMatch.id;
68
79
  // 4. Case-insensitive name match
69
- const caseInsensitiveName = this.ctx.db.prepare(`
70
- SELECT id FROM ${T.projects} WHERE LOWER(name) = LOWER(?)
71
- `).get(identifier);
80
+ const caseInsensitiveName = this.ctx.drizzle
81
+ .select({ id: pmoProjects.id })
82
+ .from(pmoProjects)
83
+ .where(sql `LOWER(${pmoProjects.name}) = LOWER(${identifier})`)
84
+ .get();
72
85
  if (caseInsensitiveName)
73
86
  return caseInsensitiveName.id;
74
87
  // 5. Slugified name match
75
- const allProjects = this.ctx.db.prepare(`
76
- SELECT id, name FROM ${T.projects}
77
- `).all();
88
+ const allProjects = this.ctx.drizzle
89
+ .select({ id: pmoProjects.id, name: pmoProjects.name })
90
+ .from(pmoProjects)
91
+ .all();
78
92
  const identifierLower = identifier.toLowerCase();
79
93
  for (const project of allProjects) {
80
94
  const projectSlug = slugify(project.name);
@@ -98,48 +112,51 @@ export class TicketStorage {
98
112
  // Get status_id from project's workflow
99
113
  let statusId = ticket.statusId;
100
114
  // Get the project's workflow
101
- const project = this.ctx.db.prepare(`
102
- SELECT workflow_id FROM ${T.projects} WHERE id = ?
103
- `).get(projectId);
115
+ const project = this.ctx.drizzle
116
+ .select({ workflowId: pmoProjects.workflowId })
117
+ .from(pmoProjects)
118
+ .where(eq(pmoProjects.id, projectId))
119
+ .get();
104
120
  if (!project) {
105
121
  throw new PMOError('NOT_FOUND', `Project not found: ${projectId}`);
106
122
  }
107
- const workflowId = project.workflow_id || 'default';
123
+ const workflowId = project.workflowId || 'default';
108
124
  // If statusName is provided, look up status by name
109
125
  if (!statusId && ticket.statusName) {
110
- const namedStatus = this.ctx.db.prepare(`
111
- SELECT id FROM ${T.workflow_statuses}
112
- WHERE workflow_id = ? AND LOWER(name) = LOWER(?)
113
- `).get(workflowId, ticket.statusName);
126
+ const namedStatus = this.ctx.drizzle
127
+ .select({ id: pmoWorkflowStatuses.id })
128
+ .from(pmoWorkflowStatuses)
129
+ .where(and(eq(pmoWorkflowStatuses.workflowId, workflowId), sql `LOWER(${pmoWorkflowStatuses.name}) = LOWER(${ticket.statusName})`))
130
+ .get();
114
131
  if (namedStatus) {
115
132
  statusId = namedStatus.id;
116
133
  }
117
134
  }
118
135
  if (!statusId) {
119
136
  // Get default status from workflow
120
- const defaultStatus = this.ctx.db.prepare(`
121
- SELECT id FROM ${T.workflow_statuses}
122
- WHERE workflow_id = ? AND is_default = 1
123
- `).get(workflowId);
137
+ const defaultStatus = this.ctx.drizzle
138
+ .select({ id: pmoWorkflowStatuses.id })
139
+ .from(pmoWorkflowStatuses)
140
+ .where(and(eq(pmoWorkflowStatuses.workflowId, workflowId), eq(pmoWorkflowStatuses.isDefault, true)))
141
+ .get();
124
142
  if (defaultStatus) {
125
143
  statusId = defaultStatus.id;
126
144
  }
127
145
  else {
128
146
  // Fall back to first status in workflow (by category then position)
129
- const firstStatus = this.ctx.db.prepare(`
130
- SELECT id FROM ${T.workflow_statuses}
131
- WHERE workflow_id = ?
132
- ORDER BY
133
- CASE category
147
+ const firstStatus = this.ctx.drizzle
148
+ .select({ id: pmoWorkflowStatuses.id })
149
+ .from(pmoWorkflowStatuses)
150
+ .where(eq(pmoWorkflowStatuses.workflowId, workflowId))
151
+ .orderBy(sql `CASE ${pmoWorkflowStatuses.category}
134
152
  WHEN 'backlog' THEN 1
135
153
  WHEN 'unstarted' THEN 2
136
154
  WHEN 'started' THEN 3
137
155
  WHEN 'completed' THEN 4
138
156
  WHEN 'canceled' THEN 5
139
- END,
140
- position ASC
141
- LIMIT 1
142
- `).get(workflowId);
157
+ END`, asc(pmoWorkflowStatuses.position))
158
+ .limit(1)
159
+ .get();
143
160
  if (firstStatus) {
144
161
  statusId = firstStatus.id;
145
162
  }
@@ -149,43 +166,60 @@ export class TicketStorage {
149
166
  }
150
167
  }
151
168
  // Get next position for the target status (append to end with gapped integer)
152
- const maxPos = this.ctx.db.prepare(`
153
- SELECT COALESCE(MAX(position), 0) as max_pos FROM ${T.tickets} WHERE status_id = ?
154
- `).get(statusId);
155
- const position = maxPos.max_pos + 1000;
169
+ const maxPos = this.ctx.drizzle
170
+ .select({ maxPos: sql `COALESCE(MAX(${pmoTickets.position}), 0)` })
171
+ .from(pmoTickets)
172
+ .where(eq(pmoTickets.statusId, statusId))
173
+ .get();
174
+ const position = (maxPos?.maxPos ?? 0) + 1000;
156
175
  // Insert ticket
157
176
  const labels = ticket.labels || [];
158
177
  try {
159
- this.ctx.db.prepare(`
160
- INSERT INTO ${T.tickets} (
161
- id, project_id, title, description, priority, category,
162
- status_id, owner, assignee, spec_id, epic_id, labels,
163
- position, created_at, updated_at, last_synced_from_spec, last_synced_from_board
164
- )
165
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
166
- `).run(id, projectId, title, ticket.description || null, ticket.priority || null, validatedCategory, statusId, ticket.owner || null, ticket.assignee || null, specId, ticket.epicId || null, JSON.stringify(labels), position, now, now, ticket.lastSyncedFromSpec || null, ticket.lastSyncedFromBoard || null);
178
+ const ticketValues = {
179
+ id,
180
+ projectId,
181
+ title,
182
+ description: ticket.description || null,
183
+ priority: ticket.priority || null,
184
+ category: validatedCategory,
185
+ status: 'backlog',
186
+ statusId: statusId,
187
+ owner: ticket.owner || null,
188
+ assignee: ticket.assignee || null,
189
+ specId,
190
+ epicId: ticket.epicId || null,
191
+ labels: JSON.stringify(labels),
192
+ position,
193
+ createdAt: String(now),
194
+ updatedAt: String(now),
195
+ lastSyncedFromSpec: ticket.lastSyncedFromSpec ? String(ticket.lastSyncedFromSpec) : null,
196
+ lastSyncedFromBoard: ticket.lastSyncedFromBoard ? String(ticket.lastSyncedFromBoard) : null,
197
+ };
198
+ this.ctx.drizzle.insert(pmoTickets).values(ticketValues).run();
167
199
  }
168
200
  catch (err) {
169
201
  wrapSqliteError('Ticket', 'create', err);
170
202
  }
171
203
  // Insert subtasks
172
204
  if (ticket.subtasks && ticket.subtasks.length > 0) {
173
- const insertSubtask = this.ctx.db.prepare(`
174
- INSERT INTO ${T.subtasks} (id, ticket_id, title, done, position)
175
- VALUES (?, ?, ?, ?, ?)
176
- `);
177
- ticket.subtasks.forEach((st, idx) => {
178
- insertSubtask.run(st.id || slugify(st.title), id, st.title, st.done ? 1 : 0, idx);
179
- });
205
+ for (const [idx, st] of ticket.subtasks.entries()) {
206
+ this.ctx.drizzle.insert(pmoSubtasks).values({
207
+ id: st.id || slugify(st.title),
208
+ ticketId: id,
209
+ title: st.title,
210
+ done: st.done || false,
211
+ position: idx,
212
+ }).run();
213
+ }
180
214
  }
181
215
  // Insert metadata
182
216
  if (ticket.metadata) {
183
- const insertMeta = this.ctx.db.prepare(`
184
- INSERT INTO ${T.ticket_metadata} (ticket_id, key, value)
185
- VALUES (?, ?, ?)
186
- `);
187
217
  for (const [key, value] of Object.entries(ticket.metadata)) {
188
- insertMeta.run(id, key, value);
218
+ this.ctx.drizzle.insert(pmoTicketMetadata).values({
219
+ ticketId: id,
220
+ key,
221
+ value,
222
+ }).run();
189
223
  }
190
224
  }
191
225
  this.ctx.updateBoardTimestamp(projectId);
@@ -203,18 +237,38 @@ export class TicketStorage {
203
237
  * Joins workflow_statuses to get column name (status name is the column).
204
238
  */
205
239
  async getTicketById(id) {
206
- const row = this.ctx.db.prepare(`
207
- SELECT t.*,
208
- ws.id as column_id,
209
- t.position as position,
210
- ws.name as column_name
211
- FROM ${T.tickets} t
212
- LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
213
- WHERE LOWER(t.id) = LOWER(?)
214
- `).get(id);
215
- if (!row)
240
+ const rows = this.ctx.drizzle
241
+ .select({
242
+ id: pmoTickets.id,
243
+ project_id: pmoTickets.projectId,
244
+ title: pmoTickets.title,
245
+ description: pmoTickets.description,
246
+ priority: pmoTickets.priority,
247
+ category: pmoTickets.category,
248
+ status_id: pmoTickets.statusId,
249
+ owner: pmoTickets.owner,
250
+ assignee: pmoTickets.assignee,
251
+ branch: pmoTickets.branch,
252
+ spec_id: pmoTickets.specId,
253
+ epic_id: pmoTickets.epicId,
254
+ labels: pmoTickets.labels,
255
+ position: pmoTickets.position,
256
+ created_at: pmoTickets.createdAt,
257
+ updated_at: pmoTickets.updatedAt,
258
+ last_synced_from_spec: pmoTickets.lastSyncedFromSpec,
259
+ last_synced_from_board: pmoTickets.lastSyncedFromBoard,
260
+ column_id: pmoWorkflowStatuses.id,
261
+ column_name: pmoWorkflowStatuses.name,
262
+ project_name: sql `NULL`,
263
+ })
264
+ .from(pmoTickets)
265
+ .leftJoin(pmoWorkflowStatuses, eq(pmoTickets.statusId, pmoWorkflowStatuses.id))
266
+ .where(sql `LOWER(${pmoTickets.id}) = LOWER(${id})`)
267
+ .all();
268
+ if (rows.length === 0)
216
269
  return null;
217
- return rowToTicket(this.ctx.db, row);
270
+ const row = rows[0];
271
+ return rowToTicket(this.ctx.drizzle, row);
218
272
  }
219
273
  /**
220
274
  * Update a ticket.
@@ -230,82 +284,63 @@ export class TicketStorage {
230
284
  if (changes.category !== undefined) {
231
285
  validatedCategory = await this.validateCategory(changes.category);
232
286
  }
233
- const updates = [];
234
- const params = [];
235
- if (changes.title !== undefined) {
236
- updates.push('title = ?');
237
- params.push(changes.title);
238
- }
239
- if (changes.description !== undefined) {
240
- updates.push('description = ?');
241
- params.push(changes.description);
242
- }
243
- if (changes.priority !== undefined) {
244
- updates.push('priority = ?');
245
- params.push(changes.priority);
246
- }
247
- if (validatedCategory !== undefined) {
248
- updates.push('category = ?');
249
- params.push(validatedCategory);
250
- }
251
- if (changes.statusId !== undefined) {
252
- updates.push('status_id = ?');
253
- params.push(changes.statusId);
254
- }
255
- if (changes.owner !== undefined) {
256
- updates.push('owner = ?');
257
- params.push(changes.owner);
258
- }
259
- if (changes.assignee !== undefined) {
260
- updates.push('assignee = ?');
261
- params.push(changes.assignee);
262
- }
263
- if (changes.branch !== undefined) {
264
- updates.push('branch = ?');
265
- params.push(changes.branch);
266
- }
267
- if (changes.specId !== undefined) {
268
- updates.push('spec_id = ?');
269
- params.push(changes.specId);
270
- }
287
+ const updates = {};
288
+ if (changes.title !== undefined)
289
+ updates.title = changes.title;
290
+ if (changes.description !== undefined)
291
+ updates.description = changes.description;
292
+ if (changes.priority !== undefined)
293
+ updates.priority = changes.priority;
294
+ if (validatedCategory !== undefined)
295
+ updates.category = validatedCategory;
296
+ if (changes.statusId !== undefined)
297
+ updates.statusId = changes.statusId;
298
+ if (changes.owner !== undefined)
299
+ updates.owner = changes.owner;
300
+ if (changes.assignee !== undefined)
301
+ updates.assignee = changes.assignee;
302
+ if (changes.branch !== undefined)
303
+ updates.branch = changes.branch;
304
+ if (changes.specId !== undefined)
305
+ updates.specId = changes.specId;
271
306
  if (changes.lastSyncedFromSpec !== undefined) {
272
- updates.push('last_synced_from_spec = ?');
273
- params.push(changes.lastSyncedFromSpec);
307
+ updates.lastSyncedFromSpec = changes.lastSyncedFromSpec;
274
308
  }
275
309
  if (changes.lastSyncedFromBoard !== undefined) {
276
- updates.push('last_synced_from_board = ?');
277
- params.push(changes.lastSyncedFromBoard);
278
- }
279
- if (changes.labels !== undefined) {
280
- updates.push('labels = ?');
281
- params.push(JSON.stringify(changes.labels));
282
- }
283
- if (updates.length > 0) {
284
- updates.push('updated_at = ?');
285
- params.push(Date.now());
286
- params.push(id);
287
- this.ctx.db.prepare(`UPDATE ${T.tickets} SET ${updates.join(', ')} WHERE id = ?`).run(...params);
310
+ updates.lastSyncedFromBoard = changes.lastSyncedFromBoard;
311
+ }
312
+ if (changes.labels !== undefined)
313
+ updates.labels = JSON.stringify(changes.labels);
314
+ if (Object.keys(updates).length > 0) {
315
+ updates.updatedAt = String(Date.now());
316
+ this.ctx.drizzle
317
+ .update(pmoTickets)
318
+ .set(updates)
319
+ .where(eq(pmoTickets.id, id))
320
+ .run();
288
321
  }
289
322
  // Update subtasks if provided
290
323
  if (changes.subtasks !== undefined) {
291
- this.ctx.db.prepare(`DELETE FROM ${T.subtasks} WHERE ticket_id = ?`).run(id);
292
- const insertSubtask = this.ctx.db.prepare(`
293
- INSERT INTO ${T.subtasks} (id, ticket_id, title, done, position)
294
- VALUES (?, ?, ?, ?, ?)
295
- `);
296
- changes.subtasks.forEach((st, idx) => {
297
- insertSubtask.run(st.id || slugify(st.title), id, st.title, st.done ? 1 : 0, idx);
298
- });
324
+ this.ctx.drizzle.delete(pmoSubtasks).where(eq(pmoSubtasks.ticketId, id)).run();
325
+ for (const [idx, st] of changes.subtasks.entries()) {
326
+ this.ctx.drizzle.insert(pmoSubtasks).values({
327
+ id: st.id || slugify(st.title),
328
+ ticketId: id,
329
+ title: st.title,
330
+ done: st.done || false,
331
+ position: idx,
332
+ }).run();
333
+ }
299
334
  }
300
335
  // Update metadata if provided
301
336
  if (changes.metadata !== undefined) {
302
- this.ctx.db.prepare(`DELETE FROM ${T.ticket_metadata} WHERE ticket_id = ?`).run(id);
303
- const insertMeta = this.ctx.db.prepare(`
304
- INSERT INTO ${T.ticket_metadata} (ticket_id, key, value)
305
- VALUES (?, ?, ?)
306
- `);
337
+ this.ctx.drizzle.delete(pmoTicketMetadata).where(eq(pmoTicketMetadata.ticketId, id)).run();
307
338
  for (const [key, value] of Object.entries(changes.metadata)) {
308
- insertMeta.run(id, key, value);
339
+ this.ctx.drizzle.insert(pmoTicketMetadata).values({
340
+ ticketId: id,
341
+ key,
342
+ value,
343
+ }).run();
309
344
  }
310
345
  }
311
346
  // Update board timestamp for the ticket's actual project
@@ -318,11 +353,11 @@ export class TicketStorage {
318
353
  * Update the timestamp for a specific project.
319
354
  */
320
355
  updateProjectTimestamp(projectId) {
321
- this.ctx.db.prepare(`
322
- UPDATE ${T.projects}
323
- SET updated_at = ?
324
- WHERE id = ?
325
- `).run(Date.now(), projectId);
356
+ this.ctx.drizzle
357
+ .update(pmoProjects)
358
+ .set({ updatedAt: String(Date.now()) })
359
+ .where(eq(pmoProjects.id, projectId))
360
+ .run();
326
361
  }
327
362
  /**
328
363
  * Move a ticket to a different status (column).
@@ -336,18 +371,21 @@ export class TicketStorage {
336
371
  throw new PMOError('NOT_FOUND', `Ticket not found: ${id}`, id);
337
372
  }
338
373
  // Get project's workflow
339
- const project = this.ctx.db.prepare(`
340
- SELECT workflow_id FROM ${T.projects} WHERE id = ?
341
- `).get(projectId);
374
+ const project = this.ctx.drizzle
375
+ .select({ workflowId: pmoProjects.workflowId })
376
+ .from(pmoProjects)
377
+ .where(eq(pmoProjects.id, projectId))
378
+ .get();
342
379
  if (!project) {
343
380
  throw new PMOError('NOT_FOUND', `Project not found: ${projectId}`);
344
381
  }
345
- const workflowId = project.workflow_id || 'default';
382
+ const workflowId = project.workflowId || 'default';
346
383
  // Find target status by ID or name
347
- const targetStatus = this.ctx.db.prepare(`
348
- SELECT id FROM ${T.workflow_statuses}
349
- WHERE workflow_id = ? AND (id = ? OR LOWER(name) = LOWER(?))
350
- `).get(workflowId, column, column);
384
+ const targetStatus = this.ctx.drizzle
385
+ .select({ id: pmoWorkflowStatuses.id })
386
+ .from(pmoWorkflowStatuses)
387
+ .where(and(eq(pmoWorkflowStatuses.workflowId, workflowId), or(eq(pmoWorkflowStatuses.id, column), sql `LOWER(${pmoWorkflowStatuses.name}) = LOWER(${column})`)))
388
+ .get();
351
389
  if (!targetStatus) {
352
390
  throw new PMOError('NOT_FOUND', `Status not found: ${column}`);
353
391
  }
@@ -357,17 +395,23 @@ export class TicketStorage {
357
395
  newPosition = position;
358
396
  }
359
397
  else {
360
- const maxPos = this.ctx.db.prepare(`
361
- SELECT COALESCE(MAX(position), 0) as max_pos FROM ${T.tickets} WHERE status_id = ?
362
- `).get(targetStatus.id);
363
- newPosition = maxPos.max_pos + 1000;
398
+ const maxPos = this.ctx.drizzle
399
+ .select({ maxPos: sql `COALESCE(MAX(${pmoTickets.position}), 0)` })
400
+ .from(pmoTickets)
401
+ .where(eq(pmoTickets.statusId, targetStatus.id))
402
+ .get();
403
+ newPosition = (maxPos?.maxPos ?? 0) + 1000;
364
404
  }
365
405
  // Update ticket's status_id and position
366
- this.ctx.db.prepare(`
367
- UPDATE ${T.tickets}
368
- SET status_id = ?, position = ?, updated_at = ?
369
- WHERE id = ?
370
- `).run(targetStatus.id, newPosition, Date.now(), id);
406
+ this.ctx.drizzle
407
+ .update(pmoTickets)
408
+ .set({
409
+ statusId: targetStatus.id,
410
+ position: newPosition,
411
+ updatedAt: String(Date.now()),
412
+ })
413
+ .where(eq(pmoTickets.id, id))
414
+ .run();
371
415
  this.ctx.updateBoardTimestamp(projectId);
372
416
  return (await this.getTicketById(id));
373
417
  }
@@ -395,12 +439,13 @@ export class TicketStorage {
395
439
  }
396
440
  const afterPosition = afterTicket.position ?? 0;
397
441
  // Find the next ticket after the target
398
- const nextTicket = this.ctx.db.prepare(`
399
- SELECT position FROM ${T.tickets}
400
- WHERE status_id = ? AND position > ? AND id != ?
401
- ORDER BY position ASC
402
- LIMIT 1
403
- `).get(existing.statusId, afterPosition, id);
442
+ const nextTicket = this.ctx.drizzle
443
+ .select({ position: pmoTickets.position })
444
+ .from(pmoTickets)
445
+ .where(and(eq(pmoTickets.statusId, existing.statusId), gt(pmoTickets.position, afterPosition), sql `${pmoTickets.id} != ${id}`))
446
+ .orderBy(asc(pmoTickets.position))
447
+ .limit(1)
448
+ .get();
404
449
  if (nextTicket) {
405
450
  // Place between afterTicket and nextTicket
406
451
  const gap = nextTicket.position - afterPosition;
@@ -413,12 +458,13 @@ export class TicketStorage {
413
458
  // Re-read the after ticket position after regapping
414
459
  const refreshedAfter = await this.getTicketById(opts.afterTicketId);
415
460
  const refreshedAfterPos = refreshedAfter?.position ?? 0;
416
- const refreshedNext = this.ctx.db.prepare(`
417
- SELECT position FROM ${T.tickets}
418
- WHERE status_id = ? AND position > ? AND id != ?
419
- ORDER BY position ASC
420
- LIMIT 1
421
- `).get(existing.statusId, refreshedAfterPos, id);
461
+ const refreshedNext = this.ctx.drizzle
462
+ .select({ position: pmoTickets.position })
463
+ .from(pmoTickets)
464
+ .where(and(eq(pmoTickets.statusId, existing.statusId), gt(pmoTickets.position, refreshedAfterPos), sql `${pmoTickets.id} != ${id}`))
465
+ .orderBy(asc(pmoTickets.position))
466
+ .limit(1)
467
+ .get();
422
468
  newPosition = refreshedNext
423
469
  ? refreshedAfterPos + Math.floor((refreshedNext.position - refreshedAfterPos) / 2)
424
470
  : refreshedAfterPos + 1000;
@@ -435,11 +481,14 @@ export class TicketStorage {
435
481
  else {
436
482
  throw new PMOError('INVALID', 'Must provide either position or after_ticket_id');
437
483
  }
438
- this.ctx.db.prepare(`
439
- UPDATE ${T.tickets}
440
- SET position = ?, updated_at = ?
441
- WHERE id = ?
442
- `).run(newPosition, Date.now(), id);
484
+ this.ctx.drizzle
485
+ .update(pmoTickets)
486
+ .set({
487
+ position: newPosition,
488
+ updatedAt: String(Date.now()),
489
+ })
490
+ .where(eq(pmoTickets.id, id))
491
+ .run();
443
492
  if (existing.projectId) {
444
493
  this.ctx.updateBoardTimestamp(existing.projectId);
445
494
  }
@@ -450,24 +499,24 @@ export class TicketStorage {
450
499
  * Optionally excludes a ticket (e.g., the one being moved).
451
500
  */
452
501
  regapPositions(statusId, excludeTicketId) {
453
- let query = `
454
- SELECT id FROM ${T.tickets}
455
- WHERE status_id = ?
456
- `;
457
- const params = [statusId];
502
+ const conditions = [eq(pmoTickets.statusId, statusId)];
458
503
  if (excludeTicketId) {
459
- query += ' AND id != ?';
460
- params.push(excludeTicketId);
461
- }
462
- query += ' ORDER BY position ASC, created_at ASC';
463
- const tickets = this.ctx.db.prepare(query).all(...params);
464
- const update = this.ctx.db.prepare(`UPDATE ${T.tickets} SET position = ? WHERE id = ?`);
465
- const regap = this.ctx.db.transaction(() => {
504
+ conditions.push(sql `${pmoTickets.id} != ${excludeTicketId}`);
505
+ }
506
+ const tickets = this.ctx.drizzle
507
+ .select({ id: pmoTickets.id })
508
+ .from(pmoTickets)
509
+ .where(and(...conditions))
510
+ .orderBy(asc(pmoTickets.position), asc(pmoTickets.createdAt))
511
+ .all();
512
+ this.ctx.drizzle.transaction((tx) => {
466
513
  tickets.forEach((ticket, idx) => {
467
- update.run((idx + 1) * 1000, ticket.id);
514
+ tx.update(pmoTickets)
515
+ .set({ position: (idx + 1) * 1000 })
516
+ .where(eq(pmoTickets.id, ticket.id))
517
+ .run();
468
518
  });
469
519
  });
470
- regap();
471
520
  }
472
521
  /**
473
522
  * Delete a ticket.
@@ -485,10 +534,10 @@ export class TicketStorage {
485
534
  // Delete ticket (by ID only, since IDs are globally unique)
486
535
  // Related data (subtasks, metadata) are deleted via CASCADE
487
536
  try {
488
- const result = this.ctx.db.prepare(`
489
- DELETE FROM ${T.tickets}
490
- WHERE id = ?
491
- `).run(id);
537
+ const result = this.ctx.drizzle
538
+ .delete(pmoTickets)
539
+ .where(eq(pmoTickets.id, id))
540
+ .run();
492
541
  if (result.changes === 0) {
493
542
  throw new PMOError('NOT_FOUND', `Ticket not found: ${id}`, id);
494
543
  }
@@ -507,7 +556,6 @@ export class TicketStorage {
507
556
  * @param filter - Additional filters to apply.
508
557
  */
509
558
  async listTickets(projectIdOrName, filter) {
510
- const params = [];
511
559
  // Resolve project identifier to actual ID if provided
512
560
  let resolvedProjectId;
513
561
  if (projectIdOrName !== undefined) {
@@ -517,91 +565,94 @@ export class TicketStorage {
517
565
  resolvedProjectId = projectIdOrName;
518
566
  }
519
567
  }
520
- // Build the base query using workflow_statuses
521
- let query = `
522
- SELECT t.*,
523
- ws.id as column_id,
524
- t.position as position,
525
- ws.name as column_name,
526
- p.name as project_name
527
- FROM ${T.tickets} t
528
- LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
529
- LEFT JOIN ${T.projects} p ON t.project_id = p.id
530
- WHERE 1=1
531
- `;
568
+ // Build conditions array for dynamic WHERE clause
569
+ const conditions = [];
532
570
  // Apply project scoping
533
571
  if (resolvedProjectId !== undefined) {
534
- query += ' AND t.project_id = ?';
535
- params.push(resolvedProjectId);
572
+ conditions.push(eq(pmoTickets.projectId, resolvedProjectId));
536
573
  }
537
- // If projectId is undefined, list all tickets across all projects
538
574
  if (filter?.statusId) {
539
- query += ' AND t.status_id = ?';
540
- params.push(filter.statusId);
575
+ conditions.push(eq(pmoTickets.statusId, filter.statusId));
541
576
  }
542
577
  if (filter?.statusCategory) {
543
- query += ' AND ws.category = ?';
544
- params.push(filter.statusCategory);
578
+ conditions.push(eq(pmoWorkflowStatuses.category, filter.statusCategory));
545
579
  }
546
580
  if (filter?.priority) {
547
- query += ' AND t.priority = ?';
548
- params.push(filter.priority);
581
+ conditions.push(eq(pmoTickets.priority, filter.priority));
549
582
  }
550
583
  if (filter?.category) {
551
- query += ' AND t.category = ?';
552
- params.push(filter.category);
584
+ conditions.push(eq(pmoTickets.category, filter.category));
553
585
  }
554
586
  if (filter?.owner) {
555
- query += ' AND t.owner = ?';
556
- params.push(filter.owner);
587
+ conditions.push(eq(pmoTickets.owner, filter.owner));
557
588
  }
558
589
  if (filter?.assignee) {
559
- query += ' AND t.assignee = ?';
560
- params.push(filter.assignee);
590
+ conditions.push(eq(pmoTickets.assignee, filter.assignee));
561
591
  }
562
592
  if (filter?.search) {
563
- query += ' AND (t.title LIKE ? OR t.description LIKE ?)';
564
- params.push(`%${filter.search}%`, `%${filter.search}%`);
593
+ conditions.push(or(like(pmoTickets.title, `%${filter.search}%`), like(pmoTickets.description, `%${filter.search}%`)));
565
594
  }
566
595
  if (filter?.spec) {
567
- query += ' AND t.spec_id = ?';
568
- params.push(filter.spec);
596
+ conditions.push(eq(pmoTickets.specId, filter.spec));
569
597
  }
570
598
  if (filter?.epic) {
571
- query += ' AND t.epic_id = ?';
572
- params.push(filter.epic);
599
+ conditions.push(eq(pmoTickets.epicId, filter.epic));
573
600
  }
574
601
  if (filter?.column) {
575
- // Column filter now uses status name
576
- query += ' AND ws.name = ?';
577
- params.push(filter.column);
602
+ conditions.push(eq(pmoWorkflowStatuses.name, filter.column));
578
603
  }
579
604
  if (filter?.label) {
580
- query += ` AND t.id IN (
581
- SELECT tl.ticket_id FROM ${T.ticket_labels} tl
582
- JOIN ${T.labels} l ON tl.label_id = l.id
583
- WHERE LOWER(l.name) = LOWER(?)
584
- )`;
585
- params.push(filter.label);
605
+ conditions.push(sql `${pmoTickets.id} IN (
606
+ SELECT ${pmoTicketLabels.ticketId} FROM ${pmoTicketLabels}
607
+ JOIN ${pmoLabels} ON ${pmoTicketLabels.labelId} = ${pmoLabels.id}
608
+ WHERE LOWER(${pmoLabels.name}) = LOWER(${filter.label})
609
+ )`);
586
610
  }
587
611
  if (filter?.labelGroup) {
588
- query += ` AND t.id IN (
589
- SELECT tl.ticket_id FROM ${T.ticket_labels} tl
590
- JOIN ${T.labels} l ON tl.label_id = l.id
591
- JOIN ${T.label_groups} lg ON l.group_id = lg.id
592
- WHERE LOWER(lg.name) = LOWER(?)
593
- )`;
594
- params.push(filter.labelGroup);
595
- }
596
- // Order by status column position, then ticket position within status
597
- if (projectIdOrName === undefined) {
598
- query += ` ORDER BY p.name, ws.position, t.position ASC, t.created_at ASC`;
599
- }
600
- else {
601
- query += ` ORDER BY ws.position, t.position ASC, t.created_at ASC`;
602
- }
603
- const rows = this.ctx.db.prepare(query).all(...params);
604
- return Promise.all(rows.map((row) => rowToTicket(this.ctx.db, row)));
612
+ conditions.push(sql `${pmoTickets.id} IN (
613
+ SELECT ${pmoTicketLabels.ticketId} FROM ${pmoTicketLabels}
614
+ JOIN ${pmoLabels} ON ${pmoTicketLabels.labelId} = ${pmoLabels.id}
615
+ JOIN ${pmoLabelGroups} ON ${pmoLabels.groupId} = ${pmoLabelGroups.id}
616
+ WHERE LOWER(${pmoLabelGroups.name}) = LOWER(${filter.labelGroup})
617
+ )`);
618
+ }
619
+ // Build order clause
620
+ const orderClauses = projectIdOrName === undefined
621
+ ? [asc(pmoProjects.name), asc(pmoWorkflowStatuses.position), asc(pmoTickets.position), asc(pmoTickets.createdAt)]
622
+ : [asc(pmoWorkflowStatuses.position), asc(pmoTickets.position), asc(pmoTickets.createdAt)];
623
+ let query = this.ctx.drizzle
624
+ .select({
625
+ id: pmoTickets.id,
626
+ project_id: pmoTickets.projectId,
627
+ title: pmoTickets.title,
628
+ description: pmoTickets.description,
629
+ priority: pmoTickets.priority,
630
+ category: pmoTickets.category,
631
+ status_id: pmoTickets.statusId,
632
+ owner: pmoTickets.owner,
633
+ assignee: pmoTickets.assignee,
634
+ branch: pmoTickets.branch,
635
+ spec_id: pmoTickets.specId,
636
+ epic_id: pmoTickets.epicId,
637
+ labels: pmoTickets.labels,
638
+ position: pmoTickets.position,
639
+ created_at: pmoTickets.createdAt,
640
+ updated_at: pmoTickets.updatedAt,
641
+ last_synced_from_spec: pmoTickets.lastSyncedFromSpec,
642
+ last_synced_from_board: pmoTickets.lastSyncedFromBoard,
643
+ column_id: pmoWorkflowStatuses.id,
644
+ column_name: pmoWorkflowStatuses.name,
645
+ project_name: pmoProjects.name,
646
+ })
647
+ .from(pmoTickets)
648
+ .leftJoin(pmoWorkflowStatuses, eq(pmoTickets.statusId, pmoWorkflowStatuses.id))
649
+ .leftJoin(pmoProjects, eq(pmoTickets.projectId, pmoProjects.id))
650
+ .$dynamic();
651
+ if (conditions.length > 0) {
652
+ query = query.where(and(...conditions));
653
+ }
654
+ const rows = query.orderBy(...orderClauses).all();
655
+ return Promise.all(rows.map((row) => rowToTicket(this.ctx.drizzle, row)));
605
656
  }
606
657
  /**
607
658
  * Move a ticket to a different project.
@@ -617,55 +668,64 @@ export class TicketStorage {
617
668
  throw new PMOError('INVALID', `Ticket ${ticketId} has no associated project`, ticketId);
618
669
  }
619
670
  // Check if target project exists and get its workflow
620
- const targetProject = this.ctx.db.prepare(`
621
- SELECT id, workflow_id FROM ${T.projects} WHERE id = ?
622
- `).get(newProjectId);
671
+ const targetProject = this.ctx.drizzle
672
+ .select({ id: pmoProjects.id, workflowId: pmoProjects.workflowId })
673
+ .from(pmoProjects)
674
+ .where(eq(pmoProjects.id, newProjectId))
675
+ .get();
623
676
  if (!targetProject) {
624
677
  throw new PMOError('NOT_FOUND', `Project not found: ${newProjectId}`, newProjectId);
625
678
  }
626
- const workflowId = targetProject.workflow_id || 'default';
679
+ const workflowId = targetProject.workflowId || 'default';
627
680
  // Get default status for target project's workflow
628
681
  let newStatusId;
629
- const defaultStatus = this.ctx.db.prepare(`
630
- SELECT id FROM ${T.workflow_statuses}
631
- WHERE workflow_id = ? AND is_default = 1
632
- `).get(workflowId);
682
+ const defaultStatus = this.ctx.drizzle
683
+ .select({ id: pmoWorkflowStatuses.id })
684
+ .from(pmoWorkflowStatuses)
685
+ .where(and(eq(pmoWorkflowStatuses.workflowId, workflowId), eq(pmoWorkflowStatuses.isDefault, true)))
686
+ .get();
633
687
  if (defaultStatus) {
634
688
  newStatusId = defaultStatus.id;
635
689
  }
636
690
  else {
637
691
  // Get first status in workflow
638
- const firstStatus = this.ctx.db.prepare(`
639
- SELECT id FROM ${T.workflow_statuses}
640
- WHERE workflow_id = ?
641
- ORDER BY
642
- CASE category
692
+ const firstStatus = this.ctx.drizzle
693
+ .select({ id: pmoWorkflowStatuses.id })
694
+ .from(pmoWorkflowStatuses)
695
+ .where(eq(pmoWorkflowStatuses.workflowId, workflowId))
696
+ .orderBy(sql `CASE ${pmoWorkflowStatuses.category}
643
697
  WHEN 'backlog' THEN 1
644
698
  WHEN 'unstarted' THEN 2
645
699
  WHEN 'started' THEN 3
646
700
  WHEN 'completed' THEN 4
647
701
  WHEN 'canceled' THEN 5
648
- END,
649
- position ASC
650
- LIMIT 1
651
- `).get(workflowId);
702
+ END`, asc(pmoWorkflowStatuses.position))
703
+ .limit(1)
704
+ .get();
652
705
  if (firstStatus) {
653
706
  newStatusId = firstStatus.id;
654
707
  }
655
708
  }
656
709
  // Get next position for the target status
657
710
  const targetStatusId = newStatusId || existing.statusId;
658
- const maxPos = this.ctx.db.prepare(`
659
- SELECT COALESCE(MAX(position), 0) as max_pos FROM ${T.tickets} WHERE status_id = ?
660
- `).get(targetStatusId);
661
- const newTicketPosition = maxPos.max_pos + 1000;
711
+ const maxPos = this.ctx.drizzle
712
+ .select({ maxPos: sql `COALESCE(MAX(${pmoTickets.position}), 0)` })
713
+ .from(pmoTickets)
714
+ .where(eq(pmoTickets.statusId, targetStatusId))
715
+ .get();
716
+ const newTicketPosition = (maxPos?.maxPos ?? 0) + 1000;
662
717
  // Update ticket's project_id, status_id, and position
663
718
  const now = Date.now();
664
- this.ctx.db.prepare(`
665
- UPDATE ${T.tickets}
666
- SET project_id = ?, status_id = ?, position = ?, updated_at = ?
667
- WHERE id = ?
668
- `).run(newProjectId, targetStatusId, newTicketPosition, now, ticketId);
719
+ this.ctx.drizzle
720
+ .update(pmoTickets)
721
+ .set({
722
+ projectId: newProjectId,
723
+ statusId: targetStatusId,
724
+ position: newTicketPosition,
725
+ updatedAt: String(now),
726
+ })
727
+ .where(eq(pmoTickets.id, ticketId))
728
+ .run();
669
729
  // Update timestamps for both projects
670
730
  this.updateProjectTimestamp(oldProjectId);
671
731
  this.updateProjectTimestamp(newProjectId);