tissues 0.6.0 → 0.6.2
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 +78 -1
- package/package.json +4 -2
- package/src/cli.js +12 -1
- package/src/commands/ai.js +149 -173
- package/src/commands/config.js +143 -69
- package/src/commands/create.js +16 -9
- package/src/commands/create.test.js +381 -0
- package/src/commands/enhancements.js +282 -0
- package/src/commands/flush.test.js +299 -0
- package/src/commands/list.js +3 -2
- package/src/commands/providers.js +347 -0
- package/src/commands/providers.test.js +28 -0
- package/src/commands/storage.js +167 -0
- package/src/commands/sync.js +225 -0
- package/src/daemon/sync.js +189 -0
- package/src/lib/ai/adapters/claude-cli.js +55 -0
- package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
- package/src/lib/ai/adapters/codex-cli.js +77 -0
- package/src/lib/ai/adapters/command.js +23 -13
- package/src/lib/ai/adapters/gemini-cli.js +55 -0
- package/src/lib/ai/adapters/openclaw.js +91 -0
- package/src/lib/ai/agent-actions.js +271 -0
- package/src/lib/ai/agent.js +323 -0
- package/src/lib/ai/body-template.js +15 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhance.js +48 -11
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/index.js +2 -2
- package/src/lib/ai/pipeline.js +20 -2
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +118 -7
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.js +23 -3
- package/src/lib/ai/steps.test.js +335 -0
- package/src/lib/attribution.test.js +64 -0
- package/src/lib/cache.js +408 -0
- package/src/lib/db.js +42 -0
- package/src/lib/dedup.js +44 -48
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +37 -1
- package/src/lib/defaults.test.js +217 -0
- package/src/lib/drafts-perf.test.js +203 -0
- package/src/lib/drafts.test.js +300 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +76 -10
- package/src/lib/safety.test.js +217 -0
- package/src/lib/storage.js +298 -0
- package/src/lib/templates.test.js +207 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-gathering functions for the storage overview.
|
|
3
|
+
*
|
|
4
|
+
* Provides row counts, file counts, sizes, and freshness info
|
|
5
|
+
* for all persistent data tissues stores.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { getDb, getDbPath } from './db.js'
|
|
11
|
+
import { getSyncMeta } from './cache.js'
|
|
12
|
+
import { findRepoRoot } from './defaults.js'
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a table exists in the database.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} tableName
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
function tableExists(tableName) {
|
|
25
|
+
try {
|
|
26
|
+
const db = getDb()
|
|
27
|
+
const row = db.prepare(
|
|
28
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
29
|
+
).get(tableName)
|
|
30
|
+
return !!row
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Count rows in a table, optionally filtered.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} table
|
|
40
|
+
* @param {string} [where]
|
|
41
|
+
* @param {any[]} [params]
|
|
42
|
+
* @returns {number}
|
|
43
|
+
*/
|
|
44
|
+
function countRows(table, where, params = []) {
|
|
45
|
+
if (!tableExists(table)) return 0
|
|
46
|
+
try {
|
|
47
|
+
const db = getDb()
|
|
48
|
+
const sql = where
|
|
49
|
+
? `SELECT COUNT(*) as cnt FROM ${table} WHERE ${where}`
|
|
50
|
+
: `SELECT COUNT(*) as cnt FROM ${table}`
|
|
51
|
+
return db.prepare(sql).get(...params)?.cnt || 0
|
|
52
|
+
} catch {
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the size of a file in bytes, or 0 if it doesn't exist.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} filePath
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
function fileSize(filePath) {
|
|
64
|
+
try {
|
|
65
|
+
return fs.statSync(filePath).size
|
|
66
|
+
} catch {
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Count files in a directory (non-recursive).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} dir
|
|
75
|
+
* @param {string} [ext] - filter by extension
|
|
76
|
+
* @returns {{ count: number, totalSize: number }}
|
|
77
|
+
*/
|
|
78
|
+
function countFiles(dir, ext) {
|
|
79
|
+
try {
|
|
80
|
+
if (!fs.existsSync(dir)) return { count: 0, totalSize: 0 }
|
|
81
|
+
const entries = fs.readdirSync(dir)
|
|
82
|
+
const files = ext ? entries.filter(f => f.endsWith(ext)) : entries
|
|
83
|
+
let totalSize = 0
|
|
84
|
+
for (const f of files) {
|
|
85
|
+
totalSize += fileSize(path.join(dir, f))
|
|
86
|
+
}
|
|
87
|
+
return { count: files.length, totalSize }
|
|
88
|
+
} catch {
|
|
89
|
+
return { count: 0, totalSize: 0 }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Format a relative time string (e.g. "2 min ago", "3 hours ago").
|
|
95
|
+
*
|
|
96
|
+
* @param {string|null} isoDate
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function timeAgo(isoDate) {
|
|
100
|
+
if (!isoDate) return 'never'
|
|
101
|
+
const ms = Date.now() - new Date(isoDate + (isoDate.endsWith('Z') ? '' : 'Z')).getTime()
|
|
102
|
+
if (ms < 0) return 'just now'
|
|
103
|
+
const sec = Math.floor(ms / 1000)
|
|
104
|
+
if (sec < 60) return `${sec}s ago`
|
|
105
|
+
const min = Math.floor(sec / 60)
|
|
106
|
+
if (min < 60) return `${min} min ago`
|
|
107
|
+
const hr = Math.floor(min / 60)
|
|
108
|
+
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`
|
|
109
|
+
const days = Math.floor(hr / 24)
|
|
110
|
+
return `${days} day${days === 1 ? '' : 's'} ago`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Format bytes as a human-readable string.
|
|
115
|
+
*
|
|
116
|
+
* @param {number} bytes
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function formatBytes(bytes) {
|
|
120
|
+
if (bytes === 0) return '0 B'
|
|
121
|
+
if (bytes < 1024) return `${bytes} B`
|
|
122
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
|
123
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Overview data gathering
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Gather a full storage overview.
|
|
132
|
+
*
|
|
133
|
+
* @param {{ repo?: string }} [opts]
|
|
134
|
+
* @returns {object}
|
|
135
|
+
*/
|
|
136
|
+
export function getStorageOverview(opts = {}) {
|
|
137
|
+
const dbPath = getDbPath()
|
|
138
|
+
const dbSize = fileSize(dbPath)
|
|
139
|
+
const repoRoot = findRepoRoot()
|
|
140
|
+
|
|
141
|
+
// Table row counts
|
|
142
|
+
const fingerprints = countRows('fingerprints')
|
|
143
|
+
const idempotencyKeys = countRows('idempotency_keys')
|
|
144
|
+
const circuitBreakers = countRows('circuit_breaker')
|
|
145
|
+
const rateEvents = countRows('rate_events')
|
|
146
|
+
const cachedIssues = countRows('cached_issues')
|
|
147
|
+
const cachedLabels = countRows('cached_labels')
|
|
148
|
+
const cachedRepos = countRows('cached_repos')
|
|
149
|
+
const syncMeta = countRows('sync_meta')
|
|
150
|
+
|
|
151
|
+
// Sync freshness
|
|
152
|
+
const repo = opts.repo || null
|
|
153
|
+
const issuesMeta = repo ? getSyncMeta(repo, 'issues') : null
|
|
154
|
+
const labelsMeta = repo ? getSyncMeta(repo, 'labels') : null
|
|
155
|
+
const reposMeta = getSyncMeta('_global', 'repos')
|
|
156
|
+
|
|
157
|
+
// File counts
|
|
158
|
+
const tissuesDir = repoRoot ? path.join(repoRoot, '.tissues') : null
|
|
159
|
+
const draftsDir = tissuesDir ? path.join(tissuesDir, 'drafts') : null
|
|
160
|
+
const imagesDir = tissuesDir ? path.join(tissuesDir, 'images') : null
|
|
161
|
+
const templatesDir = tissuesDir ? path.join(tissuesDir, 'templates') : null
|
|
162
|
+
const enhancementsDir = tissuesDir ? path.join(tissuesDir, 'enhancements') : null
|
|
163
|
+
|
|
164
|
+
const drafts = draftsDir ? countFiles(draftsDir, '.json') : { count: 0, totalSize: 0 }
|
|
165
|
+
const images = imagesDir ? countFiles(imagesDir) : { count: 0, totalSize: 0 }
|
|
166
|
+
const templates = templatesDir ? countFiles(templatesDir, '.md') : { count: 0, totalSize: 0 }
|
|
167
|
+
const enhancements = enhancementsDir ? countFiles(enhancementsDir, '.md') : { count: 0, totalSize: 0 }
|
|
168
|
+
|
|
169
|
+
const totalDiskUsage = dbSize + drafts.totalSize + images.totalSize + templates.totalSize + enhancements.totalSize
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
database: {
|
|
173
|
+
path: dbPath,
|
|
174
|
+
size: dbSize,
|
|
175
|
+
tables: {
|
|
176
|
+
fingerprints: { rows: fingerprints, lastSync: null },
|
|
177
|
+
idempotency_keys: { rows: idempotencyKeys },
|
|
178
|
+
circuit_breaker: { rows: circuitBreakers },
|
|
179
|
+
rate_events: { rows: rateEvents },
|
|
180
|
+
cached_issues: { rows: cachedIssues, lastSync: issuesMeta?.lastSyncedAt || null },
|
|
181
|
+
cached_labels: { rows: cachedLabels, lastSync: labelsMeta?.lastSyncedAt || null },
|
|
182
|
+
cached_repos: { rows: cachedRepos, lastSync: reposMeta?.lastSyncedAt || null },
|
|
183
|
+
sync_meta: { rows: syncMeta },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
files: {
|
|
187
|
+
drafts,
|
|
188
|
+
images,
|
|
189
|
+
templates,
|
|
190
|
+
enhancements,
|
|
191
|
+
},
|
|
192
|
+
totalDiskUsage,
|
|
193
|
+
repoRoot,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get per-repo issue cache breakdown.
|
|
199
|
+
*
|
|
200
|
+
* @returns {Array<{ repo: string, open: number, closed: number, lastSync: string|null }>}
|
|
201
|
+
*/
|
|
202
|
+
export function getIssueBreakdown() {
|
|
203
|
+
if (!tableExists('cached_issues')) return []
|
|
204
|
+
try {
|
|
205
|
+
const db = getDb()
|
|
206
|
+
const repos = db.prepare(
|
|
207
|
+
"SELECT DISTINCT repo FROM cached_issues ORDER BY repo",
|
|
208
|
+
).all()
|
|
209
|
+
|
|
210
|
+
return repos.map(r => {
|
|
211
|
+
const open = db.prepare(
|
|
212
|
+
"SELECT COUNT(*) as cnt FROM cached_issues WHERE repo = ? AND state = 'open'",
|
|
213
|
+
).get(r.repo)?.cnt || 0
|
|
214
|
+
const closed = db.prepare(
|
|
215
|
+
"SELECT COUNT(*) as cnt FROM cached_issues WHERE repo = ? AND state = 'closed'",
|
|
216
|
+
).get(r.repo)?.cnt || 0
|
|
217
|
+
const meta = getSyncMeta(r.repo, 'issues')
|
|
218
|
+
return {
|
|
219
|
+
repo: r.repo,
|
|
220
|
+
open,
|
|
221
|
+
closed,
|
|
222
|
+
lastSync: meta?.lastSyncedAt || null,
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
} catch {
|
|
226
|
+
return []
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Clear cached data by target.
|
|
232
|
+
*
|
|
233
|
+
* @param {'issues' | 'labels' | 'repos' | 'fingerprints' | 'all'} target
|
|
234
|
+
* @param {{ repo?: string, includeSafety?: boolean }} [opts]
|
|
235
|
+
* @returns {{ cleared: string[] }}
|
|
236
|
+
*/
|
|
237
|
+
export function clearCachedData(target, opts = {}) {
|
|
238
|
+
const db = getDb()
|
|
239
|
+
const cleared = []
|
|
240
|
+
const repo = opts.repo
|
|
241
|
+
|
|
242
|
+
const clearTable = (table, repoCol) => {
|
|
243
|
+
if (!tableExists(table)) return
|
|
244
|
+
if (repo && repoCol) {
|
|
245
|
+
db.prepare(`DELETE FROM ${table} WHERE ${repoCol} = ?`).run(repo)
|
|
246
|
+
} else {
|
|
247
|
+
db.prepare(`DELETE FROM ${table}`).run()
|
|
248
|
+
}
|
|
249
|
+
cleared.push(table)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (target === 'issues' || target === 'all') {
|
|
253
|
+
clearTable('cached_issues', 'repo')
|
|
254
|
+
if (repo) {
|
|
255
|
+
db.prepare("DELETE FROM sync_meta WHERE repo = ? AND resource = 'issues'").run(repo)
|
|
256
|
+
} else {
|
|
257
|
+
db.prepare("DELETE FROM sync_meta WHERE resource = 'issues'").run()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (target === 'labels' || target === 'all') {
|
|
262
|
+
clearTable('cached_labels', 'repo')
|
|
263
|
+
if (repo) {
|
|
264
|
+
db.prepare("DELETE FROM sync_meta WHERE repo = ? AND resource = 'labels'").run(repo)
|
|
265
|
+
} else {
|
|
266
|
+
db.prepare("DELETE FROM sync_meta WHERE resource = 'labels'").run()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (target === 'repos' || target === 'all') {
|
|
271
|
+
clearTable('cached_repos', null)
|
|
272
|
+
db.prepare("DELETE FROM sync_meta WHERE resource = 'repos'").run()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (target === 'fingerprints' || target === 'all') {
|
|
276
|
+
clearTable('fingerprints', 'repo')
|
|
277
|
+
clearTable('idempotency_keys', 'repo')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (target === 'all' && opts.includeSafety) {
|
|
281
|
+
clearTable('circuit_breaker', 'repo')
|
|
282
|
+
clearTable('rate_events', 'repo')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { cleared }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Export all storage metadata as JSON (for debugging).
|
|
290
|
+
*
|
|
291
|
+
* @param {{ repo?: string }} [opts]
|
|
292
|
+
* @returns {object}
|
|
293
|
+
*/
|
|
294
|
+
export function exportStorageJson(opts = {}) {
|
|
295
|
+
const overview = getStorageOverview(opts)
|
|
296
|
+
const issueBreakdown = getIssueBreakdown()
|
|
297
|
+
return { ...overview, issueBreakdown }
|
|
298
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import { listTemplates, loadTemplate, renderTemplate } from './templates.js'
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// renderTemplate — pure function, no file I/O
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
describe('renderTemplate', () => {
|
|
13
|
+
test('replaces a single variable', () => {
|
|
14
|
+
const result = renderTemplate('Hello {{name}}!', { name: 'World' })
|
|
15
|
+
assert.equal(result, 'Hello World!')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('replaces multiple different variables', () => {
|
|
19
|
+
const result = renderTemplate('{{greeting}} {{name}}', { greeting: 'Hi', name: 'Alice' })
|
|
20
|
+
assert.equal(result, 'Hi Alice')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('leaves unknown variables intact', () => {
|
|
24
|
+
const result = renderTemplate('Hello {{unknown}}!', {})
|
|
25
|
+
assert.equal(result, 'Hello {{unknown}}!')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('leaves null/undefined variable values intact', () => {
|
|
29
|
+
const result = renderTemplate('{{a}} {{b}}', { a: null, b: undefined })
|
|
30
|
+
assert.equal(result, '{{a}} {{b}}')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('replaces the same variable multiple times', () => {
|
|
34
|
+
const result = renderTemplate('{{x}} and {{x}}', { x: 'foo' })
|
|
35
|
+
assert.equal(result, 'foo and foo')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('handles numeric variable values', () => {
|
|
39
|
+
const result = renderTemplate('Count: {{n}}', { n: 42 })
|
|
40
|
+
assert.equal(result, 'Count: 42')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('no substitution when template has no placeholders', () => {
|
|
44
|
+
const result = renderTemplate('Plain text body.', { anything: 'value' })
|
|
45
|
+
assert.equal(result, 'Plain text body.')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('handles empty template string', () => {
|
|
49
|
+
const result = renderTemplate('', { key: 'val' })
|
|
50
|
+
assert.equal(result, '')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('renders description placeholder used in built-in templates', () => {
|
|
54
|
+
const body = '## Summary\n\n{{description}}\n\n## Details'
|
|
55
|
+
const result = renderTemplate(body, { description: 'A short description.' })
|
|
56
|
+
assert.equal(result, '## Summary\n\nA short description.\n\n## Details')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('multiple standard template variables', () => {
|
|
60
|
+
const body = '{{title}} by {{agent}} on {{date}} in {{repo}}'
|
|
61
|
+
const result = renderTemplate(body, {
|
|
62
|
+
title: 'Fix bug',
|
|
63
|
+
agent: 'bot',
|
|
64
|
+
date: '2025-01-01',
|
|
65
|
+
repo: 'owner/repo',
|
|
66
|
+
})
|
|
67
|
+
assert.equal(result, 'Fix bug by bot on 2025-01-01 in owner/repo')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// loadTemplate — built-in templates (no file system required for these)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('loadTemplate – built-in templates', () => {
|
|
76
|
+
// Use a temp dir with .git so findRepoRoot() resolves there and no
|
|
77
|
+
// repo-level template files interfere.
|
|
78
|
+
let tmpDir
|
|
79
|
+
|
|
80
|
+
test('loads the default template', () => {
|
|
81
|
+
const tpl = loadTemplate('default', os.tmpdir())
|
|
82
|
+
assert.equal(tpl.key, 'default')
|
|
83
|
+
assert.ok(tpl.body.includes('{{description}}'))
|
|
84
|
+
// source may be 'built-in' or 'user' if the user has a custom default template
|
|
85
|
+
assert.ok(['built-in', 'user'].includes(tpl.source))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('loads the bug template', () => {
|
|
89
|
+
const tpl = loadTemplate('bug', os.tmpdir())
|
|
90
|
+
assert.equal(tpl.key, 'bug')
|
|
91
|
+
assert.ok(tpl.name.length > 0)
|
|
92
|
+
assert.ok(tpl.body.includes('{{description}}'))
|
|
93
|
+
assert.equal(tpl.source, 'built-in')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('loads the feature template', () => {
|
|
97
|
+
const tpl = loadTemplate('feature', os.tmpdir())
|
|
98
|
+
assert.equal(tpl.key, 'feature')
|
|
99
|
+
assert.equal(tpl.source, 'built-in')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('throws for unknown template name', () => {
|
|
103
|
+
assert.throws(
|
|
104
|
+
() => loadTemplate('does-not-exist', os.tmpdir()),
|
|
105
|
+
/Template "does-not-exist" not found/
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('returned object has key, name, body, source properties', () => {
|
|
110
|
+
const tpl = loadTemplate('bug', os.tmpdir())
|
|
111
|
+
assert.ok('key' in tpl)
|
|
112
|
+
assert.ok('name' in tpl)
|
|
113
|
+
assert.ok('body' in tpl)
|
|
114
|
+
assert.ok('source' in tpl)
|
|
115
|
+
assert.equal(typeof tpl.body, 'string')
|
|
116
|
+
assert.ok(tpl.body.length > 0)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// loadTemplate – repo template overrides built-in
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('loadTemplate – repo templates override built-in', () => {
|
|
125
|
+
let tmpDir
|
|
126
|
+
|
|
127
|
+
test('repo template shadows built-in when file exists', () => {
|
|
128
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-tpl-test-'))
|
|
129
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
130
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues', 'templates'), { recursive: true })
|
|
131
|
+
fs.writeFileSync(
|
|
132
|
+
path.join(tmpDir, '.tissues', 'templates', 'bug.md'),
|
|
133
|
+
'## Custom Bug Template\n\n{{description}}\n'
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const tpl = loadTemplate('bug', tmpDir)
|
|
137
|
+
assert.equal(tpl.source, 'repo')
|
|
138
|
+
assert.ok(tpl.body.includes('Custom Bug Template'))
|
|
139
|
+
|
|
140
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('repo template name extracted from file front-matter if present', () => {
|
|
144
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-tpl-name-test-'))
|
|
145
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
146
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues', 'templates'), { recursive: true })
|
|
147
|
+
fs.writeFileSync(
|
|
148
|
+
path.join(tmpDir, '.tissues', 'templates', 'custom.md'),
|
|
149
|
+
'name: My Custom Template\n\n## Body\n\n{{description}}\n'
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const tpl = loadTemplate('custom', tmpDir)
|
|
153
|
+
assert.equal(tpl.source, 'repo')
|
|
154
|
+
assert.equal(tpl.name, 'My Custom Template')
|
|
155
|
+
|
|
156
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// listTemplates
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe('listTemplates', () => {
|
|
165
|
+
test('returns an array', () => {
|
|
166
|
+
const list = listTemplates(os.tmpdir())
|
|
167
|
+
assert.ok(Array.isArray(list))
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('includes all built-in template keys', () => {
|
|
171
|
+
const list = listTemplates(os.tmpdir())
|
|
172
|
+
const builtInKeys = list.filter((t) => t.source === 'built-in').map((t) => t.key)
|
|
173
|
+
for (const key of ['default', 'bug', 'feature']) {
|
|
174
|
+
assert.ok(builtInKeys.includes(key), `missing built-in template: ${key}`)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('each item has key, name, source properties', () => {
|
|
179
|
+
const list = listTemplates(os.tmpdir())
|
|
180
|
+
for (const item of list) {
|
|
181
|
+
assert.ok('key' in item, 'missing key')
|
|
182
|
+
assert.ok('name' in item, 'missing name')
|
|
183
|
+
assert.ok('source' in item, 'missing source')
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('repo templates appear before built-ins in the list', () => {
|
|
188
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-list-test-'))
|
|
189
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
190
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues', 'templates'), { recursive: true })
|
|
191
|
+
fs.writeFileSync(
|
|
192
|
+
path.join(tmpDir, '.tissues', 'templates', 'bug.md'),
|
|
193
|
+
'## Repo Bug\n\n{{description}}\n'
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const list = listTemplates(tmpDir)
|
|
198
|
+
const repoIdx = list.findIndex((t) => t.source === 'repo' && t.key === 'bug')
|
|
199
|
+
const builtInIdx = list.findIndex((t) => t.source === 'built-in' && t.key === 'bug')
|
|
200
|
+
assert.ok(repoIdx >= 0, 'repo template not found in list')
|
|
201
|
+
assert.ok(builtInIdx >= 0, 'built-in template not found in list')
|
|
202
|
+
assert.ok(repoIdx < builtInIdx, 'repo template should appear before built-in in list')
|
|
203
|
+
} finally {
|
|
204
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
})
|