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.
Files changed (42) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +10 -1
  4. package/src/commands/ai.js +147 -173
  5. package/src/commands/create.js +6 -6
  6. package/src/commands/create.test.js +381 -0
  7. package/src/commands/flush.test.js +299 -0
  8. package/src/commands/list.js +3 -2
  9. package/src/commands/providers.js +347 -0
  10. package/src/commands/providers.test.js +28 -0
  11. package/src/commands/storage.js +167 -0
  12. package/src/commands/sync.js +225 -0
  13. package/src/daemon/sync.js +189 -0
  14. package/src/lib/ai/adapters/claude-cli.js +55 -0
  15. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  16. package/src/lib/ai/adapters/codex-cli.js +77 -0
  17. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  18. package/src/lib/ai/adapters/openclaw.js +91 -0
  19. package/src/lib/ai/agent-actions.js +271 -0
  20. package/src/lib/ai/agent.js +323 -0
  21. package/src/lib/ai/discovery.js +89 -0
  22. package/src/lib/ai/discovery.test.js +74 -0
  23. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  24. package/src/lib/ai/pipeline.test.js +257 -0
  25. package/src/lib/ai/prompt.test.js +30 -0
  26. package/src/lib/ai/router.js +23 -0
  27. package/src/lib/ai/router.test.js +481 -0
  28. package/src/lib/ai/steps.test.js +335 -0
  29. package/src/lib/attribution.test.js +64 -0
  30. package/src/lib/cache.js +408 -0
  31. package/src/lib/db.js +42 -0
  32. package/src/lib/dedup.js +8 -18
  33. package/src/lib/dedup.test.js +227 -0
  34. package/src/lib/defaults.js +20 -0
  35. package/src/lib/defaults.test.js +217 -0
  36. package/src/lib/drafts-perf.test.js +203 -0
  37. package/src/lib/drafts.test.js +300 -0
  38. package/src/lib/enhancements.test.js +294 -0
  39. package/src/lib/gh.js +60 -0
  40. package/src/lib/safety.test.js +217 -0
  41. package/src/lib/storage.js +298 -0
  42. 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]