tissues 0.5.2 → 0.6.1
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/README.md +94 -40
- package/package.json +3 -4
- package/src/cli.js +26 -22
- package/src/commands/ai.js +268 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +1035 -12
- package/src/commands/create.js +523 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/enhancements.js +282 -0
- package/src/commands/list.js +7 -5
- package/src/commands/status.js +81 -19
- package/src/commands/templates.js +157 -0
- package/src/lib/ai/adapters/anthropic.js +52 -0
- package/src/lib/ai/adapters/base.js +45 -0
- package/src/lib/ai/adapters/command.js +68 -0
- package/src/lib/ai/adapters/gemini.js +56 -0
- package/src/lib/ai/adapters/ollama.js +60 -0
- package/src/lib/ai/adapters/openai-compat.js +51 -0
- package/src/lib/ai/adapters/openai.js +44 -0
- package/src/lib/ai/body-template.js +75 -0
- package/src/lib/ai/enhance.js +107 -0
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +97 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +216 -0
- package/src/lib/ai/steps.js +492 -0
- package/src/lib/attribution.js +18 -179
- package/src/lib/clipboard.js +147 -0
- package/src/lib/color.js +9 -0
- package/src/lib/dedup.js +67 -32
- package/src/lib/defaults.js +54 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/gh.js +102 -21
- package/src/lib/repo-picker.js +2 -0
- package/src/lib/safety.js +1 -1
- package/src/lib/templates.js +8 -12
- package/src/lib/theme.js +9 -0
- package/src/commands/use.js +0 -19
package/src/commands/create.js
CHANGED
|
@@ -8,35 +8,63 @@ import { checkSafety, recordSuccess, recordFailure } from '../lib/safety.js'
|
|
|
8
8
|
import { checkDuplicate, recordCreation } from '../lib/dedup.js'
|
|
9
9
|
import { listTemplates, loadTemplate, renderTemplate } from '../lib/templates.js'
|
|
10
10
|
import { renderAttribution } from '../lib/attribution.js'
|
|
11
|
-
import { computeFingerprint } from '../lib/dedup.js'
|
|
12
11
|
import { pickRepo } from '../lib/repo-picker.js'
|
|
12
|
+
import { writeDraft, updateDraftItem, writeToDrafts, markComplete, markFailed, hasPending, countPending, attachFile } from '../lib/drafts.js'
|
|
13
13
|
import {
|
|
14
14
|
requireAuth,
|
|
15
15
|
createIssue,
|
|
16
|
+
verifyIssue,
|
|
16
17
|
listLabels,
|
|
18
|
+
listIssues,
|
|
17
19
|
createLabel,
|
|
18
20
|
addLabelsToIssue,
|
|
21
|
+
uploadImageToRepo,
|
|
19
22
|
} from '../lib/gh.js'
|
|
20
|
-
import
|
|
23
|
+
import { enhance, checkAvailable } from '../lib/ai/index.js'
|
|
24
|
+
import { runEnhancePipeline } from '../lib/ai/enhance.js'
|
|
25
|
+
import { clipboardHasImage, saveClipboardImage } from '../lib/clipboard.js'
|
|
26
|
+
import { bold, dim, red, green, yellow, cyan } from '../lib/color.js'
|
|
21
27
|
import ora from 'ora'
|
|
28
|
+
import { theme } from '../lib/theme.js'
|
|
22
29
|
|
|
23
30
|
// ---------------------------------------------------------------------------
|
|
24
31
|
// Helpers
|
|
25
32
|
// ---------------------------------------------------------------------------
|
|
26
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Split freeform user input into a title and body.
|
|
36
|
+
*
|
|
37
|
+
* Rules:
|
|
38
|
+
* 1. Explicit newlines → first line = title, rest = body
|
|
39
|
+
* 2. Short input (≤80 chars) → just a title
|
|
40
|
+
* 3. Long single line → break at last word boundary before 80 chars
|
|
41
|
+
*
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @returns {{ title: string, body: string }}
|
|
44
|
+
*/
|
|
45
|
+
function splitInput(text) {
|
|
46
|
+
const trimmed = text.trim()
|
|
47
|
+
if (trimmed.includes('\n')) {
|
|
48
|
+
const idx = trimmed.indexOf('\n')
|
|
49
|
+
return { title: trimmed.slice(0, idx).trim(), body: trimmed.slice(idx + 1).trim() }
|
|
50
|
+
}
|
|
51
|
+
if (trimmed.length <= 80) return { title: trimmed, body: '' }
|
|
52
|
+
const cut = trimmed.lastIndexOf(' ', 80)
|
|
53
|
+
const at = cut > 20 ? cut : 80
|
|
54
|
+
return { title: trimmed.slice(0, at).trim(), body: trimmed }
|
|
55
|
+
}
|
|
56
|
+
|
|
27
57
|
/**
|
|
28
58
|
* Print the circuit breaker state if it is not 'closed'.
|
|
29
59
|
*
|
|
30
60
|
* @param {string} circuitState
|
|
31
61
|
*/
|
|
32
62
|
function warnIfCircuitNotClosed(circuitState) {
|
|
33
|
-
if (circuitState
|
|
34
|
-
const stateColor = circuitState === 'open' ? chalk.red : chalk.yellow
|
|
63
|
+
if (circuitState !== 'open') return
|
|
35
64
|
console.warn(
|
|
36
|
-
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
'. Use `tissues status` to view details.',
|
|
65
|
+
yellow('[safety]') +
|
|
66
|
+
' Issue submission is paused due to recent failures.' +
|
|
67
|
+
' Run ' + cyan('tissues status') + ' for details.',
|
|
40
68
|
)
|
|
41
69
|
}
|
|
42
70
|
|
|
@@ -52,7 +80,7 @@ function formatDedupResult(result) {
|
|
|
52
80
|
if (result.existingIssue) {
|
|
53
81
|
const { number, title, url } = result.existingIssue
|
|
54
82
|
if (number) parts.push(` Existing issue: #${number}${title ? ` — ${title}` : ''}`)
|
|
55
|
-
if (url) parts.push(` URL: ${
|
|
83
|
+
if (url) parts.push(` URL: ${cyan(url)}`)
|
|
56
84
|
}
|
|
57
85
|
return parts.join('\n')
|
|
58
86
|
}
|
|
@@ -76,34 +104,10 @@ function runPostCreateHook(hookCmd, ctx) {
|
|
|
76
104
|
stdio: 'inherit',
|
|
77
105
|
})
|
|
78
106
|
} catch (err) {
|
|
79
|
-
console.warn(
|
|
107
|
+
console.warn(yellow(`[hooks] postCreate hook failed: ${err.message}`))
|
|
80
108
|
}
|
|
81
109
|
}
|
|
82
110
|
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// AI enhancement stub
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Enhance issue body with AI. Currently a structured placeholder.
|
|
89
|
-
* Wire up real AI (OpenAI / Anthropic) here.
|
|
90
|
-
*
|
|
91
|
-
* @param {string} title - issue title
|
|
92
|
-
* @param {string} description - the actual issue content describing what the issue is about;
|
|
93
|
-
* this is rendered into the template body via {{description}} and forms the core of the issue
|
|
94
|
-
* @param {string} templateBody - already-rendered template body to use as context
|
|
95
|
-
* @param {string} [instructions] - optional AI prompt instructions that guide *how* the AI
|
|
96
|
-
* writes or post-processes the issue body (e.g. "keep it under 200 words",
|
|
97
|
-
* "after creating, send to this webhook: https://..."). Does NOT appear in the issue itself.
|
|
98
|
-
* @returns {Promise<string>}
|
|
99
|
-
*/
|
|
100
|
-
async function enhanceWithAI(title, description, templateBody, instructions) {
|
|
101
|
-
// TODO: wire up real AI (OpenAI / Anthropic)
|
|
102
|
-
// - description goes INTO the issue body (rendered via {{description}} in the template)
|
|
103
|
-
// - instructions guide the AI behaviour but are NOT included in the output
|
|
104
|
-
return templateBody
|
|
105
|
-
}
|
|
106
|
-
|
|
107
111
|
// ---------------------------------------------------------------------------
|
|
108
112
|
// AbortError — thrown for user-initiated cancellations (exit 0)
|
|
109
113
|
// ---------------------------------------------------------------------------
|
|
@@ -127,7 +131,7 @@ class AbortError extends Error {
|
|
|
127
131
|
* @throws {AbortError} when the user cancels interactively
|
|
128
132
|
* @throws {Error} on hard failures
|
|
129
133
|
*/
|
|
130
|
-
async function runCreate(opts) {
|
|
134
|
+
export async function runCreate(opts) {
|
|
131
135
|
// -----------------------------------------------------------------------
|
|
132
136
|
// 0. Ensure authenticated
|
|
133
137
|
// -----------------------------------------------------------------------
|
|
@@ -141,7 +145,7 @@ async function runCreate(opts) {
|
|
|
141
145
|
repo = store.get('activeRepo')
|
|
142
146
|
}
|
|
143
147
|
if (!repo) {
|
|
144
|
-
console.log(
|
|
148
|
+
console.log(yellow('No active repo. Pick one:\n'))
|
|
145
149
|
repo = await pickRepo()
|
|
146
150
|
console.log()
|
|
147
151
|
}
|
|
@@ -151,45 +155,190 @@ async function runCreate(opts) {
|
|
|
151
155
|
// -----------------------------------------------------------------------
|
|
152
156
|
const repoRoot = findRepoRoot()
|
|
153
157
|
const config = loadConfig(repoRoot)
|
|
158
|
+
const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
|
|
154
159
|
|
|
155
160
|
// -----------------------------------------------------------------------
|
|
156
|
-
// 3.
|
|
161
|
+
// 3. Get inputs
|
|
157
162
|
// -----------------------------------------------------------------------
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
163
|
+
const isInteractive = process.stdin.isTTY && process.stdout.isTTY
|
|
164
|
+
|
|
165
|
+
let title, description, instructions
|
|
166
|
+
|
|
167
|
+
if (opts.title) {
|
|
168
|
+
// --title flag: skip wizard entirely (scripted / batch / ai use)
|
|
169
|
+
title = opts.title
|
|
170
|
+
description = opts.body ?? undefined
|
|
171
|
+
instructions = opts.instructions ?? undefined
|
|
172
|
+
} else if (isInteractive) {
|
|
173
|
+
// Single freeform prompt
|
|
174
|
+
const rawInput = await input({
|
|
175
|
+
message: "What's the issue",
|
|
176
|
+
default: opts._titleDefault,
|
|
177
|
+
theme,
|
|
178
|
+
})
|
|
179
|
+
if (!rawInput || !rawInput.trim()) {
|
|
180
|
+
console.error(red('Input is required.'))
|
|
181
|
+
throw new Error('Input is required')
|
|
182
|
+
}
|
|
183
|
+
const split = splitInput(rawInput)
|
|
184
|
+
title = split.title
|
|
185
|
+
description = opts.body ?? (split.body || undefined)
|
|
186
|
+
instructions = opts.instructions ?? undefined
|
|
187
|
+
// Pass the full user text as instructions for AI enhancement
|
|
188
|
+
if (!instructions && rawInput.trim().length > title.length) {
|
|
189
|
+
instructions = rawInput.trim()
|
|
190
|
+
}
|
|
191
|
+
// Preserve raw input for triage step — AI will refine title/description
|
|
192
|
+
opts._rawInput = rawInput.trim()
|
|
193
|
+
} else {
|
|
194
|
+
// Non-interactive without --title
|
|
195
|
+
title = opts._titleDefault
|
|
196
|
+
description = opts.body ?? undefined
|
|
197
|
+
instructions = opts.instructions ?? undefined
|
|
198
|
+
}
|
|
162
199
|
|
|
163
|
-
if (!
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
if (!title || !title.trim()) {
|
|
201
|
+
if (!isInteractive) {
|
|
202
|
+
console.error(red('Input is required. Pass --title <title> when running non-interactively.'))
|
|
203
|
+
} else {
|
|
204
|
+
console.error(red('Input is required.'))
|
|
205
|
+
}
|
|
206
|
+
throw new Error('Input is required')
|
|
167
207
|
}
|
|
168
208
|
|
|
169
209
|
// -----------------------------------------------------------------------
|
|
170
|
-
//
|
|
210
|
+
// 3b. Parse labels early (needed by dedup context and draft write)
|
|
171
211
|
// -----------------------------------------------------------------------
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
212
|
+
const labels = opts.labels
|
|
213
|
+
? opts.labels
|
|
214
|
+
.split(',')
|
|
215
|
+
.map((l) => l.trim())
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
: []
|
|
218
|
+
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
// 3c. Check dedup BEFORE writing draft (no phantom drafts on block)
|
|
221
|
+
// -----------------------------------------------------------------------
|
|
222
|
+
const relatedIssues = []
|
|
223
|
+
|
|
224
|
+
if (!opts.draft) {
|
|
225
|
+
const dedupSpinner = ora('Checking for duplicates...').start()
|
|
226
|
+
let dedupResult
|
|
227
|
+
try {
|
|
228
|
+
dedupResult = await checkDuplicate(repo, {
|
|
229
|
+
title,
|
|
230
|
+
body: description,
|
|
231
|
+
agent,
|
|
232
|
+
})
|
|
233
|
+
dedupSpinner.stop()
|
|
234
|
+
} catch (err) {
|
|
235
|
+
dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
|
|
236
|
+
dedupResult = { action: 'allow', results: [] }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (dedupResult.action === 'block') {
|
|
240
|
+
if (!isInteractive) recordFailure(repo, agent, config.safety)
|
|
241
|
+
const blockReasons = dedupResult.results.filter((r) => r.action === 'block')
|
|
242
|
+
for (const r of blockReasons) {
|
|
243
|
+
console.error(formatDedupResult(r))
|
|
244
|
+
}
|
|
245
|
+
console.error(red('Duplicate detected — issue not created.'))
|
|
246
|
+
throw new Error('Duplicate detected')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (dedupResult.action === 'warn' && !opts.force) {
|
|
250
|
+
console.warn(yellow('\nSimilar issue(s) found:'))
|
|
251
|
+
for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
|
|
252
|
+
console.warn(formatDedupResult(r))
|
|
253
|
+
if (r.existingIssue?.number) {
|
|
254
|
+
relatedIssues.push({ number: r.existingIssue.number })
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
console.warn()
|
|
258
|
+
|
|
259
|
+
const proceed = await confirm({
|
|
260
|
+
message: 'Create anyway?',
|
|
261
|
+
default: false,
|
|
262
|
+
theme,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
if (!proceed) {
|
|
266
|
+
console.log(dim('Aborted.'))
|
|
267
|
+
throw new AbortError('Aborted by user')
|
|
268
|
+
}
|
|
269
|
+
}
|
|
177
270
|
}
|
|
178
271
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
272
|
+
// -----------------------------------------------------------------------
|
|
273
|
+
// 3d. Write draft — user data is safe from this point on
|
|
274
|
+
// -----------------------------------------------------------------------
|
|
275
|
+
const draft = opts.dryRun
|
|
276
|
+
? null
|
|
277
|
+
: writeDraft({ repo, title, description, instructions, labels }, repoRoot)
|
|
278
|
+
|
|
279
|
+
// -----------------------------------------------------------------------
|
|
280
|
+
// 3e. Attach files (--attach flag and clipboard image detection)
|
|
281
|
+
// -----------------------------------------------------------------------
|
|
282
|
+
const attachPaths = opts.attach
|
|
283
|
+
? opts.attach.split(',').map((p) => p.trim()).filter(Boolean)
|
|
284
|
+
: []
|
|
285
|
+
|
|
286
|
+
const attachments = []
|
|
287
|
+
const imageUrls = [] // URLs from uploaded images for embedding in body
|
|
288
|
+
if (draft) {
|
|
289
|
+
// Attach files from --attach flag
|
|
290
|
+
for (const fp of attachPaths) {
|
|
291
|
+
try {
|
|
292
|
+
const meta = attachFile(draft.id, fp, repoRoot)
|
|
293
|
+
attachments.push(meta)
|
|
294
|
+
console.log(green(' ✔') + ` Attached: ${meta.filename} (${Math.round(meta.size / 1024)} KB)`)
|
|
295
|
+
|
|
296
|
+
// Upload image files to repo for embedding
|
|
297
|
+
if (/\.(png|jpe?g|gif|webp)$/i.test(fp)) {
|
|
298
|
+
try {
|
|
299
|
+
const imgName = `${Date.now()}-${meta.filename}`
|
|
300
|
+
const { url } = uploadImageToRepo(repo, fp, imgName)
|
|
301
|
+
imageUrls.push({ filename: meta.filename, url })
|
|
302
|
+
console.log(green(' ✔') + ` Uploaded to repo`)
|
|
303
|
+
} catch (uploadErr) {
|
|
304
|
+
console.warn(yellow(` ⚠ Could not upload ${meta.filename}: ${uploadErr.message}`))
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch (err) {
|
|
308
|
+
console.warn(yellow(` ⚠ Could not attach ${fp}: ${err.message}`))
|
|
309
|
+
}
|
|
310
|
+
}
|
|
184
311
|
|
|
185
|
-
|
|
186
|
-
opts.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
312
|
+
// Auto-detect clipboard image in interactive mode (no confirmation prompt)
|
|
313
|
+
if (isInteractive && !opts.dryRun && clipboardHasImage()) {
|
|
314
|
+
const { getAttachmentDir } = await import('../lib/drafts.js')
|
|
315
|
+
const attachDir = getAttachmentDir(draft.id, repoRoot)
|
|
316
|
+
fs.mkdirSync(attachDir, { recursive: true })
|
|
317
|
+
const clipName = `clipboard-${Date.now()}.png`
|
|
318
|
+
const destPath = `${attachDir}/${clipName}`
|
|
319
|
+
const saved = saveClipboardImage(destPath)
|
|
320
|
+
if (saved) {
|
|
321
|
+
const stat = fs.statSync(destPath)
|
|
322
|
+
const meta = { filename: clipName, size: stat.size, type: 'image/png' }
|
|
323
|
+
attachments.push(meta)
|
|
324
|
+
console.log(green(' ✔') + ` Clipboard image attached (${Math.round(stat.size / 1024)} KB)`)
|
|
325
|
+
|
|
326
|
+
// Upload to repo for embedding
|
|
327
|
+
try {
|
|
328
|
+
const { url } = uploadImageToRepo(repo, destPath, clipName)
|
|
329
|
+
imageUrls.push({ filename: 'Clipboard image', url })
|
|
330
|
+
console.log(green(' ✔') + ` Uploaded to repo`)
|
|
331
|
+
} catch (uploadErr) {
|
|
332
|
+
console.warn(yellow(` ⚠ Could not upload clipboard image: ${uploadErr.message}`))
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
console.warn(yellow(' ⚠ Could not save clipboard image'))
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
190
339
|
|
|
191
340
|
// -----------------------------------------------------------------------
|
|
192
|
-
//
|
|
341
|
+
// 4. Pick template
|
|
193
342
|
// -----------------------------------------------------------------------
|
|
194
343
|
let templateName = opts.template
|
|
195
344
|
|
|
@@ -208,7 +357,7 @@ async function runCreate(opts) {
|
|
|
208
357
|
if (!seen.has(tpl.key)) {
|
|
209
358
|
seen.add(tpl.key)
|
|
210
359
|
choices.push({
|
|
211
|
-
name: `${tpl.name} ${
|
|
360
|
+
name: `${tpl.name} ${dim(`(${tpl.source})`)}`,
|
|
212
361
|
value: tpl.key,
|
|
213
362
|
})
|
|
214
363
|
}
|
|
@@ -218,6 +367,7 @@ async function runCreate(opts) {
|
|
|
218
367
|
templateName = await select({
|
|
219
368
|
message: 'Choose a template',
|
|
220
369
|
choices,
|
|
370
|
+
theme,
|
|
221
371
|
})
|
|
222
372
|
} else {
|
|
223
373
|
templateName = 'default'
|
|
@@ -228,60 +378,14 @@ async function runCreate(opts) {
|
|
|
228
378
|
try {
|
|
229
379
|
template = loadTemplate(templateName, repoRoot)
|
|
230
380
|
} catch (err) {
|
|
231
|
-
console.error(
|
|
381
|
+
console.error(red(`Template error: ${err.message}`))
|
|
232
382
|
throw new Error(`Template error: ${err.message}`)
|
|
233
383
|
}
|
|
234
384
|
|
|
235
385
|
// -----------------------------------------------------------------------
|
|
236
|
-
//
|
|
386
|
+
// 5. Render template
|
|
237
387
|
// -----------------------------------------------------------------------
|
|
238
388
|
const session = opts.session ?? null
|
|
239
|
-
const dedupSpinner = ora('Checking for duplicates...').start()
|
|
240
|
-
let dedupResult
|
|
241
|
-
try {
|
|
242
|
-
dedupResult = await checkDuplicate(repo, {
|
|
243
|
-
title,
|
|
244
|
-
body: description,
|
|
245
|
-
agent,
|
|
246
|
-
// No idempotency key by default — callers who need deterministic keys
|
|
247
|
-
// can pass --session and combine with agent + title in future work
|
|
248
|
-
})
|
|
249
|
-
dedupSpinner.stop()
|
|
250
|
-
} catch (err) {
|
|
251
|
-
dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
|
|
252
|
-
dedupResult = { action: 'allow', results: [] }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (dedupResult.action === 'block') {
|
|
256
|
-
recordFailure(repo, agent, config.safety)
|
|
257
|
-
console.error(chalk.red('Duplicate detected — issue not created.'))
|
|
258
|
-
for (const r of dedupResult.results.filter((r) => r.action === 'block')) {
|
|
259
|
-
console.error(formatDedupResult(r))
|
|
260
|
-
}
|
|
261
|
-
throw new Error('Duplicate detected')
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (dedupResult.action === 'warn' && !opts.force) {
|
|
265
|
-
console.warn(chalk.yellow('\nSimilar issue(s) found:'))
|
|
266
|
-
for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
|
|
267
|
-
console.warn(formatDedupResult(r))
|
|
268
|
-
}
|
|
269
|
-
console.warn()
|
|
270
|
-
|
|
271
|
-
const proceed = await confirm({
|
|
272
|
-
message: 'Create anyway?',
|
|
273
|
-
default: false,
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
if (!proceed) {
|
|
277
|
-
console.log(chalk.dim('Aborted.'))
|
|
278
|
-
throw new AbortError('Aborted by user')
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// -----------------------------------------------------------------------
|
|
283
|
-
// 7. Render template
|
|
284
|
-
// -----------------------------------------------------------------------
|
|
285
389
|
const renderedTemplate = renderTemplate(template.body, {
|
|
286
390
|
title,
|
|
287
391
|
description: description || '',
|
|
@@ -292,63 +396,234 @@ async function runCreate(opts) {
|
|
|
292
396
|
})
|
|
293
397
|
|
|
294
398
|
// -----------------------------------------------------------------------
|
|
295
|
-
//
|
|
399
|
+
// 6. AI enhancement (skip for --no-enhance or --dry-run)
|
|
296
400
|
// -----------------------------------------------------------------------
|
|
297
401
|
let body = renderedTemplate
|
|
402
|
+
let pipelineResult = null
|
|
298
403
|
if (opts.enhance !== false && !opts.dryRun) {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
404
|
+
const enhancementNames = opts.enhancements
|
|
405
|
+
? opts.enhancements.split(',').map((s) => s.trim()).filter(Boolean)
|
|
406
|
+
: undefined
|
|
407
|
+
const aiContext = {
|
|
408
|
+
template: templateName,
|
|
409
|
+
labels: opts.labels ? opts.labels.split(',').map((l) => l.trim()).filter(Boolean) : [],
|
|
410
|
+
provider: opts.provider,
|
|
411
|
+
model: opts.model,
|
|
412
|
+
enhancements: enhancementNames,
|
|
413
|
+
}
|
|
414
|
+
if (checkAvailable(config, aiContext)) {
|
|
415
|
+
const pipelineConfig = config.ai?.pipeline || {}
|
|
416
|
+
const usePipeline = opts.pipeline === true ||
|
|
417
|
+
(pipelineConfig.enabled && opts.pipeline !== false)
|
|
418
|
+
|
|
419
|
+
if (usePipeline) {
|
|
420
|
+
// Multi-step pipeline enhancement
|
|
421
|
+
let aiSpinner = ora('Enhancing with AI pipeline...').start()
|
|
422
|
+
try {
|
|
423
|
+
// Gather existing issues and repo labels for pipeline context
|
|
424
|
+
let existingIssues = []
|
|
425
|
+
let repoLabels = []
|
|
426
|
+
try { existingIssues = listIssues(repo) } catch { /* non-fatal */ }
|
|
427
|
+
try { repoLabels = listLabels(repo) } catch { /* non-fatal */ }
|
|
428
|
+
|
|
429
|
+
// When --title is explicit, skip triage (no rawInput to analyze)
|
|
430
|
+
const rawInput = opts.title ? '' : (opts._rawInput || '')
|
|
431
|
+
|
|
432
|
+
pipelineResult = await runEnhancePipeline(config, {
|
|
433
|
+
rawInput,
|
|
434
|
+
title,
|
|
435
|
+
description,
|
|
436
|
+
instructions,
|
|
437
|
+
templateBody: renderedTemplate,
|
|
438
|
+
labels,
|
|
439
|
+
existingIssues,
|
|
440
|
+
repoLabels,
|
|
441
|
+
}, {
|
|
442
|
+
onStepStart(step) {
|
|
443
|
+
if (!aiSpinner.isSpinning) aiSpinner = ora().start()
|
|
444
|
+
aiSpinner.text = `${step.displayName}...`
|
|
445
|
+
},
|
|
446
|
+
onStepDone(step, ctx) {
|
|
447
|
+
let detail = ''
|
|
448
|
+
if (step.name === 'triage') detail = ctx.title ? `: ${ctx.title}` : ''
|
|
449
|
+
if (step.name === 'dedup') detail = ctx.dedupScore ? ` (${ctx.dedupScore.level})` : ' (none)'
|
|
450
|
+
if (step.name === 'scope') detail = ctx.scopeAnalysis ? ` (${ctx.scopeAnalysis.files.length} files)` : ''
|
|
451
|
+
if (step.name === 'complexity') detail = ctx.complexity != null ? `: ${ctx.complexity}/10` : ''
|
|
452
|
+
if (step.name === 'risk') detail = ctx.risk != null ? `: ${ctx.risk}/10` : ''
|
|
453
|
+
if (step.name === 'labels') detail = ctx.aiLabels?.length ? `: ${ctx.aiLabels.join(', ')}` : ''
|
|
454
|
+
aiSpinner.succeed(`${step.displayName}${detail}`)
|
|
455
|
+
},
|
|
456
|
+
onStepSkip(step) { /* silent */ },
|
|
457
|
+
onStepFail(step, err) {
|
|
458
|
+
if (!aiSpinner.isSpinning) aiSpinner = ora().start()
|
|
459
|
+
aiSpinner.warn(`${step.displayName} failed`)
|
|
460
|
+
console.warn(dim(` ${err.message}`))
|
|
461
|
+
},
|
|
462
|
+
}, aiContext)
|
|
463
|
+
|
|
464
|
+
aiSpinner.stop()
|
|
465
|
+
body = pipelineResult.body || renderedTemplate
|
|
466
|
+
|
|
467
|
+
// Use AI-derived title if triage step produced one
|
|
468
|
+
if (pipelineResult.title && pipelineResult.title !== title) {
|
|
469
|
+
title = pipelineResult.title
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Merge AI-suggested labels
|
|
473
|
+
if (pipelineResult.aiLabels?.length) {
|
|
474
|
+
for (const l of pipelineResult.aiLabels) {
|
|
475
|
+
if (!labels.includes(l)) labels.push(l)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Collect AI dedup matches as related issues
|
|
480
|
+
if (pipelineResult.dedupScore?.matches?.length) {
|
|
481
|
+
const seen = new Set(relatedIssues.map((i) => i.number))
|
|
482
|
+
for (const m of pipelineResult.dedupScore.matches) {
|
|
483
|
+
if (m.number && !seen.has(m.number)) {
|
|
484
|
+
relatedIssues.push({ number: m.number })
|
|
485
|
+
seen.add(m.number)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Warn on high AI dedup confidence
|
|
491
|
+
if (pipelineResult.dedupScore?.level === 'high' && !opts.force) {
|
|
492
|
+
console.warn(yellow('\nAI detected likely duplicate:'))
|
|
493
|
+
for (const m of pipelineResult.dedupScore.matches || []) {
|
|
494
|
+
console.warn(dim(` #${m.number}: ${m.reason}`))
|
|
495
|
+
}
|
|
496
|
+
console.warn()
|
|
497
|
+
}
|
|
498
|
+
} catch (err) {
|
|
499
|
+
aiSpinner.stop()
|
|
500
|
+
if (/\b401\b/.test(err.message)) {
|
|
501
|
+
console.warn(red(' API key is invalid or expired.'))
|
|
502
|
+
console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
|
|
503
|
+
} else {
|
|
504
|
+
console.warn(yellow('Pipeline failed — falling back to single-shot enhancement'))
|
|
505
|
+
console.warn(dim(` ${err.message}`))
|
|
506
|
+
}
|
|
507
|
+
// Fall back to single-shot
|
|
508
|
+
try {
|
|
509
|
+
body = await enhance(config, title, description, renderedTemplate, instructions, aiContext)
|
|
510
|
+
} catch {
|
|
511
|
+
// Use template as-is
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
// Single-shot enhancement (original behavior)
|
|
516
|
+
const aiSpinner = ora('Enhancing with AI...').start()
|
|
517
|
+
try {
|
|
518
|
+
body = await enhance(config, title, description, renderedTemplate, instructions, aiContext)
|
|
519
|
+
aiSpinner.succeed('Enhanced')
|
|
520
|
+
} catch (err) {
|
|
521
|
+
if (/\b401\b/.test(err.message)) {
|
|
522
|
+
aiSpinner.fail('AI enhancement failed')
|
|
523
|
+
console.warn(red(' API key is invalid or expired.'))
|
|
524
|
+
console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
|
|
525
|
+
console.warn(dim(' Continuing without AI enhancement.\n'))
|
|
526
|
+
} else {
|
|
527
|
+
aiSpinner.warn(`AI enhancement failed — using template as-is`)
|
|
528
|
+
console.warn(dim(` ${err.message}`))
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
305
532
|
}
|
|
306
533
|
}
|
|
307
534
|
|
|
308
535
|
// -----------------------------------------------------------------------
|
|
309
|
-
//
|
|
536
|
+
// 6b. Embed uploaded images in body
|
|
537
|
+
// -----------------------------------------------------------------------
|
|
538
|
+
if (imageUrls.length > 0) {
|
|
539
|
+
const imageMarkdown = imageUrls
|
|
540
|
+
.map(({ filename, url }) => ``)
|
|
541
|
+
.join('\n')
|
|
542
|
+
body = body.trimEnd() + '\n\n' + imageMarkdown
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// -----------------------------------------------------------------------
|
|
546
|
+
// 6c. Add related issues (dedup matches)
|
|
547
|
+
// -----------------------------------------------------------------------
|
|
548
|
+
if (relatedIssues.length > 0) {
|
|
549
|
+
const refs = relatedIssues.map((i) => `#${i.number}`).join(' ')
|
|
550
|
+
body = body.trimEnd() + `\n\n---\n**Possibly Duplicate:** ${refs}`
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// -----------------------------------------------------------------------
|
|
554
|
+
// 7. Add attribution
|
|
310
555
|
// -----------------------------------------------------------------------
|
|
311
|
-
const fingerprint = computeFingerprint(title, body)
|
|
312
556
|
const attributionBlock = renderAttribution({
|
|
313
|
-
agent,
|
|
314
557
|
session: session ?? undefined,
|
|
315
|
-
|
|
316
|
-
fingerprint: `sha256:${fingerprint}`,
|
|
558
|
+
model: opts.model ?? undefined,
|
|
317
559
|
})
|
|
318
560
|
|
|
319
|
-
|
|
561
|
+
if (attributionBlock) {
|
|
562
|
+
body = body.trimEnd() + '\n\n' + attributionBlock
|
|
563
|
+
}
|
|
320
564
|
|
|
321
565
|
// -----------------------------------------------------------------------
|
|
322
|
-
//
|
|
566
|
+
// 8. Update draft with processed body — draft → pending
|
|
323
567
|
// -----------------------------------------------------------------------
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
.map((l) => l.trim())
|
|
328
|
-
.filter(Boolean)
|
|
329
|
-
: []
|
|
568
|
+
if (draft) {
|
|
569
|
+
updateDraftItem(draft.id, { title, body, labels }, repoRoot)
|
|
570
|
+
}
|
|
330
571
|
|
|
331
572
|
// -----------------------------------------------------------------------
|
|
332
573
|
// Dry run — stop here
|
|
333
574
|
// -----------------------------------------------------------------------
|
|
334
575
|
if (opts.dryRun) {
|
|
335
576
|
const border = '─'.repeat(44)
|
|
336
|
-
console.log(
|
|
337
|
-
console.log(` ${
|
|
338
|
-
console.log(` ${
|
|
339
|
-
console.log(` ${
|
|
340
|
-
console.log(` ${
|
|
341
|
-
if (labels.length > 0) console.log(` ${
|
|
342
|
-
console.log(`\n ${
|
|
577
|
+
console.log(cyan(`\n─── DRY RUN ${border.slice(11)}`))
|
|
578
|
+
console.log(` ${bold('Repo: ')} ${repo}`)
|
|
579
|
+
console.log(` ${bold('Title: ')} ${title}`)
|
|
580
|
+
console.log(` ${bold('Agent: ')} ${agent}`)
|
|
581
|
+
console.log(` ${bold('Template:')} ${template.name} (${template.source})`)
|
|
582
|
+
if (labels.length > 0) console.log(` ${bold('Labels: ')} ${labels.join(', ')}`)
|
|
583
|
+
console.log(`\n ${bold('Body:')}`)
|
|
343
584
|
console.log(renderedTemplate)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
585
|
+
if (attributionBlock) {
|
|
586
|
+
console.log(`\n ${dim('─── attribution ───')}`)
|
|
587
|
+
console.log(dim(attributionBlock))
|
|
588
|
+
}
|
|
589
|
+
console.log(cyan(`${border}\n No issue created.\n`))
|
|
590
|
+
return null
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// -----------------------------------------------------------------------
|
|
594
|
+
// 9. Draft mode — stop here, issue is already saved
|
|
595
|
+
// -----------------------------------------------------------------------
|
|
596
|
+
if (opts.draft) {
|
|
597
|
+
console.log()
|
|
598
|
+
console.log(green('✔') + ' ' + bold('Draft saved.'))
|
|
599
|
+
console.log(` ${dim('Title:')} ${title}`)
|
|
600
|
+
if (labels.length > 0) console.log(` ${dim('Labels:')} ${labels.join(', ')}`)
|
|
601
|
+
console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to review and send.'))
|
|
347
602
|
return null
|
|
348
603
|
}
|
|
349
604
|
|
|
350
605
|
// -----------------------------------------------------------------------
|
|
351
|
-
//
|
|
606
|
+
// 10. Check safety (skip circuit breaker for interactive/human wizard)
|
|
607
|
+
// -----------------------------------------------------------------------
|
|
608
|
+
if (!isInteractive) {
|
|
609
|
+
const safetyResult = checkSafety(repo, agent, config.safety)
|
|
610
|
+
warnIfCircuitNotClosed(safetyResult.circuitState)
|
|
611
|
+
|
|
612
|
+
if (!safetyResult.allowed) {
|
|
613
|
+
// Issue is saved in drafts — tell the user and exit cleanly
|
|
614
|
+
console.log()
|
|
615
|
+
console.log(
|
|
616
|
+
yellow('⏸') + ' ' + bold('Issue saved to drafts.') +
|
|
617
|
+
' Safety check blocked submission:',
|
|
618
|
+
)
|
|
619
|
+
console.log(dim(` ${safetyResult.reason}`))
|
|
620
|
+
console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to send when ready.'))
|
|
621
|
+
process.exit(0)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// -----------------------------------------------------------------------
|
|
626
|
+
// 12. Create issue (with graceful label handling)
|
|
352
627
|
// -----------------------------------------------------------------------
|
|
353
628
|
let existingLabels = labels
|
|
354
629
|
let missingLabels = []
|
|
@@ -366,21 +641,38 @@ async function runCreate(opts) {
|
|
|
366
641
|
const createSpinner = ora('Creating issue...').start()
|
|
367
642
|
let issue
|
|
368
643
|
try {
|
|
369
|
-
issue =
|
|
370
|
-
createSpinner.succeed(`Issue created: ${
|
|
644
|
+
issue = createIssue(repo, { title, body, labels: existingLabels })
|
|
645
|
+
createSpinner.succeed(`Issue created: ${cyan(issue.url)}`)
|
|
371
646
|
} catch (err) {
|
|
372
647
|
// GitHub API failures are NOT safety failures — do not record to circuit
|
|
373
648
|
createSpinner.fail(`Failed to create issue: ${err.message}`)
|
|
649
|
+
markFailed(draft.id, err.message, repoRoot)
|
|
650
|
+
console.log(dim(' Issue saved to drafts. Run ') + cyan('tissues drafts') + dim(' to retry.'))
|
|
374
651
|
throw new Error(`Failed to create issue: ${err.message}`)
|
|
375
652
|
}
|
|
376
653
|
|
|
654
|
+
// -----------------------------------------------------------------------
|
|
655
|
+
// 13. Verify issue exists before removing draft
|
|
656
|
+
// -----------------------------------------------------------------------
|
|
657
|
+
const verified = verifyIssue(repo, issue.number)
|
|
658
|
+
if (!verified) {
|
|
659
|
+
console.warn(
|
|
660
|
+
yellow(` Issue #${issue.number} could not be verified — keeping in drafts`),
|
|
661
|
+
)
|
|
662
|
+
console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to retry.'))
|
|
663
|
+
// Don't throw — the issue URL was returned, it may still be fine
|
|
664
|
+
} else {
|
|
665
|
+
markComplete(draft.id, { issueNumber: issue.number, issueUrl: issue.url }, repoRoot)
|
|
666
|
+
}
|
|
667
|
+
|
|
377
668
|
if (missingLabels.length > 0) {
|
|
378
669
|
console.log(
|
|
379
|
-
|
|
670
|
+
yellow(`Labels not found: ${missingLabels.join(', ')} — issue created without them`),
|
|
380
671
|
)
|
|
381
672
|
const createMissing = await confirm({
|
|
382
673
|
message: 'Create missing labels and apply them?',
|
|
383
674
|
default: true,
|
|
675
|
+
theme,
|
|
384
676
|
})
|
|
385
677
|
if (createMissing) {
|
|
386
678
|
for (const labelName of missingLabels) {
|
|
@@ -394,26 +686,45 @@ async function runCreate(opts) {
|
|
|
394
686
|
}
|
|
395
687
|
try {
|
|
396
688
|
addLabelsToIssue(repo, issue.number, missingLabels)
|
|
397
|
-
console.log(
|
|
689
|
+
console.log(green('✓ Labels created and applied'))
|
|
398
690
|
} catch (err) {
|
|
399
|
-
console.warn(
|
|
691
|
+
console.warn(yellow(`Failed to apply labels: ${err.message}`))
|
|
400
692
|
}
|
|
401
693
|
}
|
|
402
694
|
}
|
|
403
695
|
|
|
404
696
|
// -----------------------------------------------------------------------
|
|
405
|
-
//
|
|
697
|
+
// 13b. Attachment notice
|
|
698
|
+
// -----------------------------------------------------------------------
|
|
699
|
+
if (attachments.length > 0) {
|
|
700
|
+
const nonUploaded = attachments.length - imageUrls.length
|
|
701
|
+
if (nonUploaded > 0) {
|
|
702
|
+
const { getAttachmentDir } = await import('../lib/drafts.js')
|
|
703
|
+
const attachDir = getAttachmentDir(draft.id, repoRoot)
|
|
704
|
+
console.log(
|
|
705
|
+
dim(` 📎 ${nonUploaded} non-image attachment${nonUploaded === 1 ? '' : 's'} saved locally. Upload manually to the issue.`),
|
|
706
|
+
)
|
|
707
|
+
console.log(dim(` ${attachDir}`))
|
|
708
|
+
}
|
|
709
|
+
if (imageUrls.length > 0) {
|
|
710
|
+
console.log(dim(` 📎 ${imageUrls.length} image${imageUrls.length === 1 ? '' : 's'} uploaded and embedded in issue body.`))
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// -----------------------------------------------------------------------
|
|
715
|
+
// 14. Record success
|
|
406
716
|
// -----------------------------------------------------------------------
|
|
407
717
|
try {
|
|
408
718
|
await recordCreation(repo, {
|
|
409
719
|
title,
|
|
410
720
|
body,
|
|
411
721
|
issueNumber: issue.number,
|
|
722
|
+
url: issue.url,
|
|
412
723
|
agent,
|
|
413
724
|
})
|
|
414
725
|
} catch (err) {
|
|
415
726
|
// Non-fatal — dedup DB write failure shouldn't abort the command
|
|
416
|
-
console.warn(
|
|
727
|
+
console.warn(dim(`[dedup] Failed to record fingerprint: ${err.message}`))
|
|
417
728
|
}
|
|
418
729
|
|
|
419
730
|
recordSuccess(repo, agent)
|
|
@@ -441,7 +752,7 @@ export const createCommand = new Command('create')
|
|
|
441
752
|
.description('Create a new GitHub issue')
|
|
442
753
|
.argument('[title...]', 'Issue title (positional shorthand for --title)')
|
|
443
754
|
.option('--repo <repo>', 'Repository override (owner/name)')
|
|
444
|
-
.option('--template <name>', 'Template to use (bug, feature,
|
|
755
|
+
.option('--template <name>', 'Template to use (default, bug, feature, or custom)')
|
|
445
756
|
.option('--agent <name>', 'Agent identifier for attribution (default: human)')
|
|
446
757
|
.option('--session <id>', 'Session ID for attribution')
|
|
447
758
|
.option('--title <title>', 'Issue title (skips interactive prompt)')
|
|
@@ -450,7 +761,14 @@ export const createCommand = new Command('create')
|
|
|
450
761
|
.option('--labels <labels>', 'Comma-separated labels to apply')
|
|
451
762
|
.option('--force', 'Skip dedup warnings (still blocks on exact matches)')
|
|
452
763
|
.option('--dry-run', 'Check dedup and safety without creating the issue')
|
|
764
|
+
.option('--draft', 'Save to drafts without creating the issue')
|
|
765
|
+
.option('--attach <paths>', 'Comma-separated file paths to attach')
|
|
453
766
|
.option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
|
|
767
|
+
.option('--pipeline', 'Force multi-step AI pipeline even if config disabled')
|
|
768
|
+
.option('--no-pipeline', 'Force single-shot AI enhancement even if pipeline enabled')
|
|
769
|
+
.option('--enhancements <names>', 'Comma-separated enhancement names to run (e.g. "context,risk")')
|
|
770
|
+
.option('--provider <name>', 'AI provider override (anthropic, openai, gemini)')
|
|
771
|
+
.option('--model <name>', 'AI model override')
|
|
454
772
|
.option(
|
|
455
773
|
'--batch <file>',
|
|
456
774
|
'Create multiple issues from a JSON file (title, body, template, labels, agent, session per item)',
|
|
@@ -459,6 +777,24 @@ export const createCommand = new Command('create')
|
|
|
459
777
|
// Positional words → prefill the prompt (user can still edit)
|
|
460
778
|
// --title flag → skip the prompt entirely (scripted/batch use)
|
|
461
779
|
if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
|
|
780
|
+
|
|
781
|
+
// -----------------------------------------------------------------------
|
|
782
|
+
// Recovery check — notify user if there are pending draft items
|
|
783
|
+
// -----------------------------------------------------------------------
|
|
784
|
+
if (!opts.batch && !opts.dryRun) {
|
|
785
|
+
const repoRoot = findRepoRoot()
|
|
786
|
+
const pending = countPending(repoRoot)
|
|
787
|
+
if (pending > 0) {
|
|
788
|
+
console.log(
|
|
789
|
+
cyan('✨') +
|
|
790
|
+
` ${bold(`${pending} issue${pending === 1 ? '' : 's'} saved`)} from a previous session.`,
|
|
791
|
+
)
|
|
792
|
+
console.log(
|
|
793
|
+
dim(' Run ') + cyan('tissues drafts') + dim(' to manage them, or continue to create a new issue.\n'),
|
|
794
|
+
)
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
462
798
|
// -----------------------------------------------------------------------
|
|
463
799
|
// Batch mode
|
|
464
800
|
// -----------------------------------------------------------------------
|
|
@@ -467,18 +803,18 @@ export const createCommand = new Command('create')
|
|
|
467
803
|
try {
|
|
468
804
|
items = JSON.parse(fs.readFileSync(opts.batch, 'utf8'))
|
|
469
805
|
} catch (err) {
|
|
470
|
-
console.error(
|
|
806
|
+
console.error(red(`Failed to read batch file: ${err.message}`))
|
|
471
807
|
process.exit(1)
|
|
472
808
|
}
|
|
473
809
|
|
|
474
810
|
if (!Array.isArray(items)) {
|
|
475
|
-
console.error(
|
|
811
|
+
console.error(red('Batch file must contain a JSON array'))
|
|
476
812
|
process.exit(1)
|
|
477
813
|
}
|
|
478
814
|
|
|
479
815
|
const results = []
|
|
480
816
|
for (const [i, item] of items.entries()) {
|
|
481
|
-
console.log(
|
|
817
|
+
console.log(dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
|
|
482
818
|
// item fields override CLI opts; labels array → comma-string for runCreate
|
|
483
819
|
const itemOpts = {
|
|
484
820
|
...opts,
|
|
@@ -493,10 +829,10 @@ export const createCommand = new Command('create')
|
|
|
493
829
|
}
|
|
494
830
|
}
|
|
495
831
|
|
|
496
|
-
console.log(
|
|
832
|
+
console.log(bold('\nBatch summary:'))
|
|
497
833
|
for (const r of results) {
|
|
498
|
-
if (r.ok) console.log(
|
|
499
|
-
else console.log(
|
|
834
|
+
if (r.ok) console.log(green(` ✔ ${r.title}`) + dim(` — ${r.url}`))
|
|
835
|
+
else console.log(red(` ✖ ${r.title}`) + dim(` — ${r.error}`))
|
|
500
836
|
}
|
|
501
837
|
process.exit(results.every((r) => r.ok) ? 0 : 1)
|
|
502
838
|
return
|
|
@@ -507,7 +843,37 @@ export const createCommand = new Command('create')
|
|
|
507
843
|
// -----------------------------------------------------------------------
|
|
508
844
|
try {
|
|
509
845
|
const result = await runCreate(opts)
|
|
510
|
-
if (result === null) process.exit(0) // dry-run
|
|
846
|
+
if (result === null) process.exit(0) // dry-run or draft
|
|
847
|
+
} catch (err) {
|
|
848
|
+
process.exit(err.isAbort ? 0 : 1)
|
|
849
|
+
}
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
// Draft command — alias for create --draft
|
|
854
|
+
// ---------------------------------------------------------------------------
|
|
855
|
+
|
|
856
|
+
export const draftCommand = new Command('draft')
|
|
857
|
+
.description('Draft an issue without creating it')
|
|
858
|
+
.argument('[title...]', 'Issue title')
|
|
859
|
+
.option('--repo <repo>', 'Repository override (owner/name)')
|
|
860
|
+
.option('--template <name>', 'Template to use')
|
|
861
|
+
.option('--agent <name>', 'Agent identifier for attribution (default: human)')
|
|
862
|
+
.option('--session <id>', 'Session ID for attribution')
|
|
863
|
+
.option('--title <title>', 'Issue title (skips interactive prompt)')
|
|
864
|
+
.option('--body <body>', 'Issue body / description (skips interactive prompt)')
|
|
865
|
+
.option('--instructions <text>', 'AI enhancement instruction')
|
|
866
|
+
.option('--labels <labels>', 'Comma-separated labels to apply')
|
|
867
|
+
.option('--attach <paths>', 'Comma-separated file paths to attach')
|
|
868
|
+
.option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
|
|
869
|
+
.option('--provider <name>', 'AI provider override (anthropic, openai, gemini)')
|
|
870
|
+
.option('--model <name>', 'AI model override')
|
|
871
|
+
.action(async (titleArg, opts) => {
|
|
872
|
+
if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
|
|
873
|
+
opts.draft = true
|
|
874
|
+
try {
|
|
875
|
+
await runCreate(opts)
|
|
876
|
+
process.exit(0)
|
|
511
877
|
} catch (err) {
|
|
512
878
|
process.exit(err.isAbort ? 0 : 1)
|
|
513
879
|
}
|