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,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
|
@@ -321,16 +321,22 @@ export function addLabelsToIssue(repo, issueNumber, labels) {
|
|
|
321
321
|
*/
|
|
322
322
|
export function listIssues(repo, opts = {}) {
|
|
323
323
|
const limit = opts.limit ?? 100
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
'
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
324
|
+
let raw
|
|
325
|
+
try {
|
|
326
|
+
raw = execFileSync('gh', [
|
|
327
|
+
'issue', 'list',
|
|
328
|
+
'--repo', repo,
|
|
329
|
+
'--state', 'open',
|
|
330
|
+
'--limit', String(limit),
|
|
331
|
+
'--json', 'number,title,url,labels,createdAt',
|
|
332
|
+
], {
|
|
333
|
+
encoding: 'utf8',
|
|
334
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
335
|
+
}).trim()
|
|
336
|
+
} catch (err) {
|
|
337
|
+
const reason = (err.stderr || err.message || '').trim()
|
|
338
|
+
throw new Error(`gh issue list failed: ${reason}`)
|
|
339
|
+
}
|
|
334
340
|
|
|
335
341
|
if (!raw) return []
|
|
336
342
|
const issues = JSON.parse(raw)
|
|
@@ -375,6 +381,66 @@ export function uploadImageToRepo(repo, localPath, filename) {
|
|
|
375
381
|
return { url, path: repoPath }
|
|
376
382
|
}
|
|
377
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
|
+
|
|
378
444
|
/**
|
|
379
445
|
* List repos the user has access to.
|
|
380
446
|
* @param {{ limit?: number }} [opts]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for safety.js
|
|
3
|
+
*
|
|
4
|
+
* safety.js depends heavily on db.js (SQLite). These tests focus on the
|
|
5
|
+
* pure/isolated behaviors: formatCooldownRemaining logic is internal, so we
|
|
6
|
+
* test the exported interface surface that does NOT require a live DB.
|
|
7
|
+
*
|
|
8
|
+
* For the DB-dependent functions (checkSafety, recordSuccess, recordFailure,
|
|
9
|
+
* getSafetyStatus, forceReset) we use a real SQLite DB in a temp directory so
|
|
10
|
+
* the tests remain self-contained and clean up after themselves.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { test, describe, before, after } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Set up an isolated temp directory that looks like a git repo so db.js
|
|
21
|
+
// writes tissues.db there instead of into the project root.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
let tmpDir
|
|
25
|
+
|
|
26
|
+
before(() => {
|
|
27
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-safety-test-'))
|
|
28
|
+
// Create .git so findRepoRootForDb resolves here
|
|
29
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
30
|
+
// Point the process cwd so db.js resolves correctly when the module is loaded
|
|
31
|
+
process.chdir(tmpDir)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
after(() => {
|
|
35
|
+
// Restore cwd to something safe
|
|
36
|
+
try { process.chdir(os.tmpdir()) } catch { /* ignore */ }
|
|
37
|
+
// Clean up temp dir
|
|
38
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Import AFTER before() sets up tmpDir so db.js picks up the right cwd.
|
|
42
|
+
// Node ESM caches modules so we import lazily in each test instead.
|
|
43
|
+
// We use dynamic import inside the tests to defer resolution.
|
|
44
|
+
// However, since ESM imports are hoisted, we need a workaround:
|
|
45
|
+
// we define a helper that loads the module on demand via dynamic import.
|
|
46
|
+
async function getSafety() {
|
|
47
|
+
return import('./safety.js')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function getDb() {
|
|
51
|
+
return import('./db.js')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Tests
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('checkSafety – allowed when no events recorded', () => {
|
|
59
|
+
test('returns allowed=true and circuitState=closed for fresh repo/agent', async () => {
|
|
60
|
+
const { checkSafety } = await getSafety()
|
|
61
|
+
const result = checkSafety('test/repo-fresh', 'agent-alpha')
|
|
62
|
+
assert.equal(result.allowed, true)
|
|
63
|
+
assert.equal(result.circuitState, 'closed')
|
|
64
|
+
assert.ok(result.rateInfo)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('rateInfo contains rate counters', async () => {
|
|
68
|
+
const { checkSafety } = await getSafety()
|
|
69
|
+
const result = checkSafety('test/repo-fresh2', 'agent-beta')
|
|
70
|
+
assert.ok('agentHourCount' in result.rateInfo)
|
|
71
|
+
assert.ok('agentBurstCount' in result.rateInfo)
|
|
72
|
+
assert.ok('globalHourCount' in result.rateInfo)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('checkSafety – rate limit enforcement', () => {
|
|
77
|
+
test('blocks when per-agent hourly limit is reached', async () => {
|
|
78
|
+
const { checkSafety } = await getSafety()
|
|
79
|
+
const { recordRateEvent } = await getDb()
|
|
80
|
+
const repo = 'test/repo-hourly'
|
|
81
|
+
const agent = 'agent-hourly'
|
|
82
|
+
|
|
83
|
+
// Simulate maxPerHour (default 10) create events
|
|
84
|
+
for (let i = 0; i < 10; i++) {
|
|
85
|
+
recordRateEvent(repo, agent, 'create')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = checkSafety(repo, agent)
|
|
89
|
+
assert.equal(result.allowed, false)
|
|
90
|
+
assert.ok(result.reason.includes('Hourly rate limit'))
|
|
91
|
+
assert.equal(result.circuitState, 'closed')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('blocks when burst limit is reached', async () => {
|
|
95
|
+
const { checkSafety } = await getSafety()
|
|
96
|
+
const { recordRateEvent } = await getDb()
|
|
97
|
+
const repo = 'test/repo-burst'
|
|
98
|
+
const agent = 'agent-burst'
|
|
99
|
+
|
|
100
|
+
// Simulate burstLimit (default 5) create events in burst window
|
|
101
|
+
for (let i = 0; i < 5; i++) {
|
|
102
|
+
recordRateEvent(repo, agent, 'create')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = checkSafety(repo, agent)
|
|
106
|
+
assert.equal(result.allowed, false)
|
|
107
|
+
assert.ok(result.reason.includes('Burst rate limit') || result.reason.includes('Hourly rate limit'))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('custom config overrides default limits', async () => {
|
|
111
|
+
const { checkSafety } = await getSafety()
|
|
112
|
+
const { recordRateEvent } = await getDb()
|
|
113
|
+
const repo = 'test/repo-custom'
|
|
114
|
+
const agent = 'agent-custom'
|
|
115
|
+
|
|
116
|
+
// Record 2 events — should be blocked with maxPerHour=2
|
|
117
|
+
recordRateEvent(repo, agent, 'create')
|
|
118
|
+
recordRateEvent(repo, agent, 'create')
|
|
119
|
+
|
|
120
|
+
const result = checkSafety(repo, agent, { maxPerHour: 2, burstLimit: 10 })
|
|
121
|
+
assert.equal(result.allowed, false)
|
|
122
|
+
assert.ok(result.reason.includes('Hourly rate limit'))
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('checkSafety – circuit breaker', () => {
|
|
127
|
+
test('circuit is open after being tripped', async () => {
|
|
128
|
+
const { checkSafety } = await getSafety()
|
|
129
|
+
const { tripCircuit } = await getDb()
|
|
130
|
+
const repo = 'test/repo-circuit'
|
|
131
|
+
const agent = 'agent-circuit'
|
|
132
|
+
|
|
133
|
+
tripCircuit(repo, agent, 30)
|
|
134
|
+
|
|
135
|
+
const result = checkSafety(repo, agent)
|
|
136
|
+
assert.equal(result.allowed, false)
|
|
137
|
+
assert.equal(result.circuitState, 'open')
|
|
138
|
+
assert.ok(result.reason.includes('Circuit breaker is open'))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('circuit resets to closed after forceReset', async () => {
|
|
142
|
+
const { checkSafety, forceReset } = await getSafety()
|
|
143
|
+
const { tripCircuit } = await getDb()
|
|
144
|
+
const repo = 'test/repo-reset'
|
|
145
|
+
const agent = 'agent-reset'
|
|
146
|
+
|
|
147
|
+
tripCircuit(repo, agent, 30)
|
|
148
|
+
forceReset(repo, agent)
|
|
149
|
+
|
|
150
|
+
const result = checkSafety(repo, agent)
|
|
151
|
+
assert.equal(result.circuitState, 'closed')
|
|
152
|
+
assert.equal(result.allowed, true)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('recordSuccess', () => {
|
|
157
|
+
test('recording success does not throw', async () => {
|
|
158
|
+
const { recordSuccess } = await getSafety()
|
|
159
|
+
assert.doesNotThrow(() => recordSuccess('test/repo-ok', 'agent-ok'))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('recording success increments rate event count', async () => {
|
|
163
|
+
const { recordSuccess } = await getSafety()
|
|
164
|
+
const { countRecentEvents } = await getDb()
|
|
165
|
+
const repo = 'test/repo-success-count'
|
|
166
|
+
const agent = 'agent-sc'
|
|
167
|
+
|
|
168
|
+
const before = countRecentEvents(repo, agent, 60)
|
|
169
|
+
recordSuccess(repo, agent)
|
|
170
|
+
const after = countRecentEvents(repo, agent, 60)
|
|
171
|
+
assert.equal(after, before + 1)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('recordFailure', () => {
|
|
176
|
+
test('recording failures does not throw', async () => {
|
|
177
|
+
const { recordFailure } = await getSafety()
|
|
178
|
+
assert.doesNotThrow(() => recordFailure('test/repo-fail', 'agent-fail'))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('circuit trips after tripThreshold failures', async () => {
|
|
182
|
+
const { recordFailure, checkSafety } = await getSafety()
|
|
183
|
+
const repo = 'test/repo-trip'
|
|
184
|
+
const agent = 'agent-trip'
|
|
185
|
+
const config = { tripThreshold: 3, cooldownMinutes: 30 }
|
|
186
|
+
|
|
187
|
+
recordFailure(repo, agent, config)
|
|
188
|
+
recordFailure(repo, agent, config)
|
|
189
|
+
recordFailure(repo, agent, config) // should trip
|
|
190
|
+
|
|
191
|
+
const result = checkSafety(repo, agent)
|
|
192
|
+
assert.equal(result.circuitState, 'open')
|
|
193
|
+
assert.equal(result.allowed, false)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('getSafetyStatus', () => {
|
|
198
|
+
test('returns a formatted string with status info', async () => {
|
|
199
|
+
const { getSafetyStatus } = await getSafety()
|
|
200
|
+
const status = getSafetyStatus('test/repo-status', 'agent-status')
|
|
201
|
+
assert.ok(typeof status === 'string')
|
|
202
|
+
assert.ok(status.includes('Circuit breaker'))
|
|
203
|
+
assert.ok(status.includes('Hourly'))
|
|
204
|
+
assert.ok(status.includes('Burst'))
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('shows OPEN in status when circuit is tripped', async () => {
|
|
208
|
+
const { getSafetyStatus } = await getSafety()
|
|
209
|
+
const { tripCircuit } = await getDb()
|
|
210
|
+
const repo = 'test/repo-status-open'
|
|
211
|
+
const agent = 'agent-status-open'
|
|
212
|
+
|
|
213
|
+
tripCircuit(repo, agent, 30)
|
|
214
|
+
const status = getSafetyStatus(repo, agent)
|
|
215
|
+
assert.ok(status.includes('OPEN'))
|
|
216
|
+
})
|
|
217
|
+
})
|