@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.
- package/dist/commands/work/start.js +30 -4
- package/dist/lib/agents/commands.d.ts +14 -0
- package/dist/lib/agents/commands.js +58 -0
- package/dist/lib/database/drizzle-schema.d.ts +17 -0
- package/dist/lib/database/drizzle-schema.js +2 -0
- package/dist/lib/database/index.js +39 -0
- package/dist/lib/execution/runners.js +9 -8
- package/dist/lib/mcp/helpers.d.ts +2 -0
- package/dist/lib/mcp/helpers.js +1 -0
- package/dist/lib/mcp/tools/ticket.js +25 -0
- package/dist/lib/pmo/schema.d.ts +2 -2
- package/dist/lib/pmo/schema.js +3 -0
- package/dist/lib/pmo/storage/dependencies.js +2 -2
- package/dist/lib/pmo/storage/epics.js +2 -10
- package/dist/lib/pmo/storage/index.d.ts +4 -0
- package/dist/lib/pmo/storage/index.js +3 -0
- package/dist/lib/pmo/storage/projects.d.ts +1 -1
- package/dist/lib/pmo/storage/projects.js +2 -10
- package/dist/lib/pmo/storage/specs.js +2 -10
- package/dist/lib/pmo/storage/tickets.d.ts +20 -3
- package/dist/lib/pmo/storage/tickets.js +139 -33
- package/dist/lib/pmo/storage/views.js +2 -10
- package/dist/lib/pmo/types.d.ts +4 -0
- package/oclif.manifest.json +2028 -2028
- package/package.json +2 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ticket operations for PMO.
|
|
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
|
+
* 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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
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
|
}
|
package/dist/lib/pmo/types.d.ts
CHANGED
|
@@ -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[]>;
|