tissues 0.5.2 → 0.6.0
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 +24 -22
- package/src/commands/ai.js +266 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +961 -12
- package/src/commands/create.js +516 -157
- package/src/commands/drafts.js +288 -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 +58 -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 +60 -0
- package/src/lib/ai/enhance.js +70 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +79 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +128 -0
- package/src/lib/ai/steps.js +472 -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 +33 -4
- package/src/lib/defaults.js +38 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/gh.js +86 -11
- 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,228 @@ 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 aiContext = {
|
|
405
|
+
template: templateName,
|
|
406
|
+
labels: opts.labels ? opts.labels.split(',').map((l) => l.trim()).filter(Boolean) : [],
|
|
407
|
+
provider: opts.provider,
|
|
408
|
+
model: opts.model,
|
|
409
|
+
}
|
|
410
|
+
if (checkAvailable(config, aiContext)) {
|
|
411
|
+
const pipelineConfig = config.ai?.pipeline || {}
|
|
412
|
+
const usePipeline = opts.pipeline === true ||
|
|
413
|
+
(pipelineConfig.enabled && opts.pipeline !== false)
|
|
414
|
+
|
|
415
|
+
if (usePipeline) {
|
|
416
|
+
// Multi-step pipeline enhancement
|
|
417
|
+
let aiSpinner = ora('Enhancing with AI pipeline...').start()
|
|
418
|
+
try {
|
|
419
|
+
// Gather existing issues and repo labels for pipeline context
|
|
420
|
+
let existingIssues = []
|
|
421
|
+
let repoLabels = []
|
|
422
|
+
try { existingIssues = listIssues(repo) } catch { /* non-fatal */ }
|
|
423
|
+
try { repoLabels = listLabels(repo) } catch { /* non-fatal */ }
|
|
424
|
+
|
|
425
|
+
// When --title is explicit, skip triage (no rawInput to analyze)
|
|
426
|
+
const rawInput = opts.title ? '' : (opts._rawInput || '')
|
|
427
|
+
|
|
428
|
+
pipelineResult = await runEnhancePipeline(config, {
|
|
429
|
+
rawInput,
|
|
430
|
+
title,
|
|
431
|
+
description,
|
|
432
|
+
instructions,
|
|
433
|
+
templateBody: renderedTemplate,
|
|
434
|
+
labels,
|
|
435
|
+
existingIssues,
|
|
436
|
+
repoLabels,
|
|
437
|
+
}, {
|
|
438
|
+
onStepStart(step) { aiSpinner.text = `${step.displayName}...` },
|
|
439
|
+
onStepDone(step, ctx) {
|
|
440
|
+
let detail = ''
|
|
441
|
+
if (step.name === 'triage') detail = ctx.title ? `: ${ctx.title}` : ''
|
|
442
|
+
if (step.name === 'dedup') detail = ctx.dedupScore ? ` (${ctx.dedupScore.level})` : ' (none)'
|
|
443
|
+
if (step.name === 'scope') detail = ctx.scopeAnalysis ? ` (${ctx.scopeAnalysis.files.length} files)` : ''
|
|
444
|
+
if (step.name === 'complexity') detail = ctx.complexity != null ? `: ${ctx.complexity}/10` : ''
|
|
445
|
+
if (step.name === 'risk') detail = ctx.risk != null ? `: ${ctx.risk}/10` : ''
|
|
446
|
+
if (step.name === 'labels') detail = ctx.aiLabels?.length ? `: ${ctx.aiLabels.join(', ')}` : ''
|
|
447
|
+
aiSpinner.succeed(`${step.displayName}${detail}`)
|
|
448
|
+
aiSpinner = ora('').start()
|
|
449
|
+
},
|
|
450
|
+
onStepSkip(step) { /* silent */ },
|
|
451
|
+
onStepFail(step, err) {
|
|
452
|
+
aiSpinner.warn(`${step.displayName} failed`)
|
|
453
|
+
console.warn(dim(` ${err.message}`))
|
|
454
|
+
aiSpinner = ora('').start()
|
|
455
|
+
},
|
|
456
|
+
}, aiContext)
|
|
457
|
+
|
|
458
|
+
aiSpinner.stop()
|
|
459
|
+
body = pipelineResult.body || renderedTemplate
|
|
460
|
+
|
|
461
|
+
// Use AI-derived title if triage step produced one
|
|
462
|
+
if (pipelineResult.title && pipelineResult.title !== title) {
|
|
463
|
+
title = pipelineResult.title
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Merge AI-suggested labels
|
|
467
|
+
if (pipelineResult.aiLabels?.length) {
|
|
468
|
+
for (const l of pipelineResult.aiLabels) {
|
|
469
|
+
if (!labels.includes(l)) labels.push(l)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Collect AI dedup matches as related issues
|
|
474
|
+
if (pipelineResult.dedupScore?.matches?.length) {
|
|
475
|
+
const seen = new Set(relatedIssues.map((i) => i.number))
|
|
476
|
+
for (const m of pipelineResult.dedupScore.matches) {
|
|
477
|
+
if (m.number && !seen.has(m.number)) {
|
|
478
|
+
relatedIssues.push({ number: m.number })
|
|
479
|
+
seen.add(m.number)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Warn on high AI dedup confidence
|
|
485
|
+
if (pipelineResult.dedupScore?.level === 'high' && !opts.force) {
|
|
486
|
+
console.warn(yellow('\nAI detected likely duplicate:'))
|
|
487
|
+
for (const m of pipelineResult.dedupScore.matches || []) {
|
|
488
|
+
console.warn(dim(` #${m.number}: ${m.reason}`))
|
|
489
|
+
}
|
|
490
|
+
console.warn()
|
|
491
|
+
}
|
|
492
|
+
} catch (err) {
|
|
493
|
+
aiSpinner.stop()
|
|
494
|
+
if (/\b401\b/.test(err.message)) {
|
|
495
|
+
console.warn(red(' API key is invalid or expired.'))
|
|
496
|
+
console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
|
|
497
|
+
} else {
|
|
498
|
+
console.warn(yellow('Pipeline failed — falling back to single-shot enhancement'))
|
|
499
|
+
console.warn(dim(` ${err.message}`))
|
|
500
|
+
}
|
|
501
|
+
// Fall back to single-shot
|
|
502
|
+
try {
|
|
503
|
+
body = await enhance(config, title, description, renderedTemplate, instructions, aiContext)
|
|
504
|
+
} catch {
|
|
505
|
+
// Use template as-is
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
// Single-shot enhancement (original behavior)
|
|
510
|
+
const aiSpinner = ora('Enhancing with AI...').start()
|
|
511
|
+
try {
|
|
512
|
+
body = await enhance(config, title, description, renderedTemplate, instructions, aiContext)
|
|
513
|
+
aiSpinner.succeed('Enhanced')
|
|
514
|
+
} catch (err) {
|
|
515
|
+
if (/\b401\b/.test(err.message)) {
|
|
516
|
+
aiSpinner.fail('AI enhancement failed')
|
|
517
|
+
console.warn(red(' API key is invalid or expired.'))
|
|
518
|
+
console.warn(dim(' Run ') + cyan('tissues config') + dim(' to update your API key.'))
|
|
519
|
+
console.warn(dim(' Continuing without AI enhancement.\n'))
|
|
520
|
+
} else {
|
|
521
|
+
aiSpinner.warn(`AI enhancement failed — using template as-is`)
|
|
522
|
+
console.warn(dim(` ${err.message}`))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
305
526
|
}
|
|
306
527
|
}
|
|
307
528
|
|
|
308
529
|
// -----------------------------------------------------------------------
|
|
309
|
-
//
|
|
530
|
+
// 6b. Embed uploaded images in body
|
|
531
|
+
// -----------------------------------------------------------------------
|
|
532
|
+
if (imageUrls.length > 0) {
|
|
533
|
+
const imageMarkdown = imageUrls
|
|
534
|
+
.map(({ filename, url }) => ``)
|
|
535
|
+
.join('\n')
|
|
536
|
+
body = body.trimEnd() + '\n\n' + imageMarkdown
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// -----------------------------------------------------------------------
|
|
540
|
+
// 6c. Add related issues (dedup matches)
|
|
541
|
+
// -----------------------------------------------------------------------
|
|
542
|
+
if (relatedIssues.length > 0) {
|
|
543
|
+
const refs = relatedIssues.map((i) => `#${i.number}`).join(' ')
|
|
544
|
+
body = body.trimEnd() + `\n\n---\n**Possibly Duplicate:** ${refs}`
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// -----------------------------------------------------------------------
|
|
548
|
+
// 7. Add attribution
|
|
310
549
|
// -----------------------------------------------------------------------
|
|
311
|
-
const fingerprint = computeFingerprint(title, body)
|
|
312
550
|
const attributionBlock = renderAttribution({
|
|
313
|
-
agent,
|
|
314
551
|
session: session ?? undefined,
|
|
315
|
-
|
|
316
|
-
fingerprint: `sha256:${fingerprint}`,
|
|
552
|
+
model: opts.model ?? undefined,
|
|
317
553
|
})
|
|
318
554
|
|
|
319
|
-
|
|
555
|
+
if (attributionBlock) {
|
|
556
|
+
body = body.trimEnd() + '\n\n' + attributionBlock
|
|
557
|
+
}
|
|
320
558
|
|
|
321
559
|
// -----------------------------------------------------------------------
|
|
322
|
-
//
|
|
560
|
+
// 8. Update draft with processed body — draft → pending
|
|
323
561
|
// -----------------------------------------------------------------------
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
.map((l) => l.trim())
|
|
328
|
-
.filter(Boolean)
|
|
329
|
-
: []
|
|
562
|
+
if (draft) {
|
|
563
|
+
updateDraftItem(draft.id, { title, body, labels }, repoRoot)
|
|
564
|
+
}
|
|
330
565
|
|
|
331
566
|
// -----------------------------------------------------------------------
|
|
332
567
|
// Dry run — stop here
|
|
333
568
|
// -----------------------------------------------------------------------
|
|
334
569
|
if (opts.dryRun) {
|
|
335
570
|
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 ${
|
|
571
|
+
console.log(cyan(`\n─── DRY RUN ${border.slice(11)}`))
|
|
572
|
+
console.log(` ${bold('Repo: ')} ${repo}`)
|
|
573
|
+
console.log(` ${bold('Title: ')} ${title}`)
|
|
574
|
+
console.log(` ${bold('Agent: ')} ${agent}`)
|
|
575
|
+
console.log(` ${bold('Template:')} ${template.name} (${template.source})`)
|
|
576
|
+
if (labels.length > 0) console.log(` ${bold('Labels: ')} ${labels.join(', ')}`)
|
|
577
|
+
console.log(`\n ${bold('Body:')}`)
|
|
343
578
|
console.log(renderedTemplate)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
579
|
+
if (attributionBlock) {
|
|
580
|
+
console.log(`\n ${dim('─── attribution ───')}`)
|
|
581
|
+
console.log(dim(attributionBlock))
|
|
582
|
+
}
|
|
583
|
+
console.log(cyan(`${border}\n No issue created.\n`))
|
|
584
|
+
return null
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// -----------------------------------------------------------------------
|
|
588
|
+
// 9. Draft mode — stop here, issue is already saved
|
|
589
|
+
// -----------------------------------------------------------------------
|
|
590
|
+
if (opts.draft) {
|
|
591
|
+
console.log()
|
|
592
|
+
console.log(green('✔') + ' ' + bold('Draft saved.'))
|
|
593
|
+
console.log(` ${dim('Title:')} ${title}`)
|
|
594
|
+
if (labels.length > 0) console.log(` ${dim('Labels:')} ${labels.join(', ')}`)
|
|
595
|
+
console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to review and send.'))
|
|
347
596
|
return null
|
|
348
597
|
}
|
|
349
598
|
|
|
350
599
|
// -----------------------------------------------------------------------
|
|
351
|
-
//
|
|
600
|
+
// 10. Check safety (skip circuit breaker for interactive/human wizard)
|
|
601
|
+
// -----------------------------------------------------------------------
|
|
602
|
+
if (!isInteractive) {
|
|
603
|
+
const safetyResult = checkSafety(repo, agent, config.safety)
|
|
604
|
+
warnIfCircuitNotClosed(safetyResult.circuitState)
|
|
605
|
+
|
|
606
|
+
if (!safetyResult.allowed) {
|
|
607
|
+
// Issue is saved in drafts — tell the user and exit cleanly
|
|
608
|
+
console.log()
|
|
609
|
+
console.log(
|
|
610
|
+
yellow('⏸') + ' ' + bold('Issue saved to drafts.') +
|
|
611
|
+
' Safety check blocked submission:',
|
|
612
|
+
)
|
|
613
|
+
console.log(dim(` ${safetyResult.reason}`))
|
|
614
|
+
console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to send when ready.'))
|
|
615
|
+
process.exit(0)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// -----------------------------------------------------------------------
|
|
620
|
+
// 12. Create issue (with graceful label handling)
|
|
352
621
|
// -----------------------------------------------------------------------
|
|
353
622
|
let existingLabels = labels
|
|
354
623
|
let missingLabels = []
|
|
@@ -366,21 +635,38 @@ async function runCreate(opts) {
|
|
|
366
635
|
const createSpinner = ora('Creating issue...').start()
|
|
367
636
|
let issue
|
|
368
637
|
try {
|
|
369
|
-
issue =
|
|
370
|
-
createSpinner.succeed(`Issue created: ${
|
|
638
|
+
issue = createIssue(repo, { title, body, labels: existingLabels })
|
|
639
|
+
createSpinner.succeed(`Issue created: ${cyan(issue.url)}`)
|
|
371
640
|
} catch (err) {
|
|
372
641
|
// GitHub API failures are NOT safety failures — do not record to circuit
|
|
373
642
|
createSpinner.fail(`Failed to create issue: ${err.message}`)
|
|
643
|
+
markFailed(draft.id, err.message, repoRoot)
|
|
644
|
+
console.log(dim(' Issue saved to drafts. Run ') + cyan('tissues drafts') + dim(' to retry.'))
|
|
374
645
|
throw new Error(`Failed to create issue: ${err.message}`)
|
|
375
646
|
}
|
|
376
647
|
|
|
648
|
+
// -----------------------------------------------------------------------
|
|
649
|
+
// 13. Verify issue exists before removing draft
|
|
650
|
+
// -----------------------------------------------------------------------
|
|
651
|
+
const verified = verifyIssue(repo, issue.number)
|
|
652
|
+
if (!verified) {
|
|
653
|
+
console.warn(
|
|
654
|
+
yellow(` Issue #${issue.number} could not be verified — keeping in drafts`),
|
|
655
|
+
)
|
|
656
|
+
console.log(dim(' Run ') + cyan('tissues drafts') + dim(' to retry.'))
|
|
657
|
+
// Don't throw — the issue URL was returned, it may still be fine
|
|
658
|
+
} else {
|
|
659
|
+
markComplete(draft.id, { issueNumber: issue.number, issueUrl: issue.url }, repoRoot)
|
|
660
|
+
}
|
|
661
|
+
|
|
377
662
|
if (missingLabels.length > 0) {
|
|
378
663
|
console.log(
|
|
379
|
-
|
|
664
|
+
yellow(`Labels not found: ${missingLabels.join(', ')} — issue created without them`),
|
|
380
665
|
)
|
|
381
666
|
const createMissing = await confirm({
|
|
382
667
|
message: 'Create missing labels and apply them?',
|
|
383
668
|
default: true,
|
|
669
|
+
theme,
|
|
384
670
|
})
|
|
385
671
|
if (createMissing) {
|
|
386
672
|
for (const labelName of missingLabels) {
|
|
@@ -394,26 +680,45 @@ async function runCreate(opts) {
|
|
|
394
680
|
}
|
|
395
681
|
try {
|
|
396
682
|
addLabelsToIssue(repo, issue.number, missingLabels)
|
|
397
|
-
console.log(
|
|
683
|
+
console.log(green('✓ Labels created and applied'))
|
|
398
684
|
} catch (err) {
|
|
399
|
-
console.warn(
|
|
685
|
+
console.warn(yellow(`Failed to apply labels: ${err.message}`))
|
|
400
686
|
}
|
|
401
687
|
}
|
|
402
688
|
}
|
|
403
689
|
|
|
404
690
|
// -----------------------------------------------------------------------
|
|
405
|
-
//
|
|
691
|
+
// 13b. Attachment notice
|
|
692
|
+
// -----------------------------------------------------------------------
|
|
693
|
+
if (attachments.length > 0) {
|
|
694
|
+
const nonUploaded = attachments.length - imageUrls.length
|
|
695
|
+
if (nonUploaded > 0) {
|
|
696
|
+
const { getAttachmentDir } = await import('../lib/drafts.js')
|
|
697
|
+
const attachDir = getAttachmentDir(draft.id, repoRoot)
|
|
698
|
+
console.log(
|
|
699
|
+
dim(` 📎 ${nonUploaded} non-image attachment${nonUploaded === 1 ? '' : 's'} saved locally. Upload manually to the issue.`),
|
|
700
|
+
)
|
|
701
|
+
console.log(dim(` ${attachDir}`))
|
|
702
|
+
}
|
|
703
|
+
if (imageUrls.length > 0) {
|
|
704
|
+
console.log(dim(` 📎 ${imageUrls.length} image${imageUrls.length === 1 ? '' : 's'} uploaded and embedded in issue body.`))
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// -----------------------------------------------------------------------
|
|
709
|
+
// 14. Record success
|
|
406
710
|
// -----------------------------------------------------------------------
|
|
407
711
|
try {
|
|
408
712
|
await recordCreation(repo, {
|
|
409
713
|
title,
|
|
410
714
|
body,
|
|
411
715
|
issueNumber: issue.number,
|
|
716
|
+
url: issue.url,
|
|
412
717
|
agent,
|
|
413
718
|
})
|
|
414
719
|
} catch (err) {
|
|
415
720
|
// Non-fatal — dedup DB write failure shouldn't abort the command
|
|
416
|
-
console.warn(
|
|
721
|
+
console.warn(dim(`[dedup] Failed to record fingerprint: ${err.message}`))
|
|
417
722
|
}
|
|
418
723
|
|
|
419
724
|
recordSuccess(repo, agent)
|
|
@@ -441,7 +746,7 @@ export const createCommand = new Command('create')
|
|
|
441
746
|
.description('Create a new GitHub issue')
|
|
442
747
|
.argument('[title...]', 'Issue title (positional shorthand for --title)')
|
|
443
748
|
.option('--repo <repo>', 'Repository override (owner/name)')
|
|
444
|
-
.option('--template <name>', 'Template to use (bug, feature,
|
|
749
|
+
.option('--template <name>', 'Template to use (default, bug, feature, or custom)')
|
|
445
750
|
.option('--agent <name>', 'Agent identifier for attribution (default: human)')
|
|
446
751
|
.option('--session <id>', 'Session ID for attribution')
|
|
447
752
|
.option('--title <title>', 'Issue title (skips interactive prompt)')
|
|
@@ -450,7 +755,13 @@ export const createCommand = new Command('create')
|
|
|
450
755
|
.option('--labels <labels>', 'Comma-separated labels to apply')
|
|
451
756
|
.option('--force', 'Skip dedup warnings (still blocks on exact matches)')
|
|
452
757
|
.option('--dry-run', 'Check dedup and safety without creating the issue')
|
|
758
|
+
.option('--draft', 'Save to drafts without creating the issue')
|
|
759
|
+
.option('--attach <paths>', 'Comma-separated file paths to attach')
|
|
453
760
|
.option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
|
|
761
|
+
.option('--pipeline', 'Force multi-step AI pipeline even if config disabled')
|
|
762
|
+
.option('--no-pipeline', 'Force single-shot AI enhancement even if pipeline enabled')
|
|
763
|
+
.option('--provider <name>', 'AI provider override (anthropic, openai, gemini)')
|
|
764
|
+
.option('--model <name>', 'AI model override')
|
|
454
765
|
.option(
|
|
455
766
|
'--batch <file>',
|
|
456
767
|
'Create multiple issues from a JSON file (title, body, template, labels, agent, session per item)',
|
|
@@ -459,6 +770,24 @@ export const createCommand = new Command('create')
|
|
|
459
770
|
// Positional words → prefill the prompt (user can still edit)
|
|
460
771
|
// --title flag → skip the prompt entirely (scripted/batch use)
|
|
461
772
|
if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
|
|
773
|
+
|
|
774
|
+
// -----------------------------------------------------------------------
|
|
775
|
+
// Recovery check — notify user if there are pending draft items
|
|
776
|
+
// -----------------------------------------------------------------------
|
|
777
|
+
if (!opts.batch && !opts.dryRun) {
|
|
778
|
+
const repoRoot = findRepoRoot()
|
|
779
|
+
const pending = countPending(repoRoot)
|
|
780
|
+
if (pending > 0) {
|
|
781
|
+
console.log(
|
|
782
|
+
cyan('✨') +
|
|
783
|
+
` ${bold(`${pending} issue${pending === 1 ? '' : 's'} saved`)} from a previous session.`,
|
|
784
|
+
)
|
|
785
|
+
console.log(
|
|
786
|
+
dim(' Run ') + cyan('tissues drafts') + dim(' to manage them, or continue to create a new issue.\n'),
|
|
787
|
+
)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
462
791
|
// -----------------------------------------------------------------------
|
|
463
792
|
// Batch mode
|
|
464
793
|
// -----------------------------------------------------------------------
|
|
@@ -467,18 +796,18 @@ export const createCommand = new Command('create')
|
|
|
467
796
|
try {
|
|
468
797
|
items = JSON.parse(fs.readFileSync(opts.batch, 'utf8'))
|
|
469
798
|
} catch (err) {
|
|
470
|
-
console.error(
|
|
799
|
+
console.error(red(`Failed to read batch file: ${err.message}`))
|
|
471
800
|
process.exit(1)
|
|
472
801
|
}
|
|
473
802
|
|
|
474
803
|
if (!Array.isArray(items)) {
|
|
475
|
-
console.error(
|
|
804
|
+
console.error(red('Batch file must contain a JSON array'))
|
|
476
805
|
process.exit(1)
|
|
477
806
|
}
|
|
478
807
|
|
|
479
808
|
const results = []
|
|
480
809
|
for (const [i, item] of items.entries()) {
|
|
481
|
-
console.log(
|
|
810
|
+
console.log(dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
|
|
482
811
|
// item fields override CLI opts; labels array → comma-string for runCreate
|
|
483
812
|
const itemOpts = {
|
|
484
813
|
...opts,
|
|
@@ -493,10 +822,10 @@ export const createCommand = new Command('create')
|
|
|
493
822
|
}
|
|
494
823
|
}
|
|
495
824
|
|
|
496
|
-
console.log(
|
|
825
|
+
console.log(bold('\nBatch summary:'))
|
|
497
826
|
for (const r of results) {
|
|
498
|
-
if (r.ok) console.log(
|
|
499
|
-
else console.log(
|
|
827
|
+
if (r.ok) console.log(green(` ✔ ${r.title}`) + dim(` — ${r.url}`))
|
|
828
|
+
else console.log(red(` ✖ ${r.title}`) + dim(` — ${r.error}`))
|
|
500
829
|
}
|
|
501
830
|
process.exit(results.every((r) => r.ok) ? 0 : 1)
|
|
502
831
|
return
|
|
@@ -507,7 +836,37 @@ export const createCommand = new Command('create')
|
|
|
507
836
|
// -----------------------------------------------------------------------
|
|
508
837
|
try {
|
|
509
838
|
const result = await runCreate(opts)
|
|
510
|
-
if (result === null) process.exit(0) // dry-run
|
|
839
|
+
if (result === null) process.exit(0) // dry-run or draft
|
|
840
|
+
} catch (err) {
|
|
841
|
+
process.exit(err.isAbort ? 0 : 1)
|
|
842
|
+
}
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
// ---------------------------------------------------------------------------
|
|
846
|
+
// Draft command — alias for create --draft
|
|
847
|
+
// ---------------------------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
export const draftCommand = new Command('draft')
|
|
850
|
+
.description('Draft an issue without creating it')
|
|
851
|
+
.argument('[title...]', 'Issue title')
|
|
852
|
+
.option('--repo <repo>', 'Repository override (owner/name)')
|
|
853
|
+
.option('--template <name>', 'Template to use')
|
|
854
|
+
.option('--agent <name>', 'Agent identifier for attribution (default: human)')
|
|
855
|
+
.option('--session <id>', 'Session ID for attribution')
|
|
856
|
+
.option('--title <title>', 'Issue title (skips interactive prompt)')
|
|
857
|
+
.option('--body <body>', 'Issue body / description (skips interactive prompt)')
|
|
858
|
+
.option('--instructions <text>', 'AI enhancement instruction')
|
|
859
|
+
.option('--labels <labels>', 'Comma-separated labels to apply')
|
|
860
|
+
.option('--attach <paths>', 'Comma-separated file paths to attach')
|
|
861
|
+
.option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
|
|
862
|
+
.option('--provider <name>', 'AI provider override (anthropic, openai, gemini)')
|
|
863
|
+
.option('--model <name>', 'AI model override')
|
|
864
|
+
.action(async (titleArg, opts) => {
|
|
865
|
+
if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
|
|
866
|
+
opts.draft = true
|
|
867
|
+
try {
|
|
868
|
+
await runCreate(opts)
|
|
869
|
+
process.exit(0)
|
|
511
870
|
} catch (err) {
|
|
512
871
|
process.exit(err.isAbort ? 0 : 1)
|
|
513
872
|
}
|