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
|
@@ -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
|
+
}
|
package/src/lib/gh.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { execFileSync, execSync } from 'node:child_process'
|
|
7
|
-
import
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import nodePath from 'node:path'
|
|
9
|
+
import { red, cyan, dim } from './color.js'
|
|
8
10
|
|
|
9
11
|
// ---------------------------------------------------------------------------
|
|
10
12
|
// gh availability
|
|
@@ -22,14 +24,14 @@ export function requireGh() {
|
|
|
22
24
|
}).trim()
|
|
23
25
|
return path
|
|
24
26
|
} catch {
|
|
25
|
-
console.error(
|
|
27
|
+
console.error(red('\n gh CLI is required but not installed.\n'))
|
|
26
28
|
console.error(' Install it with one of:')
|
|
27
|
-
console.error(
|
|
28
|
-
console.error(
|
|
29
|
-
console.error(
|
|
30
|
-
console.error(
|
|
29
|
+
console.error(cyan(' brew install gh ') + dim('# macOS'))
|
|
30
|
+
console.error(cyan(' sudo apt install gh ') + dim('# Debian/Ubuntu'))
|
|
31
|
+
console.error(cyan(' winget install GitHub.cli ') + dim('# Windows'))
|
|
32
|
+
console.error(cyan(' conda install -c conda-forge gh ') + dim('# conda'))
|
|
31
33
|
console.error()
|
|
32
|
-
console.error(
|
|
34
|
+
console.error(dim(' More: https://cli.github.com'))
|
|
33
35
|
console.error()
|
|
34
36
|
process.exit(1)
|
|
35
37
|
}
|
|
@@ -62,9 +64,9 @@ export function requireAuth() {
|
|
|
62
64
|
const token = getToken()
|
|
63
65
|
if (token) return token
|
|
64
66
|
|
|
65
|
-
console.error(
|
|
67
|
+
console.error(red('\n Not authenticated with GitHub.\n'))
|
|
66
68
|
console.error(' Run:')
|
|
67
|
-
console.error(
|
|
69
|
+
console.error(cyan(' gh auth login'))
|
|
68
70
|
console.error()
|
|
69
71
|
process.exit(1)
|
|
70
72
|
}
|
|
@@ -75,11 +77,26 @@ export function requireAuth() {
|
|
|
75
77
|
*/
|
|
76
78
|
export function getAuthStatus() {
|
|
77
79
|
try {
|
|
78
|
-
const raw = execFileSync('gh', ['auth', 'status', '--json'], {
|
|
80
|
+
const raw = execFileSync('gh', ['auth', 'status', '--json', 'hosts'], {
|
|
79
81
|
encoding: 'utf8',
|
|
80
82
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
81
83
|
}).trim()
|
|
82
|
-
|
|
84
|
+
const data = JSON.parse(raw)
|
|
85
|
+
// Normalize: flatten hosts map into an accounts array for callers
|
|
86
|
+
const accounts = []
|
|
87
|
+
if (data.hosts) {
|
|
88
|
+
for (const entries of Object.values(data.hosts)) {
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
accounts.push({
|
|
91
|
+
login: entry.login,
|
|
92
|
+
active: !!entry.active,
|
|
93
|
+
host: entry.host,
|
|
94
|
+
state: entry.state,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { accounts }
|
|
83
100
|
} catch {
|
|
84
101
|
// Older gh versions may not support --json, fall back to text parsing
|
|
85
102
|
try {
|
|
@@ -217,6 +234,32 @@ export function createIssue(repo, { title, body, labels }) {
|
|
|
217
234
|
return { number, url }
|
|
218
235
|
}
|
|
219
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Verify that a GitHub issue exists and is open.
|
|
239
|
+
* Used after `createIssue()` to confirm the issue was actually created
|
|
240
|
+
* before removing it from the outbox.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} repo - owner/name
|
|
243
|
+
* @param {number} number - issue number
|
|
244
|
+
* @returns {boolean} true if the issue exists and is open
|
|
245
|
+
*/
|
|
246
|
+
export function verifyIssue(repo, number) {
|
|
247
|
+
try {
|
|
248
|
+
const raw = execFileSync('gh', [
|
|
249
|
+
'issue', 'view', String(number),
|
|
250
|
+
'--repo', repo,
|
|
251
|
+
'--json', 'number,state',
|
|
252
|
+
], {
|
|
253
|
+
encoding: 'utf8',
|
|
254
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
255
|
+
}).trim()
|
|
256
|
+
const data = JSON.parse(raw)
|
|
257
|
+
return data.state === 'OPEN'
|
|
258
|
+
} catch {
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
220
263
|
/**
|
|
221
264
|
* List all label names for a repo.
|
|
222
265
|
* @param {string} repo - owner/name
|
|
@@ -300,6 +343,38 @@ export function listIssues(repo, opts = {}) {
|
|
|
300
343
|
}))
|
|
301
344
|
}
|
|
302
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Upload an image file to a repo's .tissues/images/ directory and return
|
|
348
|
+
* the raw URL for embedding in markdown.
|
|
349
|
+
*
|
|
350
|
+
* Uses the GitHub Contents API via `gh api` to commit the file.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} repo - owner/name
|
|
353
|
+
* @param {string} localPath - path to the local image file
|
|
354
|
+
* @param {string} [filename] - optional filename override
|
|
355
|
+
* @returns {{ url: string, path: string }} raw URL and repo path
|
|
356
|
+
*/
|
|
357
|
+
export function uploadImageToRepo(repo, localPath, filename) {
|
|
358
|
+
const name = filename || nodePath.basename(localPath)
|
|
359
|
+
const repoPath = `.tissues/images/${name}`
|
|
360
|
+
const content = fs.readFileSync(localPath).toString('base64')
|
|
361
|
+
|
|
362
|
+
const raw = execFileSync('gh', [
|
|
363
|
+
'api',
|
|
364
|
+
`repos/${repo}/contents/${repoPath}`,
|
|
365
|
+
'--method', 'PUT',
|
|
366
|
+
'-f', `message=tissues: attach ${name}`,
|
|
367
|
+
'-f', `content=${content}`,
|
|
368
|
+
], {
|
|
369
|
+
encoding: 'utf8',
|
|
370
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
371
|
+
}).trim()
|
|
372
|
+
|
|
373
|
+
const data = JSON.parse(raw)
|
|
374
|
+
const url = data.content?.download_url || `https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}`
|
|
375
|
+
return { url, path: repoPath }
|
|
376
|
+
}
|
|
377
|
+
|
|
303
378
|
/**
|
|
304
379
|
* List repos the user has access to.
|
|
305
380
|
* @param {{ limit?: number }} [opts]
|
package/src/lib/repo-picker.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { search } from '@inquirer/prompts'
|
|
2
2
|
import { store, setConfig } from './config.js'
|
|
3
|
+
import { theme } from './theme.js'
|
|
3
4
|
import { listRepos } from './gh.js'
|
|
4
5
|
import ora from 'ora'
|
|
5
6
|
|
|
@@ -46,6 +47,7 @@ export async function pickRepo() {
|
|
|
46
47
|
.filter((r) => r.toLowerCase().includes(term))
|
|
47
48
|
.map((r) => ({ name: r, value: r }))
|
|
48
49
|
},
|
|
50
|
+
theme,
|
|
49
51
|
})
|
|
50
52
|
|
|
51
53
|
trackRepoUsage(repo)
|
package/src/lib/safety.js
CHANGED
|
@@ -110,7 +110,7 @@ export function checkSafety(repo, agent, config = {}) {
|
|
|
110
110
|
|
|
111
111
|
// Step 3: half-open → log and allow the probe through (no rate checks)
|
|
112
112
|
if (circuitState === 'half-open') {
|
|
113
|
-
|
|
113
|
+
// half-open: silently allow the probe through — no user-facing noise
|
|
114
114
|
return {
|
|
115
115
|
allowed: true,
|
|
116
116
|
circuitState,
|
package/src/lib/templates.js
CHANGED
|
@@ -20,18 +20,14 @@ const BUILT_IN_TEMPLATES = {
|
|
|
20
20
|
name: 'Feature Request',
|
|
21
21
|
body: `## Feature\n\n{{description}}\n\n## Motivation\n\n## Proposed solution\n\n## Alternatives considered\n\n`,
|
|
22
22
|
},
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
refactor: {
|
|
32
|
-
name: 'Refactor',
|
|
33
|
-
body: `## Refactor\n\n{{description}}\n\n## Motivation\n\n## Scope\n\n## Risk assessment\n\n`,
|
|
34
|
-
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Return the list of built-in template keys.
|
|
27
|
+
* @returns {string[]}
|
|
28
|
+
*/
|
|
29
|
+
export function builtInTemplateKeys() {
|
|
30
|
+
return Object.keys(BUILT_IN_TEMPLATES)
|
|
35
31
|
}
|
|
36
32
|
|
|
37
33
|
// ---------------------------------------------------------------------------
|
package/src/lib/theme.js
ADDED
package/src/commands/use.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander'
|
|
2
|
-
import { pickRepo } from '../lib/repo-picker.js'
|
|
3
|
-
import { setConfig } from '../lib/config.js'
|
|
4
|
-
import chalk from 'chalk'
|
|
5
|
-
|
|
6
|
-
export const useCommand = new Command('use')
|
|
7
|
-
.description('Set the active repository context')
|
|
8
|
-
.argument('[repo]', 'Repository in owner/name format (e.g. owner/repo)')
|
|
9
|
-
.option('--repo <repo>', 'Repository in owner/name format (alias for positional argument)')
|
|
10
|
-
.action(async (repoArg, opts) => {
|
|
11
|
-
const repo = repoArg || opts.repo
|
|
12
|
-
if (repo) {
|
|
13
|
-
setConfig({ activeRepo: repo })
|
|
14
|
-
console.log(chalk.green(`✓ Active repo set to ${repo}`))
|
|
15
|
-
} else {
|
|
16
|
-
const selected = await pickRepo()
|
|
17
|
-
console.log(chalk.green(`\n✓ Active repo set to ${selected}`))
|
|
18
|
-
}
|
|
19
|
-
})
|