tissues 0.6.1 → 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 +10 -1
- package/src/commands/ai.js +147 -173
- package/src/commands/create.js +6 -6
- package/src/commands/create.test.js +381 -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/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/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +23 -0
- package/src/lib/ai/router.test.js +481 -0
- 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 +8 -18
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +20 -0
- 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.test.js +294 -0
- package/src/lib/gh.js +60 -0
- 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,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for drafts.js
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories — no mocks. Each describe block gets its own
|
|
5
|
+
* isolated tmpDir so tests remain independent and clean up after themselves.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { test, describe, before, after, beforeEach } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
getDraftsDir,
|
|
16
|
+
writeToDrafts,
|
|
17
|
+
readDrafts,
|
|
18
|
+
markComplete,
|
|
19
|
+
markFailed,
|
|
20
|
+
hasPending,
|
|
21
|
+
countPending,
|
|
22
|
+
} from './drafts.js'
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helper: create a temp dir that looks like a repo root
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function makeTempRepo() {
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-drafts-test-'))
|
|
30
|
+
fs.mkdirSync(path.join(dir, '.git'))
|
|
31
|
+
return dir
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// writeToDrafts
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
describe('writeToDrafts()', () => {
|
|
39
|
+
let tmpDir
|
|
40
|
+
|
|
41
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
42
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
43
|
+
|
|
44
|
+
test('creates a JSON file with correct structure', () => {
|
|
45
|
+
const item = writeToDrafts(
|
|
46
|
+
{ repo: 'owner/repo', title: 'My issue', body: 'Some body', labels: ['bug'] },
|
|
47
|
+
tmpDir,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
assert.equal(item.repo, 'owner/repo')
|
|
51
|
+
assert.equal(item.title, 'My issue')
|
|
52
|
+
assert.equal(item.body, 'Some body')
|
|
53
|
+
assert.deepEqual(item.labels, ['bug'])
|
|
54
|
+
assert.equal(item.status, 'pending')
|
|
55
|
+
assert.equal(item.attemptCount, 0)
|
|
56
|
+
assert.ok(item.id, 'id should be set')
|
|
57
|
+
assert.ok(item.createdAt, 'createdAt should be set')
|
|
58
|
+
|
|
59
|
+
const dir = getDraftsDir(tmpDir)
|
|
60
|
+
const filePath = path.join(dir, `${item.id}.json`)
|
|
61
|
+
assert.ok(fs.existsSync(filePath), 'file should exist on disk')
|
|
62
|
+
|
|
63
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
64
|
+
assert.deepEqual(parsed, item)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('uses empty array for labels when omitted', () => {
|
|
68
|
+
const item = writeToDrafts({ repo: 'owner/repo', title: 'No labels', body: 'body' }, tmpDir)
|
|
69
|
+
assert.deepEqual(item.labels, [])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('two concurrent writeToDrafts() calls produce unique IDs', () => {
|
|
73
|
+
const a = writeToDrafts({ repo: 'a/b', title: 'A', body: '' }, tmpDir)
|
|
74
|
+
const b = writeToDrafts({ repo: 'a/b', title: 'B', body: '' }, tmpDir)
|
|
75
|
+
assert.notEqual(a.id, b.id)
|
|
76
|
+
|
|
77
|
+
const dir = getDraftsDir(tmpDir)
|
|
78
|
+
assert.ok(fs.existsSync(path.join(dir, `${a.id}.json`)))
|
|
79
|
+
assert.ok(fs.existsSync(path.join(dir, `${b.id}.json`)))
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// readDrafts
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('readDrafts()', () => {
|
|
88
|
+
let tmpDir
|
|
89
|
+
|
|
90
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
91
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
92
|
+
|
|
93
|
+
test('returns empty array when drafts is empty', () => {
|
|
94
|
+
const items = readDrafts(tmpDir)
|
|
95
|
+
assert.deepEqual(items, [])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('returns items sorted by createdAt ascending', async () => {
|
|
99
|
+
// Write three items with intentional ordering
|
|
100
|
+
const a = writeToDrafts({ repo: 'r/r', title: 'First', body: '' }, tmpDir)
|
|
101
|
+
await new Promise((r) => setTimeout(r, 5)) // tiny gap so timestamps differ
|
|
102
|
+
const b = writeToDrafts({ repo: 'r/r', title: 'Second', body: '' }, tmpDir)
|
|
103
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
104
|
+
const c = writeToDrafts({ repo: 'r/r', title: 'Third', body: '' }, tmpDir)
|
|
105
|
+
|
|
106
|
+
const items = readDrafts(tmpDir)
|
|
107
|
+
const titles = items.map((i) => i.title)
|
|
108
|
+
assert.ok(titles.indexOf('First') < titles.indexOf('Second'), 'First should be before Second')
|
|
109
|
+
assert.ok(titles.indexOf('Second') < titles.indexOf('Third'), 'Second should be before Third')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('ignores non-JSON files gracefully', () => {
|
|
113
|
+
const dir = getDraftsDir(tmpDir)
|
|
114
|
+
fs.writeFileSync(path.join(dir, '.DS_Store'), 'junk')
|
|
115
|
+
fs.writeFileSync(path.join(dir, 'README.txt'), 'not json')
|
|
116
|
+
|
|
117
|
+
// Should not throw and should not include the non-JSON files
|
|
118
|
+
const items = readDrafts(tmpDir)
|
|
119
|
+
const nonDraft = items.filter((i) => !i.id)
|
|
120
|
+
assert.equal(nonDraft.length, 0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('skips corrupt JSON files', () => {
|
|
124
|
+
const dir = getDraftsDir(tmpDir)
|
|
125
|
+
fs.writeFileSync(path.join(dir, 'corrupt.json'), '{ invalid json !!!')
|
|
126
|
+
|
|
127
|
+
// Should not throw
|
|
128
|
+
const items = readDrafts(tmpDir)
|
|
129
|
+
// The corrupt file should be silently skipped
|
|
130
|
+
const corrupt = items.find((i) => i === undefined)
|
|
131
|
+
assert.equal(corrupt, undefined)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// markComplete
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe('markComplete()', () => {
|
|
140
|
+
let tmpDir
|
|
141
|
+
|
|
142
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
143
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
144
|
+
|
|
145
|
+
test('deletes the draft file', () => {
|
|
146
|
+
const item = writeToDrafts({ repo: 'r/r', title: 'To delete', body: '' }, tmpDir)
|
|
147
|
+
const dir = getDraftsDir(tmpDir)
|
|
148
|
+
const filePath = path.join(dir, `${item.id}.json`)
|
|
149
|
+
|
|
150
|
+
assert.ok(fs.existsSync(filePath))
|
|
151
|
+
markComplete(item.id, { issueNumber: 42, issueUrl: 'https://github.com/r/r/issues/42' }, tmpDir)
|
|
152
|
+
assert.ok(!fs.existsSync(filePath), 'file should be deleted')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('is idempotent — does not throw if file already gone', () => {
|
|
156
|
+
assert.doesNotThrow(() => markComplete('nonexistent-id', {}, tmpDir))
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// markFailed
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe('markFailed()', () => {
|
|
165
|
+
let tmpDir
|
|
166
|
+
|
|
167
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
168
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
169
|
+
|
|
170
|
+
test('updates file with status=failed and error message', () => {
|
|
171
|
+
const item = writeToDrafts({ repo: 'r/r', title: 'Fail me', body: '' }, tmpDir)
|
|
172
|
+
markFailed(item.id, 'Connection refused', tmpDir)
|
|
173
|
+
|
|
174
|
+
const dir = getDraftsDir(tmpDir)
|
|
175
|
+
const filePath = path.join(dir, `${item.id}.json`)
|
|
176
|
+
assert.ok(fs.existsSync(filePath), 'file should still exist')
|
|
177
|
+
|
|
178
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
179
|
+
assert.equal(parsed.status, 'failed')
|
|
180
|
+
assert.equal(parsed.error, 'Connection refused')
|
|
181
|
+
assert.ok(parsed.failedAt, 'failedAt should be set')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('is idempotent — does not throw if file is gone', () => {
|
|
185
|
+
assert.doesNotThrow(() => markFailed('nonexistent-id', 'error', tmpDir))
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// hasPending / countPending
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('hasPending() and countPending()', () => {
|
|
194
|
+
let tmpDir
|
|
195
|
+
|
|
196
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
197
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
198
|
+
|
|
199
|
+
test('hasPending() returns false when drafts dir does not exist', () => {
|
|
200
|
+
const freshDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-fresh-'))
|
|
201
|
+
fs.mkdirSync(path.join(freshDir, '.git'))
|
|
202
|
+
try {
|
|
203
|
+
assert.equal(hasPending(freshDir), false)
|
|
204
|
+
} finally {
|
|
205
|
+
fs.rmSync(freshDir, { recursive: true, force: true })
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('countPending() returns 0 when drafts is empty', () => {
|
|
210
|
+
assert.equal(countPending(tmpDir), 0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('hasPending() returns true after writing an item', () => {
|
|
214
|
+
writeToDrafts({ repo: 'r/r', title: 'Pending', body: '' }, tmpDir)
|
|
215
|
+
assert.equal(hasPending(tmpDir), true)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('countPending() counts correctly', () => {
|
|
219
|
+
writeToDrafts({ repo: 'r/r', title: 'One more', body: '' }, tmpDir)
|
|
220
|
+
const count = countPending(tmpDir)
|
|
221
|
+
assert.ok(count >= 2, `Expected at least 2 pending, got ${count}`)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('hasPending() returns false after all items are removed', () => {
|
|
225
|
+
const items = readDrafts(tmpDir)
|
|
226
|
+
for (const item of items) {
|
|
227
|
+
markComplete(item.id, {}, tmpDir)
|
|
228
|
+
}
|
|
229
|
+
assert.equal(hasPending(tmpDir), false)
|
|
230
|
+
assert.equal(countPending(tmpDir), 0)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Full round-trip lifecycle
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe('Full round-trip: write → read → markComplete', () => {
|
|
239
|
+
let tmpDir
|
|
240
|
+
|
|
241
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
242
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
243
|
+
|
|
244
|
+
test('lifecycle ends with empty drafts', async () => {
|
|
245
|
+
// Write three items
|
|
246
|
+
const a = writeToDrafts({ repo: 'lifecycle/repo', title: 'Issue A', body: 'body a' }, tmpDir)
|
|
247
|
+
await new Promise((r) => setTimeout(r, 2))
|
|
248
|
+
const b = writeToDrafts({ repo: 'lifecycle/repo', title: 'Issue B', body: 'body b' }, tmpDir)
|
|
249
|
+
await new Promise((r) => setTimeout(r, 2))
|
|
250
|
+
const c = writeToDrafts({ repo: 'lifecycle/repo', title: 'Issue C', body: 'body c' }, tmpDir)
|
|
251
|
+
|
|
252
|
+
// Read and verify order
|
|
253
|
+
const items = readDrafts(tmpDir)
|
|
254
|
+
assert.equal(items.length, 3)
|
|
255
|
+
assert.equal(items[0].title, 'Issue A')
|
|
256
|
+
assert.equal(items[1].title, 'Issue B')
|
|
257
|
+
assert.equal(items[2].title, 'Issue C')
|
|
258
|
+
|
|
259
|
+
// Complete all
|
|
260
|
+
for (const item of items) {
|
|
261
|
+
markComplete(item.id, {}, tmpDir)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Drafts should be empty
|
|
265
|
+
assert.equal(hasPending(tmpDir), false)
|
|
266
|
+
assert.deepEqual(readDrafts(tmpDir), [])
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Migration: old outbox dir → drafts
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
describe('outbox → drafts migration', () => {
|
|
275
|
+
let tmpDir
|
|
276
|
+
|
|
277
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
278
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
279
|
+
|
|
280
|
+
test('auto-renames .tissues/outbox/ to .tissues/drafts/ on first access', () => {
|
|
281
|
+
// Simulate an old outbox directory
|
|
282
|
+
const oldDir = path.join(tmpDir, '.tissues', 'outbox')
|
|
283
|
+
fs.mkdirSync(oldDir, { recursive: true })
|
|
284
|
+
fs.writeFileSync(path.join(oldDir, 'test-issue.json'), JSON.stringify({
|
|
285
|
+
id: 'test-issue',
|
|
286
|
+
repo: 'r/r',
|
|
287
|
+
title: 'Test issue',
|
|
288
|
+
body: '',
|
|
289
|
+
labels: [],
|
|
290
|
+
createdAt: new Date().toISOString(),
|
|
291
|
+
status: 'pending',
|
|
292
|
+
}))
|
|
293
|
+
|
|
294
|
+
// getDraftsDir should migrate
|
|
295
|
+
const dir = getDraftsDir(tmpDir)
|
|
296
|
+
assert.ok(dir.endsWith('.tissues/drafts'), `Expected drafts dir, got ${dir}`)
|
|
297
|
+
assert.ok(!fs.existsSync(oldDir), 'old outbox dir should be gone')
|
|
298
|
+
assert.ok(fs.existsSync(path.join(dir, 'test-issue.json')), 'migrated file should exist')
|
|
299
|
+
})
|
|
300
|
+
})
|
|
@@ -0,0 +1,294 @@
|
|
|
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 {
|
|
7
|
+
BUILT_IN_ENHANCEMENTS,
|
|
8
|
+
builtInEnhancementKeys,
|
|
9
|
+
listEnhancements,
|
|
10
|
+
loadEnhancement,
|
|
11
|
+
loadAllEnhancements,
|
|
12
|
+
parseEnhancementFile,
|
|
13
|
+
} from './enhancements.js'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// BUILT_IN_ENHANCEMENTS
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
describe('BUILT_IN_ENHANCEMENTS', () => {
|
|
20
|
+
test('has 8 built-in enhancements', () => {
|
|
21
|
+
assert.equal(Object.keys(BUILT_IN_ENHANCEMENTS).length, 8)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('each enhancement has required fields', () => {
|
|
25
|
+
for (const [key, enh] of Object.entries(BUILT_IN_ENHANCEMENTS)) {
|
|
26
|
+
assert.ok(enh.key, `${key} has key`)
|
|
27
|
+
assert.ok(enh.name, `${key} has name`)
|
|
28
|
+
assert.ok(typeof enh.maxTokens === 'number', `${key} has maxTokens`)
|
|
29
|
+
assert.ok(['always', 'auto', 'never'].includes(enh.mode), `${key} has valid mode`)
|
|
30
|
+
assert.ok(['json', 'markdown'].includes(enh.format), `${key} has valid format`)
|
|
31
|
+
assert.ok(enh.contextKey, `${key} has contextKey`)
|
|
32
|
+
assert.ok(typeof enh.order === 'number', `${key} has order`)
|
|
33
|
+
assert.ok(typeof enh.prompt === 'string', `${key} has prompt`)
|
|
34
|
+
assert.ok(enh.prompt.length > 0, `${key} prompt is non-empty`)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('triage and format are structural', () => {
|
|
39
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.triage.isStructural, true)
|
|
40
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.format.isStructural, true)
|
|
41
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.context.isStructural, false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('orders are correct', () => {
|
|
45
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.triage.order, 10)
|
|
46
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.dedup.order, 15)
|
|
47
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.context.order, 20)
|
|
48
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.scope.order, 30)
|
|
49
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.complexity.order, 40)
|
|
50
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.risk.order, 50)
|
|
51
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.labels.order, 60)
|
|
52
|
+
assert.equal(BUILT_IN_ENHANCEMENTS.format.order, 90)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('builtInEnhancementKeys', () => {
|
|
57
|
+
test('returns all 8 keys', () => {
|
|
58
|
+
const keys = builtInEnhancementKeys()
|
|
59
|
+
assert.equal(keys.length, 8)
|
|
60
|
+
assert.ok(keys.includes('triage'))
|
|
61
|
+
assert.ok(keys.includes('format'))
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// parseEnhancementFile
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('parseEnhancementFile', () => {
|
|
70
|
+
test('parses frontmatter + body', () => {
|
|
71
|
+
const content = [
|
|
72
|
+
'---',
|
|
73
|
+
'name: Security Review',
|
|
74
|
+
'maxTokens: 2048',
|
|
75
|
+
'mode: always',
|
|
76
|
+
'format: json',
|
|
77
|
+
'contextKey: securityScore',
|
|
78
|
+
'order: 55',
|
|
79
|
+
'requires: ["scopeAnalysis"]',
|
|
80
|
+
'---',
|
|
81
|
+
'You are a security reviewer.',
|
|
82
|
+
'Return JSON.',
|
|
83
|
+
].join('\n')
|
|
84
|
+
|
|
85
|
+
const { meta, prompt } = parseEnhancementFile(content)
|
|
86
|
+
assert.equal(meta.name, 'Security Review')
|
|
87
|
+
assert.equal(meta.maxTokens, 2048)
|
|
88
|
+
assert.equal(meta.mode, 'always')
|
|
89
|
+
assert.equal(meta.format, 'json')
|
|
90
|
+
assert.equal(meta.contextKey, 'securityScore')
|
|
91
|
+
assert.equal(meta.order, 55)
|
|
92
|
+
assert.deepEqual(meta.requires, ['scopeAnalysis'])
|
|
93
|
+
assert.ok(prompt.includes('You are a security reviewer.'))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('handles file with no frontmatter', () => {
|
|
97
|
+
const content = 'Just a plain prompt.'
|
|
98
|
+
const { meta, prompt } = parseEnhancementFile(content)
|
|
99
|
+
assert.deepEqual(meta, {})
|
|
100
|
+
assert.equal(prompt, 'Just a plain prompt.')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('parses boolean values', () => {
|
|
104
|
+
const content = '---\nname: Test\nsome: true\nother: false\n---\nprompt'
|
|
105
|
+
const { meta } = parseEnhancementFile(content)
|
|
106
|
+
assert.equal(meta.some, true)
|
|
107
|
+
assert.equal(meta.other, false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('parses numeric values', () => {
|
|
111
|
+
const content = '---\nmaxTokens: 512\norder: 42\n---\nprompt'
|
|
112
|
+
const { meta } = parseEnhancementFile(content)
|
|
113
|
+
assert.equal(meta.maxTokens, 512)
|
|
114
|
+
assert.equal(meta.order, 42)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// listEnhancements
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
describe('listEnhancements', () => {
|
|
123
|
+
test('returns an array including built-ins', () => {
|
|
124
|
+
const list = listEnhancements(os.tmpdir())
|
|
125
|
+
assert.ok(Array.isArray(list))
|
|
126
|
+
const builtInKeys = list.filter((e) => e.source === 'built-in').map((e) => e.key)
|
|
127
|
+
assert.ok(builtInKeys.includes('triage'))
|
|
128
|
+
assert.ok(builtInKeys.includes('format'))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('each item has key, name, source, mode, order', () => {
|
|
132
|
+
const list = listEnhancements(os.tmpdir())
|
|
133
|
+
for (const item of list) {
|
|
134
|
+
assert.ok('key' in item)
|
|
135
|
+
assert.ok('name' in item)
|
|
136
|
+
assert.ok('source' in item)
|
|
137
|
+
assert.ok('mode' in item)
|
|
138
|
+
assert.ok('order' in item)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// loadEnhancement
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe('loadEnhancement', () => {
|
|
148
|
+
test('loads a built-in enhancement', () => {
|
|
149
|
+
const enh = loadEnhancement('triage', os.tmpdir())
|
|
150
|
+
assert.equal(enh.key, 'triage')
|
|
151
|
+
assert.equal(enh.source, 'built-in')
|
|
152
|
+
assert.ok(enh.prompt.length > 0)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('throws for unknown enhancement', () => {
|
|
156
|
+
assert.throws(
|
|
157
|
+
() => loadEnhancement('does-not-exist', os.tmpdir()),
|
|
158
|
+
/Enhancement "does-not-exist" not found/,
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Three-tier priority
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
describe('three-tier priority', () => {
|
|
168
|
+
test('user enhancement overrides built-in', () => {
|
|
169
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-enh-test-'))
|
|
170
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
171
|
+
|
|
172
|
+
// Create a user enhancement that overrides 'context'
|
|
173
|
+
const userDir = path.join(os.homedir(), '.config', 'tissues', 'enhancements')
|
|
174
|
+
const testFile = path.join(userDir, '__test-context-override.md')
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
fs.mkdirSync(userDir, { recursive: true })
|
|
178
|
+
fs.writeFileSync(testFile, '---\nname: Custom Context\nmaxTokens: 512\n---\nCustom prompt here.')
|
|
179
|
+
|
|
180
|
+
const enh = loadEnhancement('__test-context-override', tmpDir)
|
|
181
|
+
assert.equal(enh.source, 'user')
|
|
182
|
+
assert.ok(enh.prompt.includes('Custom prompt here.'))
|
|
183
|
+
} finally {
|
|
184
|
+
try { fs.unlinkSync(testFile) } catch { /* ignore */ }
|
|
185
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('repo enhancement overrides user and built-in', () => {
|
|
190
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-enh-repo-test-'))
|
|
191
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
192
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues', 'enhancements'), { recursive: true })
|
|
193
|
+
fs.writeFileSync(
|
|
194
|
+
path.join(tmpDir, '.tissues', 'enhancements', 'context.md'),
|
|
195
|
+
'---\nname: Repo Context\n---\nRepo-level context prompt.',
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const enh = loadEnhancement('context', tmpDir)
|
|
200
|
+
assert.equal(enh.source, 'repo')
|
|
201
|
+
assert.ok(enh.prompt.includes('Repo-level context prompt.'))
|
|
202
|
+
} finally {
|
|
203
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('repo enhancements appear before built-ins in list', () => {
|
|
208
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-enh-list-test-'))
|
|
209
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
210
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues', 'enhancements'), { recursive: true })
|
|
211
|
+
fs.writeFileSync(
|
|
212
|
+
path.join(tmpDir, '.tissues', 'enhancements', 'context.md'),
|
|
213
|
+
'---\nname: Repo Context\n---\nOverride.',
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const list = listEnhancements(tmpDir)
|
|
218
|
+
const repoIdx = list.findIndex((e) => e.source === 'repo' && e.key === 'context')
|
|
219
|
+
const builtInIdx = list.findIndex((e) => e.source === 'built-in' && e.key === 'context')
|
|
220
|
+
assert.ok(repoIdx >= 0)
|
|
221
|
+
assert.ok(builtInIdx >= 0)
|
|
222
|
+
assert.ok(repoIdx < builtInIdx)
|
|
223
|
+
} finally {
|
|
224
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// loadAllEnhancements
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe('loadAllEnhancements', () => {
|
|
234
|
+
test('returns sorted array of all enhancements', () => {
|
|
235
|
+
const all = loadAllEnhancements(os.tmpdir())
|
|
236
|
+
assert.ok(Array.isArray(all))
|
|
237
|
+
assert.ok(all.length >= 8)
|
|
238
|
+
|
|
239
|
+
// Verify sorted by order
|
|
240
|
+
for (let i = 1; i < all.length; i++) {
|
|
241
|
+
assert.ok(all[i].order >= all[i - 1].order, `${all[i].key} order >= ${all[i - 1].key}`)
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('custom enhancements are included', () => {
|
|
246
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-enh-all-test-'))
|
|
247
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
248
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues', 'enhancements'), { recursive: true })
|
|
249
|
+
fs.writeFileSync(
|
|
250
|
+
path.join(tmpDir, '.tissues', 'enhancements', 'security.md'),
|
|
251
|
+
'---\nname: Security Review\norder: 55\ncontextKey: securityScore\n---\nReview security.',
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const all = loadAllEnhancements(tmpDir)
|
|
256
|
+
const security = all.find((e) => e.key === 'security')
|
|
257
|
+
assert.ok(security)
|
|
258
|
+
assert.equal(security.name, 'Security Review')
|
|
259
|
+
assert.equal(security.order, 55)
|
|
260
|
+
assert.equal(security.source, 'repo')
|
|
261
|
+
} finally {
|
|
262
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('user override preserves isStructural from built-in', () => {
|
|
267
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-enh-struct-test-'))
|
|
268
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
269
|
+
|
|
270
|
+
const userDir = path.join(os.homedir(), '.config', 'tissues', 'enhancements')
|
|
271
|
+
const testFile = path.join(userDir, 'triage.md')
|
|
272
|
+
|
|
273
|
+
// Only run this test if we can safely write and clean up
|
|
274
|
+
let wrote = false
|
|
275
|
+
try {
|
|
276
|
+
// Skip if user already has a triage override
|
|
277
|
+
if (fs.existsSync(testFile)) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
fs.mkdirSync(userDir, { recursive: true })
|
|
281
|
+
fs.writeFileSync(testFile, '---\nname: Custom Triage\n---\nCustom triage prompt.')
|
|
282
|
+
wrote = true
|
|
283
|
+
|
|
284
|
+
const all = loadAllEnhancements(tmpDir)
|
|
285
|
+
const triage = all.find((e) => e.key === 'triage')
|
|
286
|
+
assert.ok(triage)
|
|
287
|
+
assert.equal(triage.isStructural, true) // preserved from built-in
|
|
288
|
+
assert.equal(triage.source, 'user')
|
|
289
|
+
} finally {
|
|
290
|
+
if (wrote) try { fs.unlinkSync(testFile) } catch { /* ignore */ }
|
|
291
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
})
|
package/src/lib/gh.js
CHANGED
|
@@ -381,6 +381,66 @@ export function uploadImageToRepo(repo, localPath, filename) {
|
|
|
381
381
|
return { url, path: repoPath }
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Fetch issues via `gh api` with incremental sync support.
|
|
386
|
+
* Returns raw GitHub API issue objects (not pull requests).
|
|
387
|
+
*
|
|
388
|
+
* @param {string} repo - owner/name
|
|
389
|
+
* @param {{ since?: string, state?: string, perPage?: number, page?: number }} [opts]
|
|
390
|
+
* @returns {Array<object>} raw API issue objects
|
|
391
|
+
*/
|
|
392
|
+
export function fetchIssuesApi(repo, opts = {}) {
|
|
393
|
+
const { since, state = 'all', perPage = 100, page = 1 } = opts
|
|
394
|
+
let endpoint = `repos/${repo}/issues?state=${state}&per_page=${perPage}&page=${page}&sort=updated&direction=asc`
|
|
395
|
+
if (since) endpoint += `&since=${since}`
|
|
396
|
+
|
|
397
|
+
let raw
|
|
398
|
+
try {
|
|
399
|
+
raw = execFileSync('gh', ['api', endpoint], {
|
|
400
|
+
encoding: 'utf8',
|
|
401
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
402
|
+
}).trim()
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const reason = (err.stderr || err.message || '').trim()
|
|
405
|
+
throw new Error(`gh api issues failed: ${reason}`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!raw) return []
|
|
409
|
+
const items = JSON.parse(raw)
|
|
410
|
+
// Filter out pull requests (they have a pull_request key)
|
|
411
|
+
return items.filter(i => !i.pull_request)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Fetch labels via `gh api` with full details.
|
|
416
|
+
*
|
|
417
|
+
* @param {string} repo - owner/name
|
|
418
|
+
* @param {{ perPage?: number, page?: number }} [opts]
|
|
419
|
+
* @returns {Array<{ name: string, color: string, description: string }>}
|
|
420
|
+
*/
|
|
421
|
+
export function fetchLabelsApi(repo, opts = {}) {
|
|
422
|
+
const { perPage = 100, page = 1 } = opts
|
|
423
|
+
let raw
|
|
424
|
+
try {
|
|
425
|
+
raw = execFileSync('gh', [
|
|
426
|
+
'api', `repos/${repo}/labels?per_page=${perPage}&page=${page}`,
|
|
427
|
+
], {
|
|
428
|
+
encoding: 'utf8',
|
|
429
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
430
|
+
}).trim()
|
|
431
|
+
} catch (err) {
|
|
432
|
+
const reason = (err.stderr || err.message || '').trim()
|
|
433
|
+
throw new Error(`gh api labels failed: ${reason}`)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!raw) return []
|
|
437
|
+
return JSON.parse(raw).map(l => ({
|
|
438
|
+
name: l.name,
|
|
439
|
+
color: l.color || '',
|
|
440
|
+
description: l.description || '',
|
|
441
|
+
}))
|
|
442
|
+
}
|
|
443
|
+
|
|
384
444
|
/**
|
|
385
445
|
* List repos the user has access to.
|
|
386
446
|
* @param {{ limit?: number }} [opts]
|