@proletariat/cli 0.3.28 → 0.3.30

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.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Ticket operations for PMO.
3
3
  * Tickets reference workflow statuses directly via status_id.
4
- * Board position is derived from priority and created_at (no separate board_tickets table).
4
+ * Tickets have a position column for force-ranked ordering within a status.
5
+ * Positions use gapped integers (1000, 2000, 3000...) for stable reordering.
5
6
  */
6
7
  import { PMO_TABLES } from '../schema.js';
7
8
  import { PMOError } from '../types.js';
@@ -147,6 +148,11 @@ export class TicketStorage {
147
148
  }
148
149
  }
149
150
  }
151
+ // 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;
150
156
  // Insert ticket
151
157
  const labels = ticket.labels || [];
152
158
  try {
@@ -154,10 +160,10 @@ export class TicketStorage {
154
160
  INSERT INTO ${T.tickets} (
155
161
  id, project_id, title, description, priority, category,
156
162
  status_id, owner, assignee, spec_id, epic_id, labels,
157
- created_at, updated_at, last_synced_from_spec, last_synced_from_board
163
+ position, created_at, updated_at, last_synced_from_spec, last_synced_from_board
158
164
  )
159
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
160
- `).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), now, now, ticket.lastSyncedFromSpec || null, ticket.lastSyncedFromBoard || null);
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);
161
167
  }
162
168
  catch (err) {
163
169
  wrapSqliteError('Ticket', 'create', err);
@@ -200,7 +206,7 @@ export class TicketStorage {
200
206
  const row = this.ctx.db.prepare(`
201
207
  SELECT t.*,
202
208
  ws.id as column_id,
203
- ws.position as position,
209
+ t.position as position,
204
210
  ws.name as column_name
205
211
  FROM ${T.tickets} t
206
212
  LEFT JOIN ${T.workflow_statuses} ws ON t.status_id = ws.id
@@ -321,9 +327,10 @@ export class TicketStorage {
321
327
  /**
322
328
  * Move a ticket to a different status (column).
323
329
  * In the workflow-based system, columns ARE statuses.
324
- * The position parameter is ignored - tickets are sorted by priority then created_at.
330
+ * If position is provided, the ticket is placed at that position.
331
+ * Otherwise, the ticket is appended to the end of the target status.
325
332
  */
326
- async moveTicket(projectId, id, column, _position) {
333
+ async moveTicket(projectId, id, column, position) {
327
334
  const existing = await this.getTicketById(id);
328
335
  if (!existing) {
329
336
  throw new PMOError('NOT_FOUND', `Ticket not found: ${id}`, id);
@@ -344,15 +351,124 @@ export class TicketStorage {
344
351
  if (!targetStatus) {
345
352
  throw new PMOError('NOT_FOUND', `Status not found: ${column}`);
346
353
  }
347
- // Update ticket's status_id
354
+ // Determine position: use provided or append to end
355
+ let newPosition;
356
+ if (position !== undefined) {
357
+ newPosition = position;
358
+ }
359
+ 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;
364
+ }
365
+ // Update ticket's status_id and position
348
366
  this.ctx.db.prepare(`
349
367
  UPDATE ${T.tickets}
350
- SET status_id = ?, updated_at = ?
368
+ SET status_id = ?, position = ?, updated_at = ?
351
369
  WHERE id = ?
352
- `).run(targetStatus.id, Date.now(), id);
370
+ `).run(targetStatus.id, newPosition, Date.now(), id);
353
371
  this.ctx.updateBoardTimestamp(projectId);
354
372
  return (await this.getTicketById(id));
355
373
  }
374
+ /**
375
+ * Reorder a ticket within its current status.
376
+ * Supports two modes:
377
+ * 1. Direct position: set ticket to a specific position value
378
+ * 2. After ticket: place ticket immediately after another ticket
379
+ */
380
+ async reorderTicket(id, opts) {
381
+ const existing = await this.getTicketById(id);
382
+ if (!existing) {
383
+ throw new PMOError('NOT_FOUND', `Ticket not found: ${id}`, id);
384
+ }
385
+ let newPosition;
386
+ if (opts.afterTicketId) {
387
+ // Place after the specified ticket
388
+ const afterTicket = await this.getTicketById(opts.afterTicketId);
389
+ if (!afterTicket) {
390
+ throw new PMOError('NOT_FOUND', `Ticket not found: ${opts.afterTicketId}`, opts.afterTicketId);
391
+ }
392
+ // The after ticket must be in the same status
393
+ if (afterTicket.statusId !== existing.statusId) {
394
+ throw new PMOError('INVALID', `Cannot reorder: ${opts.afterTicketId} is in a different status`);
395
+ }
396
+ const afterPosition = afterTicket.position ?? 0;
397
+ // 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);
404
+ if (nextTicket) {
405
+ // Place between afterTicket and nextTicket
406
+ const gap = nextTicket.position - afterPosition;
407
+ if (gap > 1) {
408
+ newPosition = afterPosition + Math.floor(gap / 2);
409
+ }
410
+ else {
411
+ // No gap - need to re-gap all tickets in this status
412
+ this.regapPositions(existing.statusId, id);
413
+ // Re-read the after ticket position after regapping
414
+ const refreshedAfter = await this.getTicketById(opts.afterTicketId);
415
+ 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);
422
+ newPosition = refreshedNext
423
+ ? refreshedAfterPos + Math.floor((refreshedNext.position - refreshedAfterPos) / 2)
424
+ : refreshedAfterPos + 1000;
425
+ }
426
+ }
427
+ else {
428
+ // Append after the target (no tickets after it)
429
+ newPosition = afterPosition + 1000;
430
+ }
431
+ }
432
+ else if (opts.position !== undefined) {
433
+ newPosition = opts.position;
434
+ }
435
+ else {
436
+ throw new PMOError('INVALID', 'Must provide either position or after_ticket_id');
437
+ }
438
+ this.ctx.db.prepare(`
439
+ UPDATE ${T.tickets}
440
+ SET position = ?, updated_at = ?
441
+ WHERE id = ?
442
+ `).run(newPosition, Date.now(), id);
443
+ if (existing.projectId) {
444
+ this.ctx.updateBoardTimestamp(existing.projectId);
445
+ }
446
+ return (await this.getTicketById(id));
447
+ }
448
+ /**
449
+ * Re-gap positions for all tickets in a status using 1000-gaps.
450
+ * Optionally excludes a ticket (e.g., the one being moved).
451
+ */
452
+ regapPositions(statusId, excludeTicketId) {
453
+ let query = `
454
+ SELECT id FROM ${T.tickets}
455
+ WHERE status_id = ?
456
+ `;
457
+ const params = [statusId];
458
+ 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(() => {
466
+ tickets.forEach((ticket, idx) => {
467
+ update.run((idx + 1) * 1000, ticket.id);
468
+ });
469
+ });
470
+ regap();
471
+ }
356
472
  /**
357
473
  * Delete a ticket.
358
474
  * Works with ticket ID only - no project context required since ticket IDs are globally unique.
@@ -405,7 +521,7 @@ export class TicketStorage {
405
521
  let query = `
406
522
  SELECT t.*,
407
523
  ws.id as column_id,
408
- ws.position as position,
524
+ t.position as position,
409
525
  ws.name as column_name,
410
526
  p.name as project_name
411
527
  FROM ${T.tickets} t
@@ -460,28 +576,12 @@ export class TicketStorage {
460
576
  query += ' AND ws.name = ?';
461
577
  params.push(filter.column);
462
578
  }
463
- // Order by project, then status position, then priority, then created_at
579
+ // Order by status column position, then ticket position within status
464
580
  if (projectIdOrName === undefined) {
465
- query += ` ORDER BY p.name, ws.position,
466
- CASE t.priority
467
- WHEN 'P0' THEN 0
468
- WHEN 'P1' THEN 1
469
- WHEN 'P2' THEN 2
470
- WHEN 'P3' THEN 3
471
- ELSE 4
472
- END,
473
- t.created_at ASC`;
581
+ query += ` ORDER BY p.name, ws.position, t.position ASC, t.created_at ASC`;
474
582
  }
475
583
  else {
476
- query += ` ORDER BY ws.position,
477
- CASE t.priority
478
- WHEN 'P0' THEN 0
479
- WHEN 'P1' THEN 1
480
- WHEN 'P2' THEN 2
481
- WHEN 'P3' THEN 3
482
- ELSE 4
483
- END,
484
- t.created_at ASC`;
584
+ query += ` ORDER BY ws.position, t.position ASC, t.created_at ASC`;
485
585
  }
486
586
  const rows = this.ctx.db.prepare(query).all(...params);
487
587
  return Promise.all(rows.map((row) => rowToTicket(this.ctx.db, row)));
@@ -536,13 +636,19 @@ export class TicketStorage {
536
636
  newStatusId = firstStatus.id;
537
637
  }
538
638
  }
539
- // Update ticket's project_id and status_id
639
+ // Get next position for the target status
640
+ const targetStatusId = newStatusId || existing.statusId;
641
+ const maxPos = this.ctx.db.prepare(`
642
+ SELECT COALESCE(MAX(position), 0) as max_pos FROM ${T.tickets} WHERE status_id = ?
643
+ `).get(targetStatusId);
644
+ const newTicketPosition = maxPos.max_pos + 1000;
645
+ // Update ticket's project_id, status_id, and position
540
646
  const now = Date.now();
541
647
  this.ctx.db.prepare(`
542
648
  UPDATE ${T.tickets}
543
- SET project_id = ?, status_id = ?, updated_at = ?
649
+ SET project_id = ?, status_id = ?, position = ?, updated_at = ?
544
650
  WHERE id = ?
545
- `).run(newProjectId, newStatusId || existing.statusId, now, ticketId);
651
+ `).run(newProjectId, targetStatusId, newTicketPosition, now, ticketId);
546
652
  // Update timestamps for both projects
547
653
  this.updateProjectTimestamp(oldProjectId);
548
654
  this.updateProjectTimestamp(newProjectId);
@@ -298,16 +298,8 @@ export class ViewStorage {
298
298
  const searchTerm = `%${filters.search}%`;
299
299
  params.push(searchTerm, searchTerm);
300
300
  }
301
- // Order by priority then created_at
302
- sql += ` ORDER BY
303
- CASE t.priority
304
- WHEN 'P0' THEN 0
305
- WHEN 'P1' THEN 1
306
- WHEN 'P2' THEN 2
307
- WHEN 'P3' THEN 3
308
- ELSE 4
309
- END,
310
- t.created_at ASC`;
301
+ // Order by ticket position, then created_at as tiebreaker
302
+ sql += ` ORDER BY t.position ASC, t.created_at ASC`;
311
303
  const rows = this.ctx.db.prepare(sql).all(...params);
312
304
  return Promise.all(rows.map((row) => this.rowToTicketWithColumn(row)));
313
305
  }
@@ -664,6 +664,10 @@ export interface PMOStorage {
664
664
  getTicket(id: string): Promise<Ticket | null>;
665
665
  updateTicket(id: string, changes: Partial<Ticket>): Promise<Ticket>;
666
666
  moveTicket(projectId: string, id: string, column: string, position?: number): Promise<Ticket>;
667
+ reorderTicket(id: string, opts: {
668
+ position?: number;
669
+ afterTicketId?: string;
670
+ }): Promise<Ticket>;
667
671
  moveTicketToProject(ticketId: string, newProjectId: string): Promise<Ticket>;
668
672
  deleteTicket(id: string): Promise<void>;
669
673
  listTickets(projectId: string | undefined, filter?: TicketFilter): Promise<Ticket[]>;