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.
Files changed (40) hide show
  1. package/README.md +94 -40
  2. package/package.json +3 -4
  3. package/src/cli.js +26 -22
  4. package/src/commands/ai.js +268 -0
  5. package/src/commands/auth.js +4 -4
  6. package/src/commands/config.js +1035 -12
  7. package/src/commands/create.js +523 -157
  8. package/src/commands/drafts.js +288 -0
  9. package/src/commands/enhancements.js +282 -0
  10. package/src/commands/list.js +7 -5
  11. package/src/commands/status.js +81 -19
  12. package/src/commands/templates.js +157 -0
  13. package/src/lib/ai/adapters/anthropic.js +52 -0
  14. package/src/lib/ai/adapters/base.js +45 -0
  15. package/src/lib/ai/adapters/command.js +68 -0
  16. package/src/lib/ai/adapters/gemini.js +56 -0
  17. package/src/lib/ai/adapters/ollama.js +60 -0
  18. package/src/lib/ai/adapters/openai-compat.js +51 -0
  19. package/src/lib/ai/adapters/openai.js +44 -0
  20. package/src/lib/ai/body-template.js +75 -0
  21. package/src/lib/ai/enhance.js +107 -0
  22. package/src/lib/ai/enhancement-adapter.js +109 -0
  23. package/src/lib/ai/index.js +122 -0
  24. package/src/lib/ai/pipeline.js +97 -0
  25. package/src/lib/ai/prompt.js +39 -0
  26. package/src/lib/ai/router.js +216 -0
  27. package/src/lib/ai/steps.js +492 -0
  28. package/src/lib/attribution.js +18 -179
  29. package/src/lib/clipboard.js +147 -0
  30. package/src/lib/color.js +9 -0
  31. package/src/lib/dedup.js +67 -32
  32. package/src/lib/defaults.js +54 -2
  33. package/src/lib/drafts.js +439 -0
  34. package/src/lib/enhancements.js +436 -0
  35. package/src/lib/gh.js +102 -21
  36. package/src/lib/repo-picker.js +2 -0
  37. package/src/lib/safety.js +1 -1
  38. package/src/lib/templates.js +8 -12
  39. package/src/lib/theme.js +9 -0
  40. package/src/commands/use.js +0 -19
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Write-ahead drafts for pending issues.
3
+ *
4
+ * ZERO DATA LOSS GUARANTEE:
5
+ * Every issue is written to disk the moment we have user input — before
6
+ * template rendering, AI enhancement, dedup, or safety checks. The draft
7
+ * file is the source of truth until GitHub confirms the issue exists.
8
+ *
9
+ * Files live in `.tissues/drafts/{slug}.json` where slug is derived from
10
+ * the issue title. If a file with the same slug already exists, new attempt
11
+ * data is appended (append-only — we never overwrite user content).
12
+ *
13
+ * Lifecycle: draft → pending → complete (deleted)
14
+ * → duplicate (kept)
15
+ * → aborted (kept)
16
+ * → failed (kept)
17
+ */
18
+
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import os from 'node:os'
22
+ import crypto from 'node:crypto'
23
+ import { findRepoRoot } from './defaults.js'
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Directory resolution
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Auto-migrate old `.tissues/outbox/` to `.tissues/drafts/` on first access.
31
+ *
32
+ * @param {string} draftsDir
33
+ * @param {string} base
34
+ */
35
+ function migrateOutboxDir(draftsDir, base) {
36
+ const oldDir = path.join(base, '.tissues', 'outbox')
37
+ if (fs.existsSync(oldDir) && !fs.existsSync(draftsDir)) {
38
+ fs.renameSync(oldDir, draftsDir)
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolve the drafts directory. Creates it if it doesn't exist.
44
+ *
45
+ * @param {string|null} [repoRoot] - repo root path; auto-detected if omitted
46
+ * @returns {string} absolute path to the drafts directory
47
+ */
48
+ export function getDraftsDir(repoRoot) {
49
+ const base = repoRoot ?? findRepoRoot()
50
+ const dir = base
51
+ ? path.join(base, '.tissues', 'drafts')
52
+ : path.join(os.homedir(), '.config', 'tissues', 'drafts')
53
+ if (base) migrateOutboxDir(dir, base)
54
+ else {
55
+ const oldGlobal = path.join(os.homedir(), '.config', 'tissues', 'outbox')
56
+ if (fs.existsSync(oldGlobal) && !fs.existsSync(dir)) {
57
+ fs.renameSync(oldGlobal, dir)
58
+ }
59
+ }
60
+ fs.mkdirSync(dir, { recursive: true })
61
+ return dir
62
+ }
63
+
64
+ /**
65
+ * Compute the drafts dir path without creating it (for existence checks).
66
+ *
67
+ * @param {string|null} [repoRoot]
68
+ * @returns {string}
69
+ */
70
+ function resolveDraftsDir(repoRoot) {
71
+ const base = repoRoot ?? findRepoRoot()
72
+ return base
73
+ ? path.join(base, '.tissues', 'drafts')
74
+ : path.join(os.homedir(), '.config', 'tissues', 'drafts')
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Slug helper — title → filesystem-safe identifier
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Convert a title to a filesystem-safe slug.
83
+ *
84
+ * @param {string} title
85
+ * @returns {string} slug (max 60 chars, lowercase, hyphens)
86
+ */
87
+ function slugify(title) {
88
+ const slug = title
89
+ .toLowerCase()
90
+ .replace(/[^a-z0-9]+/g, '-')
91
+ .replace(/^-|-$/g, '')
92
+ .slice(0, 60)
93
+ return slug || crypto.randomUUID().replace(/-/g, '').slice(0, 10)
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Public API
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Write a draft immediately with raw user input.
102
+ * Called as soon as we have title + repo — before any processing.
103
+ *
104
+ * If a file with the same title slug already exists, appends a new attempt
105
+ * entry (append-only — never overwrites user content).
106
+ *
107
+ * @param {{ repo: string, title: string, description?: string, instructions?: string, labels?: string[] }} item
108
+ * @param {string|null} [repoRoot]
109
+ * @returns {{ id: string, filePath: string }}
110
+ */
111
+ export function writeDraft(item, repoRoot) {
112
+ const dir = getDraftsDir(repoRoot)
113
+ const id = slugify(item.title)
114
+ const filePath = path.join(dir, `${id}.json`)
115
+ const now = new Date().toISOString()
116
+
117
+ if (fs.existsSync(filePath)) {
118
+ // Append new attempt to existing file — never lose what's already there
119
+ try {
120
+ const existing = JSON.parse(fs.readFileSync(filePath, 'utf8'))
121
+ const attempt = {
122
+ createdAt: now,
123
+ description: item.description || '',
124
+ instructions: item.instructions || '',
125
+ }
126
+ if (!existing.attempts) existing.attempts = []
127
+ existing.attempts.push(attempt)
128
+ // Reset to draft so it's visible in the drafts list
129
+ existing.status = 'draft'
130
+ existing.updatedAt = now
131
+ fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
132
+ return { id: existing.id, filePath }
133
+ } catch {
134
+ // Corrupt file — overwrite it
135
+ }
136
+ }
137
+
138
+ const record = {
139
+ id,
140
+ repo: item.repo,
141
+ title: item.title,
142
+ description: item.description || '',
143
+ instructions: item.instructions || '',
144
+ body: '',
145
+ labels: item.labels ?? [],
146
+ createdAt: now,
147
+ status: 'draft',
148
+ attemptCount: 0,
149
+ }
150
+
151
+ fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
152
+ return { id, filePath }
153
+ }
154
+
155
+ /**
156
+ * Update a draft entry with the processed body and labels.
157
+ * Called after template rendering + AI enhancement + attribution.
158
+ * Transitions status from 'draft' to 'pending' (ready to submit).
159
+ *
160
+ * @param {string} id - draft item ID (slug)
161
+ * @param {{ body: string, labels?: string[] }} updates
162
+ * @param {string|null} [repoRoot]
163
+ */
164
+ export function updateDraftItem(id, updates, repoRoot) {
165
+ const dir = getDraftsDir(repoRoot)
166
+ const filePath = path.join(dir, `${id}.json`)
167
+ try {
168
+ const raw = fs.readFileSync(filePath, 'utf8')
169
+ const record = JSON.parse(raw)
170
+ if (updates.body) record.body = updates.body
171
+ if (updates.labels) record.labels = updates.labels
172
+ record.status = 'pending'
173
+ record.updatedAt = new Date().toISOString()
174
+ fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
175
+ } catch {
176
+ // File may have been deleted manually — that's ok
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Write a new pending item to drafts (legacy API, still used by batch).
182
+ * For single creates, prefer writeDraft() + updateDraftItem().
183
+ *
184
+ * @param {{ repo: string, title: string, body: string, labels?: string[] }} item
185
+ * @param {string|null} [repoRoot]
186
+ * @returns {{ id: string, repo: string, title: string, body: string, labels: string[], createdAt: string, status: string, attemptCount: number }}
187
+ */
188
+ export function writeToDrafts(item, repoRoot) {
189
+ const dir = getDraftsDir(repoRoot)
190
+ const id = slugify(item.title)
191
+ const filePath = path.join(dir, `${id}.json`)
192
+
193
+ // If slug exists, append
194
+ if (fs.existsSync(filePath)) {
195
+ try {
196
+ const existing = JSON.parse(fs.readFileSync(filePath, 'utf8'))
197
+ existing.body = item.body
198
+ existing.labels = item.labels ?? existing.labels
199
+ existing.status = 'pending'
200
+ existing.updatedAt = new Date().toISOString()
201
+ fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
202
+ return existing
203
+ } catch { /* corrupt — fall through to overwrite */ }
204
+ }
205
+
206
+ const record = {
207
+ id,
208
+ repo: item.repo,
209
+ title: item.title,
210
+ body: item.body,
211
+ labels: item.labels ?? [],
212
+ createdAt: new Date().toISOString(),
213
+ status: 'pending',
214
+ attemptCount: 0,
215
+ }
216
+
217
+ fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
218
+ return record
219
+ }
220
+
221
+ /**
222
+ * Read all draft items, sorted by createdAt ascending.
223
+ * Skips non-JSON files and corrupt entries gracefully.
224
+ *
225
+ * @param {string|null} [repoRoot]
226
+ * @returns {Array<object>}
227
+ */
228
+ export function readDrafts(repoRoot) {
229
+ const dir = getDraftsDir(repoRoot)
230
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'))
231
+ const items = []
232
+
233
+ for (const file of files) {
234
+ try {
235
+ const raw = fs.readFileSync(path.join(dir, file), 'utf8')
236
+ const parsed = JSON.parse(raw)
237
+ // Skip non-object values (null, arrays, primitives)
238
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
239
+ items.push(parsed)
240
+ }
241
+ } catch {
242
+ // Skip corrupt or unreadable files
243
+ }
244
+ }
245
+
246
+ return items.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
247
+ }
248
+
249
+ /**
250
+ * Delete a draft item (issue successfully created and verified).
251
+ * This is the ONLY function that removes a draft file.
252
+ *
253
+ * @param {string} id - draft item ID
254
+ * @param {object} [_meta] - unused (issueNumber, issueUrl) — reserved for future logging
255
+ * @param {string|null} [repoRoot]
256
+ */
257
+ export function markComplete(id, _meta, repoRoot) {
258
+ const dir = getDraftsDir(repoRoot)
259
+ const filePath = path.join(dir, `${id}.json`)
260
+ try {
261
+ fs.unlinkSync(filePath)
262
+ } catch {
263
+ // Already gone — that's fine
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Mark a draft item as a duplicate. File is KEPT — user data is never lost.
269
+ *
270
+ * @param {string} id
271
+ * @param {string} reason - e.g. "Fuzzy title match (100%) with #36"
272
+ * @param {string|null} [repoRoot]
273
+ */
274
+ export function markDuplicate(id, reason, repoRoot) {
275
+ const dir = getDraftsDir(repoRoot)
276
+ const filePath = path.join(dir, `${id}.json`)
277
+ try {
278
+ const raw = fs.readFileSync(filePath, 'utf8')
279
+ const record = JSON.parse(raw)
280
+ record.status = 'duplicate'
281
+ record.duplicateReason = reason
282
+ record.duplicateAt = new Date().toISOString()
283
+ fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
284
+ } catch {
285
+ // File may have been deleted already
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Mark a draft item as aborted by the user. File is KEPT.
291
+ *
292
+ * @param {string} id
293
+ * @param {string|null} [repoRoot]
294
+ */
295
+ export function markAborted(id, repoRoot) {
296
+ const dir = getDraftsDir(repoRoot)
297
+ const filePath = path.join(dir, `${id}.json`)
298
+ try {
299
+ const raw = fs.readFileSync(filePath, 'utf8')
300
+ const record = JSON.parse(raw)
301
+ record.status = 'aborted'
302
+ record.abortedAt = new Date().toISOString()
303
+ fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
304
+ } catch {
305
+ // File may have been deleted already
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Mark a draft item as failed (network error, API rejection, etc.).
311
+ * Keeps the file so the user can retry with `tissues drafts publish`.
312
+ *
313
+ * @param {string} id - draft item ID
314
+ * @param {string} errorMessage
315
+ * @param {string|null} [repoRoot]
316
+ */
317
+ export function markFailed(id, errorMessage, repoRoot) {
318
+ const dir = getDraftsDir(repoRoot)
319
+ const filePath = path.join(dir, `${id}.json`)
320
+ try {
321
+ const raw = fs.readFileSync(filePath, 'utf8')
322
+ const record = JSON.parse(raw)
323
+ record.status = 'failed'
324
+ record.error = errorMessage
325
+ record.failedAt = new Date().toISOString()
326
+ fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
327
+ } catch {
328
+ // File may have been deleted already
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Fast check: are there any pending draft items?
334
+ * Does NOT create the drafts dir if it doesn't exist.
335
+ *
336
+ * @param {string|null} [repoRoot]
337
+ * @returns {boolean}
338
+ */
339
+ export function hasPending(repoRoot) {
340
+ try {
341
+ const dir = resolveDraftsDir(repoRoot)
342
+ if (!fs.existsSync(dir)) return false
343
+ return fs.readdirSync(dir).some((f) => f.endsWith('.json'))
344
+ } catch {
345
+ return false
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Count pending draft items.
351
+ *
352
+ * @param {string|null} [repoRoot]
353
+ * @returns {number}
354
+ */
355
+ export function countPending(repoRoot) {
356
+ try {
357
+ const dir = resolveDraftsDir(repoRoot)
358
+ if (!fs.existsSync(dir)) return 0
359
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).length
360
+ } catch {
361
+ return 0
362
+ }
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Attachments
367
+ // ---------------------------------------------------------------------------
368
+
369
+ /**
370
+ * Resolve the attachment directory for a given draft item.
371
+ *
372
+ * @param {string} id - draft item ID (slug)
373
+ * @param {string|null} [repoRoot]
374
+ * @returns {string} absolute path to the attachment directory
375
+ */
376
+ export function getAttachmentDir(id, repoRoot) {
377
+ const dir = getDraftsDir(repoRoot)
378
+ return path.join(dir, id)
379
+ }
380
+
381
+ /**
382
+ * Copy a file into the draft attachment directory and record metadata
383
+ * in the draft JSON.
384
+ *
385
+ * @param {string} id - draft item ID (slug)
386
+ * @param {string} filePath - absolute or relative path to the file
387
+ * @param {string|null} [repoRoot]
388
+ * @returns {{ filename: string, size: number, type: string }}
389
+ */
390
+ export function attachFile(id, filePath, repoRoot) {
391
+ const resolved = path.resolve(filePath.replace(/^~/, os.homedir()))
392
+ if (!fs.existsSync(resolved)) {
393
+ throw new Error(`File not found: ${filePath}`)
394
+ }
395
+
396
+ const stat = fs.statSync(resolved)
397
+ if (!stat.isFile()) {
398
+ throw new Error(`Not a file: ${filePath}`)
399
+ }
400
+
401
+ const filename = path.basename(resolved)
402
+ const attachDir = getAttachmentDir(id, repoRoot)
403
+ fs.mkdirSync(attachDir, { recursive: true })
404
+
405
+ const destPath = path.join(attachDir, filename)
406
+ fs.copyFileSync(resolved, destPath)
407
+
408
+ const ext = path.extname(filename).toLowerCase()
409
+ const typeMap = {
410
+ '.png': 'image/png',
411
+ '.jpg': 'image/jpeg',
412
+ '.jpeg': 'image/jpeg',
413
+ '.gif': 'image/gif',
414
+ '.webp': 'image/webp',
415
+ '.svg': 'image/svg+xml',
416
+ '.pdf': 'application/pdf',
417
+ '.txt': 'text/plain',
418
+ '.log': 'text/plain',
419
+ }
420
+ const type = typeMap[ext] || 'application/octet-stream'
421
+
422
+ const meta = { filename, size: stat.size, type }
423
+
424
+ // Update draft JSON with attachment metadata
425
+ const draftsDir = getDraftsDir(repoRoot)
426
+ const jsonPath = path.join(draftsDir, `${id}.json`)
427
+ try {
428
+ const raw = fs.readFileSync(jsonPath, 'utf8')
429
+ const record = JSON.parse(raw)
430
+ if (!record.attachments) record.attachments = []
431
+ record.attachments.push(meta)
432
+ record.updatedAt = new Date().toISOString()
433
+ fs.writeFileSync(jsonPath, JSON.stringify(record, null, 2), 'utf8')
434
+ } catch {
435
+ // JSON may be corrupt — attachment is still saved to disk
436
+ }
437
+
438
+ return meta
439
+ }