@proletariat/cli 0.3.98 → 0.3.100
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/orchestrate/index.js +30 -3
- package/dist/commands/orchestrate/index.js.map +1 -1
- package/dist/commands/qa/index.js +1 -1
- package/dist/commands/ticket/create.d.ts +44 -0
- package/dist/commands/ticket/create.js +754 -0
- package/dist/commands/ticket/create.js.map +1 -0
- package/dist/commands/ticket/delete.d.ts +17 -0
- package/dist/commands/ticket/delete.js +204 -0
- package/dist/commands/ticket/delete.js.map +1 -0
- package/dist/commands/ticket/edit.d.ts +28 -0
- package/dist/commands/ticket/edit.js +402 -0
- package/dist/commands/ticket/edit.js.map +1 -0
- package/dist/commands/ticket/index.d.ts +13 -0
- package/dist/commands/ticket/index.js +74 -0
- package/dist/commands/ticket/index.js.map +1 -0
- package/dist/commands/ticket/list.d.ts +33 -0
- package/dist/commands/ticket/list.js +519 -0
- package/dist/commands/ticket/list.js.map +1 -0
- package/dist/commands/ticket/move.d.ts +27 -0
- package/dist/commands/ticket/move.js +413 -0
- package/dist/commands/ticket/move.js.map +1 -0
- package/dist/commands/ticket/show.d.ts +14 -0
- package/dist/commands/ticket/show.js +110 -0
- package/dist/commands/ticket/show.js.map +1 -0
- package/dist/commands/ticket/update.d.ts +28 -0
- package/dist/commands/ticket/update.js +458 -0
- package/dist/commands/ticket/update.js.map +1 -0
- package/dist/lib/execution/preflight.js +1 -1
- package/dist/lib/execution/preflight.js.map +1 -1
- package/dist/lib/mcp/tools/action.d.ts +6 -0
- package/dist/lib/mcp/tools/action.js +123 -0
- package/dist/lib/mcp/tools/action.js.map +1 -0
- package/dist/lib/mcp/tools/index.d.ts +2 -0
- package/dist/lib/mcp/tools/index.js +2 -0
- package/dist/lib/mcp/tools/index.js.map +1 -1
- package/dist/lib/mcp/tools/ticket.d.ts +6 -0
- package/dist/lib/mcp/tools/ticket.js +464 -0
- package/dist/lib/mcp/tools/ticket.js.map +1 -0
- package/dist/lib/orchestrate/poller.d.ts +22 -0
- package/dist/lib/orchestrate/poller.js +109 -0
- package/dist/lib/orchestrate/poller.js.map +1 -1
- package/dist/lib/sync/engine.js +47 -5
- package/dist/lib/sync/engine.js.map +1 -1
- package/dist/lib/sync/reconciler.d.ts +27 -1
- package/dist/lib/sync/reconciler.js +109 -1
- package/dist/lib/sync/reconciler.js.map +1 -1
- package/oclif.manifest.json +2021 -1153
- package/package.json +1 -1
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { autoExportToBoard, PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js';
|
|
5
|
+
// Note: inquirer import kept for inquirer.Separator usage in interactive mode
|
|
6
|
+
import { styles } from '../../lib/styles.js';
|
|
7
|
+
import { updateEpicTicketsSection } from '../../lib/pmo/epic-files.js';
|
|
8
|
+
import { getWorkspacePriorities } from '../../lib/work-lifecycle/settings.js';
|
|
9
|
+
import { shouldOutputJson, outputErrorAsJson, outputDryRunSuccessAsJson, outputDryRunErrorsAsJson, createMetadata, } from '../../lib/prompt-json.js';
|
|
10
|
+
import { FlagResolver } from '../../lib/flags/index.js';
|
|
11
|
+
import { multiLineInput } from '../../lib/multiline-input.js';
|
|
12
|
+
import { getRegisteredWorkSources, loadDefaultWorkSource } from '../../lib/work-source/config.js';
|
|
13
|
+
export default class TicketCreate extends PMOCommand {
|
|
14
|
+
static description = 'Create a new ticket (routes to Linear when configured, or local PMO)';
|
|
15
|
+
static examples = [
|
|
16
|
+
'<%= config.bin %> <%= command.id %>',
|
|
17
|
+
'<%= config.bin %> <%= command.id %> --title "Fix login bug" --column Backlog',
|
|
18
|
+
'<%= config.bin %> <%= command.id %> -t "Add feature" -c "In Progress" -p P1',
|
|
19
|
+
'<%= config.bin %> <%= command.id %> --project mobile-app -t "New feature"',
|
|
20
|
+
'<%= config.bin %> <%= command.id %> --epic EPIC-001 -t "Implement auth flow"',
|
|
21
|
+
'<%= config.bin %> <%= command.id %> --title "My ticket" --description-file ./ticket-desc.md',
|
|
22
|
+
'<%= config.bin %> <%= command.id %> --title "My ticket" --description-file - # Read from stdin',
|
|
23
|
+
'<%= config.bin %> <%= command.id %> --json # Output column choices as JSON',
|
|
24
|
+
'<%= config.bin %> <%= command.id %> --title "Test" -P PROJ-001 --dry-run --json # Validate without creating',
|
|
25
|
+
'<%= config.bin %> <%= command.id %> --source linear -t "Fix bug" --team ENG',
|
|
26
|
+
'<%= config.bin %> <%= command.id %> --source pmo -t "Local task" -c Backlog',
|
|
27
|
+
];
|
|
28
|
+
static flags = {
|
|
29
|
+
...pmoBaseFlags,
|
|
30
|
+
title: Flags.string({
|
|
31
|
+
char: 't',
|
|
32
|
+
description: 'Ticket title [required for non-interactive]',
|
|
33
|
+
}),
|
|
34
|
+
column: Flags.string({
|
|
35
|
+
char: 'c',
|
|
36
|
+
description: 'Column to place the ticket in',
|
|
37
|
+
}),
|
|
38
|
+
priority: Flags.string({
|
|
39
|
+
char: 'p',
|
|
40
|
+
description: 'Ticket priority (uses workspace priority scale)',
|
|
41
|
+
}),
|
|
42
|
+
category: Flags.string({
|
|
43
|
+
description: 'Ticket category (e.g., bug, feature, refactor)',
|
|
44
|
+
}),
|
|
45
|
+
description: Flags.string({
|
|
46
|
+
char: 'd',
|
|
47
|
+
description: 'Ticket description',
|
|
48
|
+
}),
|
|
49
|
+
'description-file': Flags.string({
|
|
50
|
+
char: 'D',
|
|
51
|
+
description: 'Path to a markdown file for the ticket description (use - for stdin)',
|
|
52
|
+
exclusive: ['description'],
|
|
53
|
+
}),
|
|
54
|
+
id: Flags.string({
|
|
55
|
+
description: 'Custom ticket ID (auto-generated if not provided)',
|
|
56
|
+
}),
|
|
57
|
+
interactive: Flags.boolean({
|
|
58
|
+
char: 'i',
|
|
59
|
+
description: 'Interactive mode',
|
|
60
|
+
default: false,
|
|
61
|
+
}),
|
|
62
|
+
epic: Flags.string({
|
|
63
|
+
char: 'e',
|
|
64
|
+
description: 'Link ticket to an epic (e.g., EPIC-001)',
|
|
65
|
+
}),
|
|
66
|
+
template: Flags.string({
|
|
67
|
+
char: 'T',
|
|
68
|
+
description: 'Create from a template (e.g., bug-report, feature-request)',
|
|
69
|
+
}),
|
|
70
|
+
labels: Flags.string({
|
|
71
|
+
char: 'l',
|
|
72
|
+
aliases: ['label'],
|
|
73
|
+
description: 'Labels (comma-separated)',
|
|
74
|
+
}),
|
|
75
|
+
'dry-run': Flags.boolean({
|
|
76
|
+
description: 'Validate inputs without creating ticket (use with --json for structured output)',
|
|
77
|
+
default: false,
|
|
78
|
+
}),
|
|
79
|
+
source: Flags.string({
|
|
80
|
+
description: 'Ticket source: "pmo" for local DB, "linear" for Linear API, or "auto" to detect (default: auto)',
|
|
81
|
+
options: ['auto', 'pmo', 'linear'],
|
|
82
|
+
default: 'auto',
|
|
83
|
+
}),
|
|
84
|
+
team: Flags.string({
|
|
85
|
+
description: 'Linear team key (fallback: PRLT_LINEAR_TEAM)',
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
async execute() {
|
|
89
|
+
const { flags } = await this.parse(TicketCreate);
|
|
90
|
+
// Check if JSON output mode is active
|
|
91
|
+
const jsonMode = shouldOutputJson(flags);
|
|
92
|
+
// Read description from file/stdin EARLY — before any prompts or routing
|
|
93
|
+
// that could consume stdin or error out before the read happens.
|
|
94
|
+
if (flags['description-file']) {
|
|
95
|
+
const filePath = flags['description-file'];
|
|
96
|
+
try {
|
|
97
|
+
if (filePath === '-') {
|
|
98
|
+
if (process.stdin.isTTY) {
|
|
99
|
+
if (jsonMode) {
|
|
100
|
+
outputErrorAsJson('DESCRIPTION_FILE_ERROR', 'Cannot read from stdin: no input piped. Use --description-file <path> with a file path instead, or pipe content via: echo "desc" | prlt ticket create --description-file -', createMetadata('ticket create', flags));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.error('Cannot read from stdin: no input piped. Use --description-file <path> with a file path instead, or pipe content via: echo "desc" | prlt ticket create --description-file -');
|
|
104
|
+
}
|
|
105
|
+
flags.description = fs.readFileSync(0, 'utf-8');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
flags.description = fs.readFileSync(filePath, 'utf-8');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
113
|
+
if (jsonMode) {
|
|
114
|
+
outputErrorAsJson('DESCRIPTION_FILE_ERROR', `Failed to read description file "${filePath}": ${errMsg}`, createMetadata('ticket create', flags));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.error(`Failed to read description file "${filePath}": ${errMsg}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Determine ticket source (pmo, linear, or prompt user)
|
|
121
|
+
const resolvedSource = await this.resolveSource(flags, jsonMode);
|
|
122
|
+
// If Linear source is selected, delegate to the Linear creation path
|
|
123
|
+
if (resolvedSource === 'linear') {
|
|
124
|
+
return this.createLinearIssue(flags, jsonMode);
|
|
125
|
+
}
|
|
126
|
+
// PMO path — existing flow
|
|
127
|
+
// Get project and board info (pass JSON mode config for AI agents)
|
|
128
|
+
const projectId = await this.requireProject({
|
|
129
|
+
jsonMode: {
|
|
130
|
+
flags,
|
|
131
|
+
commandName: 'ticket create',
|
|
132
|
+
baseCommand: 'prlt ticket create',
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const board = await this.storage.getBoard(projectId);
|
|
136
|
+
const columns = board.columns.map(c => c.name);
|
|
137
|
+
const projectName = await this.getProjectName(projectId);
|
|
138
|
+
// Helper to handle errors in JSON mode
|
|
139
|
+
const handleError = (code, message) => {
|
|
140
|
+
if (jsonMode) {
|
|
141
|
+
outputErrorAsJson(code, message, createMetadata('ticket create', flags));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.error(message);
|
|
145
|
+
};
|
|
146
|
+
// NOTE: --description-file handling is done early in execute(), before routing.
|
|
147
|
+
// flags.description is already populated if --description-file was provided.
|
|
148
|
+
// Validate epic if provided
|
|
149
|
+
if (flags.epic) {
|
|
150
|
+
const epic = await this.storage.getEpic(flags.epic);
|
|
151
|
+
if (!epic) {
|
|
152
|
+
return handleError('EPIC_NOT_FOUND', `Epic not found: ${flags.epic}. Use 'prlt epic list' to see available epics.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Load template if specified
|
|
156
|
+
let template = null;
|
|
157
|
+
if (flags.template) {
|
|
158
|
+
template = await this.storage.getTicketTemplate(flags.template);
|
|
159
|
+
if (!template) {
|
|
160
|
+
return handleError('TEMPLATE_NOT_FOUND', `Template not found: ${flags.template}. Run 'prlt ticket template list' to see available templates.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Parse labels from flag
|
|
164
|
+
const labelsFromFlag = flags.labels
|
|
165
|
+
? flags.labels.split(',').map(l => l.trim()).filter(Boolean)
|
|
166
|
+
: undefined;
|
|
167
|
+
// Get ticket data (interactive or from flags)
|
|
168
|
+
let ticketData;
|
|
169
|
+
// Use FlagResolver to handle both JSON mode and interactive prompts
|
|
170
|
+
// This unifies the two code paths into one pattern
|
|
171
|
+
if (!flags.interactive) {
|
|
172
|
+
// In JSON mode, default column to first backlog status if not provided
|
|
173
|
+
// This prevents prompting for column in non-interactive mode
|
|
174
|
+
if (jsonMode && !flags.column) {
|
|
175
|
+
// Prefer "Backlog" column, fall back to first column
|
|
176
|
+
const backlogColumn = columns.find(c => c.toLowerCase() === 'backlog') || columns[0];
|
|
177
|
+
flags.column = backlogColumn;
|
|
178
|
+
}
|
|
179
|
+
const resolver = new FlagResolver({
|
|
180
|
+
commandName: 'ticket create',
|
|
181
|
+
baseCommand: 'prlt ticket create',
|
|
182
|
+
jsonMode,
|
|
183
|
+
flags,
|
|
184
|
+
context: { projectId },
|
|
185
|
+
});
|
|
186
|
+
// Column selection - prompted first if missing
|
|
187
|
+
resolver.addPrompt({
|
|
188
|
+
flagName: 'column',
|
|
189
|
+
type: 'list',
|
|
190
|
+
message: 'Select column to place the ticket in:',
|
|
191
|
+
choices: () => columns.map(c => ({ name: c, value: c })),
|
|
192
|
+
when: (ctx) => !ctx.flags.column,
|
|
193
|
+
});
|
|
194
|
+
// Title input - prompted after column is set
|
|
195
|
+
resolver.addPrompt({
|
|
196
|
+
flagName: 'title',
|
|
197
|
+
type: 'input',
|
|
198
|
+
message: 'Enter ticket title:',
|
|
199
|
+
when: (ctx) => !ctx.flags.title && ctx.flags.column !== undefined,
|
|
200
|
+
validate: (value) => value.trim() ? true : 'Title cannot be empty',
|
|
201
|
+
context: (ctx) => ({
|
|
202
|
+
hint: `Provide title with: ${ctx.baseCommand}${ctx.projectId ? ` -P ${ctx.projectId}` : ''} --column "${ctx.flags.column}" --title "Your title here"`,
|
|
203
|
+
requiredFields: ['--title'],
|
|
204
|
+
optionalFields: ['--priority', '--category', '--description', '--epic', '--labels'],
|
|
205
|
+
example: `${ctx.baseCommand}${ctx.projectId ? ` -P ${ctx.projectId}` : ''} --column "${ctx.flags.column}" --title "Fix login bug" --priority P1 --category bug`,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
// Resolve missing flags (in JSON mode, outputs prompt and exits; in interactive mode, prompts user)
|
|
209
|
+
const resolvedFlags = await resolver.resolve();
|
|
210
|
+
// If we get here, we have both column and title
|
|
211
|
+
if (!resolvedFlags.title && !template?.titlePattern) {
|
|
212
|
+
return handleError('TITLE_REQUIRED', 'Title is required. Use --title or -t flag, or use --interactive mode.');
|
|
213
|
+
}
|
|
214
|
+
ticketData = {
|
|
215
|
+
title: resolvedFlags.title || template?.titlePattern || '',
|
|
216
|
+
statusName: resolvedFlags.column || columns[0],
|
|
217
|
+
priority: resolvedFlags.priority || template?.defaultPriority,
|
|
218
|
+
category: resolvedFlags.category || template?.defaultCategory,
|
|
219
|
+
description: resolvedFlags.description || template?.descriptionTemplate,
|
|
220
|
+
id: resolvedFlags.id,
|
|
221
|
+
epicId: resolvedFlags.epic,
|
|
222
|
+
labels: labelsFromFlag || template?.defaultLabels,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// Full interactive mode - use the detailed prompts
|
|
227
|
+
ticketData = await this.promptTicketData(flags, this.storage, template, columns);
|
|
228
|
+
}
|
|
229
|
+
// Validate status/column
|
|
230
|
+
if (!columns.includes(ticketData.statusName)) {
|
|
231
|
+
if (flags['dry-run']) {
|
|
232
|
+
if (jsonMode) {
|
|
233
|
+
outputDryRunErrorsAsJson([{ field: 'column', error: `Invalid column "${ticketData.statusName}". Available: ${columns.join(', ')}` }], createMetadata('ticket create', flags));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.error(`Invalid column "${ticketData.statusName}". Available columns: ${columns.join(', ')}`);
|
|
237
|
+
}
|
|
238
|
+
return handleError('INVALID_COLUMN', `Invalid column "${ticketData.statusName}". Available columns: ${columns.join(', ')}`);
|
|
239
|
+
}
|
|
240
|
+
// Handle dry-run: show what would be created without actually creating
|
|
241
|
+
if (flags['dry-run']) {
|
|
242
|
+
const wouldCreate = {
|
|
243
|
+
title: ticketData.title,
|
|
244
|
+
project: projectId,
|
|
245
|
+
column: ticketData.statusName,
|
|
246
|
+
...(ticketData.priority && { priority: ticketData.priority }),
|
|
247
|
+
...(ticketData.category && { category: ticketData.category }),
|
|
248
|
+
...(ticketData.description && { description: ticketData.description }),
|
|
249
|
+
...(ticketData.epicId && { epic: ticketData.epicId }),
|
|
250
|
+
...(ticketData.labels && ticketData.labels.length > 0 && { labels: ticketData.labels }),
|
|
251
|
+
};
|
|
252
|
+
if (jsonMode) {
|
|
253
|
+
outputDryRunSuccessAsJson('ticket', wouldCreate, createMetadata('ticket create', flags));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Human-readable dry-run output
|
|
257
|
+
this.log(styles.warning('\n[DRY RUN] Would create ticket:'));
|
|
258
|
+
this.log(styles.muted(` Title: ${ticketData.title}`));
|
|
259
|
+
this.log(styles.muted(` Project: ${projectName}`));
|
|
260
|
+
this.log(styles.muted(` Column: ${ticketData.statusName}`));
|
|
261
|
+
if (ticketData.priority) {
|
|
262
|
+
this.log(styles.muted(` Priority: ${ticketData.priority}`));
|
|
263
|
+
}
|
|
264
|
+
if (ticketData.category) {
|
|
265
|
+
this.log(styles.muted(` Category: ${ticketData.category}`));
|
|
266
|
+
}
|
|
267
|
+
if (ticketData.epicId) {
|
|
268
|
+
this.log(styles.muted(` Epic: ${ticketData.epicId}`));
|
|
269
|
+
}
|
|
270
|
+
if (ticketData.labels && ticketData.labels.length > 0) {
|
|
271
|
+
this.log(styles.muted(` Labels: ${ticketData.labels.join(', ')}`));
|
|
272
|
+
}
|
|
273
|
+
if (template) {
|
|
274
|
+
this.log(styles.muted(` Template: ${template.name}`));
|
|
275
|
+
if (template.suggestedSubtasks.length > 0) {
|
|
276
|
+
this.log(styles.muted(` Subtasks: ${template.suggestedSubtasks.length} would be created`));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
this.log(styles.muted('\n(No ticket was created)'));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Create ticket through the provider (routes to configured backend)
|
|
283
|
+
const provider = this.resolveProjectProvider(projectId, 'pmo');
|
|
284
|
+
const createResult = await provider.createTicket(projectId, {
|
|
285
|
+
id: ticketData.id,
|
|
286
|
+
title: ticketData.title,
|
|
287
|
+
statusName: ticketData.statusName,
|
|
288
|
+
priority: ticketData.priority,
|
|
289
|
+
category: ticketData.category,
|
|
290
|
+
description: ticketData.description,
|
|
291
|
+
epicId: ticketData.epicId,
|
|
292
|
+
labels: ticketData.labels,
|
|
293
|
+
});
|
|
294
|
+
if (!createResult.success || !createResult.ticket) {
|
|
295
|
+
return handleError('CREATE_FAILED', `Failed to create ticket: ${createResult.error}`);
|
|
296
|
+
}
|
|
297
|
+
const ticket = createResult.ticket;
|
|
298
|
+
// Add subtasks from template if applicable
|
|
299
|
+
if (template && template.suggestedSubtasks.length > 0) {
|
|
300
|
+
// Sequential subtask creation for consistent ordering
|
|
301
|
+
for (const subtask of template.suggestedSubtasks) {
|
|
302
|
+
// eslint-disable-next-line no-await-in-loop
|
|
303
|
+
await this.storage.addSubtask(ticket.id, subtask.title);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Auto-export to board.md after write
|
|
307
|
+
await autoExportToBoard(this.pmoPath, this.storage, (msg) => this.log(styles.muted(msg)));
|
|
308
|
+
// If linked to an epic, update the epic's markdown file with ticket list
|
|
309
|
+
if (ticketData.epicId) {
|
|
310
|
+
const epic = await this.storage.getEpic(ticketData.epicId);
|
|
311
|
+
if (epic) {
|
|
312
|
+
const epicTickets = await this.storage.getTicketsForEpic(projectId, ticketData.epicId);
|
|
313
|
+
const ticketInfos = epicTickets.map(t => ({
|
|
314
|
+
id: t.id,
|
|
315
|
+
title: t.title,
|
|
316
|
+
status: t.statusName || 'Unknown',
|
|
317
|
+
priority: t.priority,
|
|
318
|
+
}));
|
|
319
|
+
updateEpicTicketsSection(this.pmoPath, ticketData.epicId, epic.status, ticketInfos, projectId);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// JSON output mode - match MCP tool response shape
|
|
323
|
+
if (jsonMode) {
|
|
324
|
+
this.log(JSON.stringify({
|
|
325
|
+
success: true,
|
|
326
|
+
ticket: {
|
|
327
|
+
id: ticket.id,
|
|
328
|
+
title: ticket.title,
|
|
329
|
+
priority: ticket.priority,
|
|
330
|
+
category: ticket.category,
|
|
331
|
+
statusName: ticket.statusName,
|
|
332
|
+
statusCategory: ticket.statusCategory,
|
|
333
|
+
projectId: ticket.projectId,
|
|
334
|
+
assignee: ticket.assignee,
|
|
335
|
+
owner: ticket.owner,
|
|
336
|
+
branch: ticket.branch,
|
|
337
|
+
epicId: ticket.epicId,
|
|
338
|
+
position: ticket.position,
|
|
339
|
+
},
|
|
340
|
+
}, null, 2));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.log(styles.success(`\n✅ Created ticket ${styles.emphasis(ticket.id)} in project ${styles.emphasis(projectName)}`));
|
|
344
|
+
if (template) {
|
|
345
|
+
this.log(styles.muted(` Template: ${template.name}`));
|
|
346
|
+
}
|
|
347
|
+
this.log(styles.muted(` Title: ${ticket.title}`));
|
|
348
|
+
this.log(styles.muted(` Status: ${ticket.statusName}`));
|
|
349
|
+
if (ticket.priority) {
|
|
350
|
+
this.log(styles.muted(` Priority: ${ticket.priority}`));
|
|
351
|
+
}
|
|
352
|
+
if (ticket.category) {
|
|
353
|
+
this.log(styles.muted(` Category: ${ticket.category}`));
|
|
354
|
+
}
|
|
355
|
+
if (ticketData.epicId) {
|
|
356
|
+
this.log(styles.muted(` Epic: ${ticketData.epicId}`));
|
|
357
|
+
}
|
|
358
|
+
if (ticketData.labels && ticketData.labels.length > 0) {
|
|
359
|
+
this.log(styles.muted(` Labels: ${ticketData.labels.join(', ')}`));
|
|
360
|
+
}
|
|
361
|
+
if (template && template.suggestedSubtasks.length > 0) {
|
|
362
|
+
this.log(styles.muted(` Subtasks: ${template.suggestedSubtasks.length} created`));
|
|
363
|
+
}
|
|
364
|
+
this.log(styles.muted(`\n View board: prlt board`));
|
|
365
|
+
this.log(styles.muted(` List tickets: prlt ticket list`));
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Determine whether to route through Linear or PMO based on --source flag
|
|
369
|
+
* and workspace config.
|
|
370
|
+
*
|
|
371
|
+
* Resolution order for auto mode:
|
|
372
|
+
* 1. Explicit default work source (loadDefaultWorkSource) — user preference
|
|
373
|
+
* 2. Single external provider configured — auto-select it
|
|
374
|
+
* 3. Multiple external providers — prompt user to choose
|
|
375
|
+
* 4. No external providers — fall back to PMO
|
|
376
|
+
*/
|
|
377
|
+
async resolveSource(flags, jsonMode) {
|
|
378
|
+
const source = flags.source || 'auto';
|
|
379
|
+
if (source === 'pmo')
|
|
380
|
+
return 'pmo';
|
|
381
|
+
if (source === 'linear')
|
|
382
|
+
return 'linear';
|
|
383
|
+
// auto: resolve from workspace config
|
|
384
|
+
const db = this.storage.getDatabase();
|
|
385
|
+
try {
|
|
386
|
+
// 1. Respect explicit default work source if configured
|
|
387
|
+
const defaultSource = loadDefaultWorkSource(db);
|
|
388
|
+
if (defaultSource?.provider === 'linear')
|
|
389
|
+
return 'linear';
|
|
390
|
+
if (defaultSource?.provider === 'pmo')
|
|
391
|
+
return 'pmo';
|
|
392
|
+
// 2. Check registered providers (PMO is always implicitly registered)
|
|
393
|
+
const registeredSources = getRegisteredWorkSources(db);
|
|
394
|
+
const externalProviders = [...new Set(registeredSources.map(s => s.provider).filter(p => p !== 'pmo'))];
|
|
395
|
+
// No external providers — use PMO
|
|
396
|
+
if (externalProviders.length === 0)
|
|
397
|
+
return 'pmo';
|
|
398
|
+
// Single external provider — auto-select it
|
|
399
|
+
if (externalProviders.length === 1) {
|
|
400
|
+
return externalProviders[0] === 'linear' ? 'linear' : 'pmo';
|
|
401
|
+
}
|
|
402
|
+
// Multiple external providers — prompt user to select
|
|
403
|
+
const allProviders = ['pmo', ...externalProviders];
|
|
404
|
+
const choices = allProviders.map(p => ({
|
|
405
|
+
name: p === 'pmo' ? 'PMO (local)' : `${p.charAt(0).toUpperCase() + p.slice(1)}`,
|
|
406
|
+
value: p,
|
|
407
|
+
}));
|
|
408
|
+
const message = 'Multiple ticket providers configured. Where should this ticket be created?';
|
|
409
|
+
const selectedSource = await this.selectFromList({
|
|
410
|
+
message,
|
|
411
|
+
items: allProviders.map(p => ({
|
|
412
|
+
name: p === 'pmo' ? 'PMO (local)' : `${p.charAt(0).toUpperCase() + p.slice(1)}`,
|
|
413
|
+
value: p,
|
|
414
|
+
})),
|
|
415
|
+
getName: (item) => item.name,
|
|
416
|
+
getValue: (item) => item.value,
|
|
417
|
+
getCommand: (item) => `prlt ticket create --source ${item.value} --json`,
|
|
418
|
+
jsonMode: jsonMode
|
|
419
|
+
? { flags: flags, commandName: 'ticket create' }
|
|
420
|
+
: null,
|
|
421
|
+
});
|
|
422
|
+
// In JSON mode selectFromList returns null (prompt already emitted)
|
|
423
|
+
if (selectedSource === null)
|
|
424
|
+
return 'pmo';
|
|
425
|
+
// Only 'linear' gets the special path; everything else falls through to PMO
|
|
426
|
+
return selectedSource === 'linear' ? 'linear' : 'pmo';
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// workspace_settings table may not exist in older/test databases
|
|
430
|
+
return 'pmo';
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Create a ticket through the Linear provider adapter.
|
|
435
|
+
* Collects inputs (title, description, priority, labels) and routes
|
|
436
|
+
* through the provider, which handles the Linear API and mirror creation.
|
|
437
|
+
*/
|
|
438
|
+
async createLinearIssue(flags, jsonMode) {
|
|
439
|
+
const handleError = (code, message) => {
|
|
440
|
+
if (jsonMode) {
|
|
441
|
+
outputErrorAsJson(code, message, createMetadata('ticket create', flags));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
this.error(message);
|
|
445
|
+
};
|
|
446
|
+
// Collect title (required)
|
|
447
|
+
let title = flags.title;
|
|
448
|
+
if (!title) {
|
|
449
|
+
const inputTitle = await this.promptForInput({
|
|
450
|
+
message: 'Enter ticket title:',
|
|
451
|
+
fieldName: 'title',
|
|
452
|
+
validate: (input) => input.trim() ? true : 'Title cannot be empty',
|
|
453
|
+
jsonMode: jsonMode
|
|
454
|
+
? { flags: flags, commandName: 'ticket create', commandHint: 'Provide --title flag', example: 'prlt ticket create --source linear --title "My ticket"' }
|
|
455
|
+
: null,
|
|
456
|
+
});
|
|
457
|
+
// In JSON mode, promptForInput returns '' (prompt already emitted)
|
|
458
|
+
if (jsonMode && !inputTitle)
|
|
459
|
+
return;
|
|
460
|
+
title = inputTitle;
|
|
461
|
+
}
|
|
462
|
+
// NOTE: --description-file handling is done early in execute(), before routing.
|
|
463
|
+
// flags.description is already populated if --description-file was provided.
|
|
464
|
+
const description = flags.description;
|
|
465
|
+
const pmoPriority = flags.priority;
|
|
466
|
+
const category = flags.category;
|
|
467
|
+
// Parse labels from flag
|
|
468
|
+
const labelsInput = flags.labels;
|
|
469
|
+
const labelNames = labelsInput
|
|
470
|
+
? labelsInput.split(',').map(l => l.trim()).filter(Boolean)
|
|
471
|
+
: [];
|
|
472
|
+
// Handle dry-run
|
|
473
|
+
if (flags['dry-run']) {
|
|
474
|
+
const wouldCreate = {
|
|
475
|
+
source: 'linear',
|
|
476
|
+
title: title,
|
|
477
|
+
...(description && { description }),
|
|
478
|
+
...(pmoPriority && { priority: pmoPriority }),
|
|
479
|
+
...(labelNames.length > 0 && { labels: labelNames }),
|
|
480
|
+
...(category && { category }),
|
|
481
|
+
};
|
|
482
|
+
if (jsonMode) {
|
|
483
|
+
outputDryRunSuccessAsJson('ticket', wouldCreate, createMetadata('ticket create', flags));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
this.log(styles.warning('\n[DRY RUN] Would create Linear issue:'));
|
|
487
|
+
this.log(styles.muted(` Title: ${title}`));
|
|
488
|
+
if (pmoPriority) {
|
|
489
|
+
this.log(styles.muted(` Priority: ${pmoPriority}`));
|
|
490
|
+
}
|
|
491
|
+
if (labelNames.length > 0) {
|
|
492
|
+
this.log(styles.muted(` Labels: ${labelNames.join(', ')}`));
|
|
493
|
+
}
|
|
494
|
+
if (category) {
|
|
495
|
+
this.log(styles.muted(` Category: ${category}`));
|
|
496
|
+
}
|
|
497
|
+
if (description) {
|
|
498
|
+
const shortDesc = description.split('\n')[0].substring(0, 60);
|
|
499
|
+
this.log(styles.muted(` Description: ${shortDesc}${description.length > 60 ? '...' : ''}`));
|
|
500
|
+
}
|
|
501
|
+
this.log(styles.muted('\n(No issue was created)'));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
// Get project for the provider (needed for PMO mirror)
|
|
505
|
+
const projectId = await this.requireProject({
|
|
506
|
+
jsonMode: {
|
|
507
|
+
flags: flags,
|
|
508
|
+
commandName: 'ticket create',
|
|
509
|
+
baseCommand: 'prlt ticket create --source linear',
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
// Route through provider adapter — Linear provider handles API call, mirror, and mapping
|
|
513
|
+
const provider = this.resolveProjectProvider(projectId, 'linear');
|
|
514
|
+
const teamKey = flags.team;
|
|
515
|
+
const createResult = await provider.createTicket(projectId, {
|
|
516
|
+
title: title,
|
|
517
|
+
description,
|
|
518
|
+
priority: pmoPriority,
|
|
519
|
+
category,
|
|
520
|
+
labels: labelNames.length > 0 ? labelNames : undefined,
|
|
521
|
+
metadata: teamKey ? { 'linear.team': teamKey } : undefined,
|
|
522
|
+
});
|
|
523
|
+
if (!createResult.success || !createResult.ticket) {
|
|
524
|
+
return handleError('CREATE_FAILED', `Failed to create ticket: ${createResult.error}`);
|
|
525
|
+
}
|
|
526
|
+
const ticket = createResult.ticket;
|
|
527
|
+
const externalKey = ticket.metadata?.external_key;
|
|
528
|
+
const externalUrl = ticket.metadata?.external_url;
|
|
529
|
+
// JSON output
|
|
530
|
+
if (jsonMode) {
|
|
531
|
+
this.log(JSON.stringify({
|
|
532
|
+
success: true,
|
|
533
|
+
source: 'linear',
|
|
534
|
+
ticket: {
|
|
535
|
+
id: ticket.id,
|
|
536
|
+
title: ticket.title,
|
|
537
|
+
priority: ticket.priority,
|
|
538
|
+
category: ticket.category,
|
|
539
|
+
statusName: ticket.statusName,
|
|
540
|
+
projectId: ticket.projectId,
|
|
541
|
+
...(externalKey && { externalKey }),
|
|
542
|
+
...(externalUrl && { externalUrl }),
|
|
543
|
+
},
|
|
544
|
+
}, null, 2));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
this.log(styles.success(`\n✅ Created ticket ${styles.emphasis(externalKey || ticket.id)} via Linear`));
|
|
548
|
+
this.log(styles.muted(` Title: ${ticket.title}`));
|
|
549
|
+
if (ticket.priority) {
|
|
550
|
+
this.log(styles.muted(` Priority: ${ticket.priority}`));
|
|
551
|
+
}
|
|
552
|
+
if (externalUrl) {
|
|
553
|
+
this.log(styles.muted(` URL: ${externalUrl}`));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async promptTicketData(flags, storage, existingTemplate, columns) {
|
|
557
|
+
// If no template was specified via flag, offer to select one
|
|
558
|
+
let template = existingTemplate;
|
|
559
|
+
if (!template && !flags.template) {
|
|
560
|
+
const templates = await storage.listTicketTemplates();
|
|
561
|
+
if (templates.length > 0) {
|
|
562
|
+
const { selectedTemplate } = await this.prompt([
|
|
563
|
+
{
|
|
564
|
+
type: 'list',
|
|
565
|
+
name: 'selectedTemplate',
|
|
566
|
+
message: 'Start from a template?',
|
|
567
|
+
choices: [
|
|
568
|
+
{ name: 'No template (blank ticket)', value: '' },
|
|
569
|
+
new inquirer.Separator('── Templates ──'),
|
|
570
|
+
...templates.map(t => ({
|
|
571
|
+
name: `${t.name}${t.isBuiltin ? '' : ' [custom]'} - ${t.description || ''}`,
|
|
572
|
+
value: t.id,
|
|
573
|
+
})),
|
|
574
|
+
],
|
|
575
|
+
},
|
|
576
|
+
], null);
|
|
577
|
+
if (selectedTemplate) {
|
|
578
|
+
template = templates.find(t => t.id === selectedTemplate) || null;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// Prompt for title
|
|
583
|
+
const { title: answerTitle } = await this.prompt([
|
|
584
|
+
{
|
|
585
|
+
type: 'input',
|
|
586
|
+
name: 'title',
|
|
587
|
+
message: 'Ticket title:',
|
|
588
|
+
default: flags.title || template?.titlePattern,
|
|
589
|
+
validate: (input) => input.trim() ? true : 'Title cannot be empty',
|
|
590
|
+
},
|
|
591
|
+
], null);
|
|
592
|
+
// Prompt for column
|
|
593
|
+
const { column: answerColumn } = await this.prompt([
|
|
594
|
+
{
|
|
595
|
+
type: 'list',
|
|
596
|
+
name: 'column',
|
|
597
|
+
message: 'Column:',
|
|
598
|
+
choices: columns.map(c => ({ name: c, value: c })),
|
|
599
|
+
default: flags.column || columns[0],
|
|
600
|
+
},
|
|
601
|
+
], null);
|
|
602
|
+
// Prompt for priority (using workspace priority scale)
|
|
603
|
+
const db = this.storage.getDatabase();
|
|
604
|
+
const workspacePriorities = getWorkspacePriorities(db);
|
|
605
|
+
const { priority: answerPriority } = await this.prompt([
|
|
606
|
+
{
|
|
607
|
+
type: 'list',
|
|
608
|
+
name: 'priority',
|
|
609
|
+
message: 'Priority:',
|
|
610
|
+
choices: [
|
|
611
|
+
{ name: 'None', value: undefined },
|
|
612
|
+
...workspacePriorities.map(p => ({ name: p, value: p })),
|
|
613
|
+
],
|
|
614
|
+
default: flags.priority || template?.defaultPriority,
|
|
615
|
+
},
|
|
616
|
+
], null);
|
|
617
|
+
// Prompt for category
|
|
618
|
+
const { categoryChoice } = await this.prompt([
|
|
619
|
+
{
|
|
620
|
+
type: 'list',
|
|
621
|
+
name: 'categoryChoice',
|
|
622
|
+
message: 'Category:',
|
|
623
|
+
choices: [
|
|
624
|
+
{ name: 'Skip (none)', value: '' },
|
|
625
|
+
new inquirer.Separator('── Conventional Commits ──'),
|
|
626
|
+
{ name: 'feature - New feature or capability', value: 'feature' },
|
|
627
|
+
{ name: 'bug - Bug fix', value: 'bug' },
|
|
628
|
+
{ name: 'refactor - Code refactoring', value: 'refactor' },
|
|
629
|
+
{ name: 'docs - Documentation', value: 'docs' },
|
|
630
|
+
{ name: 'test - Test additions/fixes', value: 'test' },
|
|
631
|
+
{ name: 'chore - Maintenance tasks', value: 'chore' },
|
|
632
|
+
{ name: 'performance - Performance improvements', value: 'performance' },
|
|
633
|
+
{ name: 'ci - CI/CD changes', value: 'ci' },
|
|
634
|
+
{ name: 'build - Build system changes', value: 'build' },
|
|
635
|
+
new inquirer.Separator('── Extended Types ──'),
|
|
636
|
+
{ name: 'security - Security fixes', value: 'security' },
|
|
637
|
+
{ name: 'database - Database migrations', value: 'database' },
|
|
638
|
+
{ name: 'release - Release preparation', value: 'release' },
|
|
639
|
+
new inquirer.Separator('── 5Tool Founder ──'),
|
|
640
|
+
{ name: 'ship - Shipping and deployment', value: 'ship' },
|
|
641
|
+
{ name: 'growth - Growth and marketing', value: 'growth' },
|
|
642
|
+
{ name: 'support - Customer experience', value: 'support' },
|
|
643
|
+
{ name: 'strategy - Strategy and planning', value: 'strategy' },
|
|
644
|
+
{ name: 'ops - Business operations', value: 'ops' },
|
|
645
|
+
new inquirer.Separator('───────────────────'),
|
|
646
|
+
{ name: 'Custom...', value: '__custom__' },
|
|
647
|
+
],
|
|
648
|
+
default: flags.category || template?.defaultCategory || '',
|
|
649
|
+
},
|
|
650
|
+
], null);
|
|
651
|
+
// Custom category prompt if needed
|
|
652
|
+
let customCategory;
|
|
653
|
+
if (categoryChoice === '__custom__') {
|
|
654
|
+
const result = await this.prompt([{
|
|
655
|
+
type: 'input',
|
|
656
|
+
name: 'customCategory',
|
|
657
|
+
message: 'Enter custom category:',
|
|
658
|
+
validate: (input) => input.trim() ? true : 'Category cannot be empty',
|
|
659
|
+
}], null);
|
|
660
|
+
customCategory = result.customCategory;
|
|
661
|
+
}
|
|
662
|
+
const answers = { title: answerTitle, column: answerColumn, priority: answerPriority, categoryChoice, customCategory };
|
|
663
|
+
// Resolve category from choice or custom input
|
|
664
|
+
const category = answers.categoryChoice === '__custom__'
|
|
665
|
+
? answers.customCategory
|
|
666
|
+
: answers.categoryChoice || undefined;
|
|
667
|
+
// Prompt for structured description (use template description if available)
|
|
668
|
+
const description = await this.promptStructuredDescription(flags.description || template?.descriptionTemplate);
|
|
669
|
+
// Parse labels from flag or use template defaults
|
|
670
|
+
const labels = flags.labels
|
|
671
|
+
? flags.labels.split(',').map(l => l.trim()).filter(Boolean)
|
|
672
|
+
: template?.defaultLabels;
|
|
673
|
+
return {
|
|
674
|
+
title: answers.title,
|
|
675
|
+
statusName: answers.column,
|
|
676
|
+
priority: answers.priority || undefined,
|
|
677
|
+
category,
|
|
678
|
+
description: description || undefined,
|
|
679
|
+
id: flags.id,
|
|
680
|
+
epicId: flags.epic,
|
|
681
|
+
labels,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
async promptStructuredDescription(existingDescription) {
|
|
685
|
+
// If description already provided via flag, use it
|
|
686
|
+
if (existingDescription) {
|
|
687
|
+
return existingDescription;
|
|
688
|
+
}
|
|
689
|
+
this.log(styles.muted('\n─── Ticket Description (for agent execution) ───'));
|
|
690
|
+
// Prompt for "What" - the main outcome
|
|
691
|
+
const { what } = await this.prompt([
|
|
692
|
+
{
|
|
693
|
+
type: 'input',
|
|
694
|
+
name: 'what',
|
|
695
|
+
message: 'What is the concrete outcome? (one sentence):',
|
|
696
|
+
validate: (input) => input.trim() ? true : 'Outcome cannot be empty - what does success look like?',
|
|
697
|
+
},
|
|
698
|
+
], null);
|
|
699
|
+
// Prompt for acceptance criteria using multiline input
|
|
700
|
+
const doneWhenResult = await multiLineInput({
|
|
701
|
+
message: 'Done when (acceptance criteria):',
|
|
702
|
+
hint: 'Enter each criterion on a new line. Ctrl+D to finish, Ctrl+C to cancel',
|
|
703
|
+
});
|
|
704
|
+
if (doneWhenResult.cancelled) {
|
|
705
|
+
throw new Error('Ticket creation cancelled');
|
|
706
|
+
}
|
|
707
|
+
// Continue with remaining prompts
|
|
708
|
+
const { context } = await this.prompt([
|
|
709
|
+
{
|
|
710
|
+
type: 'input',
|
|
711
|
+
name: 'context',
|
|
712
|
+
message: 'Context (files, patterns, hints - optional):',
|
|
713
|
+
default: '',
|
|
714
|
+
},
|
|
715
|
+
], null);
|
|
716
|
+
const { notInScope } = await this.prompt([
|
|
717
|
+
{
|
|
718
|
+
type: 'input',
|
|
719
|
+
name: 'notInScope',
|
|
720
|
+
message: 'Not in scope (explicit exclusions - optional):',
|
|
721
|
+
default: '',
|
|
722
|
+
},
|
|
723
|
+
], null);
|
|
724
|
+
// Build structured description
|
|
725
|
+
const parts = [];
|
|
726
|
+
parts.push(`## What\n${what}`);
|
|
727
|
+
if (doneWhenResult.value.trim()) {
|
|
728
|
+
// Ensure each line in doneWhen starts with - [ ] if it doesn't already
|
|
729
|
+
const criteria = doneWhenResult.value
|
|
730
|
+
.split('\n')
|
|
731
|
+
.map(line => line.trim())
|
|
732
|
+
.filter(line => line.length > 0)
|
|
733
|
+
.map(line => {
|
|
734
|
+
if (line.startsWith('- [ ]') || line.startsWith('- [x]')) {
|
|
735
|
+
return line;
|
|
736
|
+
}
|
|
737
|
+
if (line.startsWith('-')) {
|
|
738
|
+
return `- [ ]${line.slice(1)}`;
|
|
739
|
+
}
|
|
740
|
+
return `- [ ] ${line}`;
|
|
741
|
+
})
|
|
742
|
+
.join('\n');
|
|
743
|
+
parts.push(`## Done when\n${criteria}`);
|
|
744
|
+
}
|
|
745
|
+
if (context.trim()) {
|
|
746
|
+
parts.push(`## Context\n${context}`);
|
|
747
|
+
}
|
|
748
|
+
if (notInScope.trim()) {
|
|
749
|
+
parts.push(`## Not in scope\n${notInScope}`);
|
|
750
|
+
}
|
|
751
|
+
return parts.join('\n\n');
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
//# sourceMappingURL=create.js.map
|