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,227 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { normalizeText, computeFingerprint, computeIdempotencyKey, jaccardSimilarity, scoreDedupConfidence } from './dedup.js'
|
|
4
|
+
|
|
5
|
+
describe('normalizeText', () => {
|
|
6
|
+
test('lowercases input', () => {
|
|
7
|
+
assert.equal(normalizeText('Hello World'), 'hello world')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('replaces non-alphanumeric characters with spaces', () => {
|
|
11
|
+
assert.equal(normalizeText('foo-bar_baz!qux'), 'foo bar baz qux')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('collapses multiple spaces into one', () => {
|
|
15
|
+
assert.equal(normalizeText('foo bar baz'), 'foo bar baz')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('trims leading and trailing whitespace', () => {
|
|
19
|
+
assert.equal(normalizeText(' hello world '), 'hello world')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('handles empty string', () => {
|
|
23
|
+
assert.equal(normalizeText(''), '')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('handles only special characters', () => {
|
|
27
|
+
assert.equal(normalizeText('!!!---...'), '')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('preserves digits', () => {
|
|
31
|
+
assert.equal(normalizeText('fix bug 42 in v2'), 'fix bug 42 in v2')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('normalizes punctuation-heavy title', () => {
|
|
35
|
+
assert.equal(normalizeText('[BUG]: Fix null-pointer exception!'), 'bug fix null pointer exception')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('computeFingerprint', () => {
|
|
40
|
+
test('returns a 64-character hex string', () => {
|
|
41
|
+
const fp = computeFingerprint('Title', 'Body text')
|
|
42
|
+
assert.equal(typeof fp, 'string')
|
|
43
|
+
assert.equal(fp.length, 64)
|
|
44
|
+
assert.match(fp, /^[0-9a-f]{64}$/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('same inputs produce same fingerprint', () => {
|
|
48
|
+
const fp1 = computeFingerprint('Fix login bug', 'The login page crashes when...')
|
|
49
|
+
const fp2 = computeFingerprint('Fix login bug', 'The login page crashes when...')
|
|
50
|
+
assert.equal(fp1, fp2)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('different titles produce different fingerprints', () => {
|
|
54
|
+
const fp1 = computeFingerprint('Fix login bug', 'Same body')
|
|
55
|
+
const fp2 = computeFingerprint('Fix signup bug', 'Same body')
|
|
56
|
+
assert.notEqual(fp1, fp2)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('different bodies produce different fingerprints', () => {
|
|
60
|
+
const fp1 = computeFingerprint('Same title', 'Body A')
|
|
61
|
+
const fp2 = computeFingerprint('Same title', 'Body B')
|
|
62
|
+
assert.notEqual(fp1, fp2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('only uses first 500 chars of body', () => {
|
|
66
|
+
const body500 = 'a'.repeat(500)
|
|
67
|
+
const body501 = 'a'.repeat(500) + 'z'
|
|
68
|
+
const fp3 = computeFingerprint('T', body500)
|
|
69
|
+
const fp4 = computeFingerprint('T', body501)
|
|
70
|
+
assert.equal(fp3, fp4)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('handles null/undefined body gracefully', () => {
|
|
74
|
+
const fp1 = computeFingerprint('Title', null)
|
|
75
|
+
const fp2 = computeFingerprint('Title', undefined)
|
|
76
|
+
const fp3 = computeFingerprint('Title', '')
|
|
77
|
+
assert.equal(fp1, fp3)
|
|
78
|
+
assert.equal(fp2, fp3)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('computeIdempotencyKey', () => {
|
|
83
|
+
test('returns a 64-character hex string', () => {
|
|
84
|
+
const key = computeIdempotencyKey({ agent: 'bot', trigger: 'cli', issueType: 'bug', repo: 'owner/repo' })
|
|
85
|
+
assert.equal(typeof key, 'string')
|
|
86
|
+
assert.equal(key.length, 64)
|
|
87
|
+
assert.match(key, /^[0-9a-f]{64}$/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('same context produces same key', () => {
|
|
91
|
+
const ctx = { agent: 'bot', trigger: 'api', issueType: 'feature', repo: 'org/proj' }
|
|
92
|
+
assert.equal(computeIdempotencyKey(ctx), computeIdempotencyKey(ctx))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('different agent produces different key', () => {
|
|
96
|
+
const ctx = { agent: 'bot-a', trigger: 'api', issueType: 'bug', repo: 'org/repo' }
|
|
97
|
+
const ctx2 = { ...ctx, agent: 'bot-b' }
|
|
98
|
+
assert.notEqual(computeIdempotencyKey(ctx), computeIdempotencyKey(ctx2))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('different repo produces different key', () => {
|
|
102
|
+
const ctx = { agent: 'bot', trigger: 'api', issueType: 'bug', repo: 'org/repo-a' }
|
|
103
|
+
const ctx2 = { ...ctx, repo: 'org/repo-b' }
|
|
104
|
+
assert.notEqual(computeIdempotencyKey(ctx), computeIdempotencyKey(ctx2))
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('jaccardSimilarity', () => {
|
|
109
|
+
test('identical strings return 1.0', () => {
|
|
110
|
+
assert.equal(jaccardSimilarity('fix login crash', 'fix login crash'), 1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('both empty strings return 1.0', () => {
|
|
114
|
+
assert.equal(jaccardSimilarity('', ''), 1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('both only stop words return 1.0', () => {
|
|
118
|
+
// After stop-word removal, both are empty token sets
|
|
119
|
+
assert.equal(jaccardSimilarity('the and or', 'is are was'), 1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('one empty string returns 0', () => {
|
|
123
|
+
assert.equal(jaccardSimilarity('', 'fix login'), 0)
|
|
124
|
+
assert.equal(jaccardSimilarity('fix login', ''), 0)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('one all-stop-words vs meaningful tokens returns 0', () => {
|
|
128
|
+
assert.equal(jaccardSimilarity('the and or', 'fix login crash'), 0)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('zero word overlap returns 0', () => {
|
|
132
|
+
assert.equal(jaccardSimilarity('stop agents hallucinating', 'sadfdasfa'), 0)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('partial overlap computes correct ratio', () => {
|
|
136
|
+
// tokens: {fix,login,crash} vs {fix,login,error} → intersection=2, union=4
|
|
137
|
+
const sim = jaccardSimilarity('fix login crash', 'fix login error')
|
|
138
|
+
assert.equal(sim, 2 / 4)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('stop words are stripped from comparison', () => {
|
|
142
|
+
// "fix the login crash" → {fix,login,crash}; "fix a login error" → {fix,login,error}
|
|
143
|
+
const sim = jaccardSimilarity('fix the login crash', 'fix a login error')
|
|
144
|
+
assert.equal(sim, 2 / 4)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('high overlap for near-duplicate titles', () => {
|
|
148
|
+
// {fix,login,crash} vs {fix,login,crash,safari} → 3/4 = 0.75
|
|
149
|
+
const sim = jaccardSimilarity('fix login crash', 'fix login crash on safari')
|
|
150
|
+
assert.equal(sim, 3 / 4)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('symmetric: sim(a,b) === sim(b,a)', () => {
|
|
154
|
+
const a = 'authentication error in production'
|
|
155
|
+
const b = 'authorization failure in staging'
|
|
156
|
+
assert.equal(jaccardSimilarity(a, b), jaccardSimilarity(b, a))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('returns value in [0, 1] range', () => {
|
|
160
|
+
const pairs = [
|
|
161
|
+
['', 'abc'],
|
|
162
|
+
['abc', ''],
|
|
163
|
+
['abc', 'abc'],
|
|
164
|
+
['totally', 'different'],
|
|
165
|
+
['Fix null pointer', 'fix null-pointer exception in login'],
|
|
166
|
+
]
|
|
167
|
+
for (const [a, b] of pairs) {
|
|
168
|
+
const sim = jaccardSimilarity(a, b)
|
|
169
|
+
assert.ok(sim >= 0 && sim <= 1, `similarity out of range for "${a}", "${b}": ${sim}`)
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('short titles with different words have low similarity', () => {
|
|
174
|
+
// "fix bug" → {fix,bug}, "fix auth" → {fix,auth} → 1/3 = 0.33
|
|
175
|
+
const sim = jaccardSimilarity('fix bug', 'fix auth')
|
|
176
|
+
assert.ok(sim < 0.5, `expected < 0.5 for short distinct titles, got ${sim}`)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('case insensitive', () => {
|
|
180
|
+
assert.equal(jaccardSimilarity('Fix Login Crash', 'fix login crash'), 1)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('punctuation ignored', () => {
|
|
184
|
+
// Punctuation stripped: "fix login crash!" → {fix,login,crash}, same as "fix login crash"
|
|
185
|
+
assert.equal(jaccardSimilarity('fix login crash!', 'fix login crash'), 1)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('scoreDedupConfidence', () => {
|
|
190
|
+
test('high confidence at 70+', () => {
|
|
191
|
+
const result = scoreDedupConfidence({ titleOverlap: 50, fileMentions: 20, labelMatches: 5 })
|
|
192
|
+
assert.equal(result.level, 'high')
|
|
193
|
+
assert.equal(result.confidence, 75)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('medium confidence at 40-69', () => {
|
|
197
|
+
const result = scoreDedupConfidence({ titleOverlap: 30, fileMentions: 15, labelMatches: 0 })
|
|
198
|
+
assert.equal(result.level, 'medium')
|
|
199
|
+
assert.equal(result.confidence, 45)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('low confidence at 21-39', () => {
|
|
203
|
+
const result = scoreDedupConfidence({ titleOverlap: 20, fileMentions: 5, labelMatches: 0 })
|
|
204
|
+
assert.equal(result.level, 'low')
|
|
205
|
+
assert.equal(result.confidence, 25)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('none confidence at 20 or below', () => {
|
|
209
|
+
const result = scoreDedupConfidence({ titleOverlap: 10, fileMentions: 5, labelMatches: 5 })
|
|
210
|
+
assert.equal(result.level, 'none')
|
|
211
|
+
assert.equal(result.confidence, 20)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('clamps to 0-100 range', () => {
|
|
215
|
+
const high = scoreDedupConfidence({ titleOverlap: 60, fileMentions: 40, labelMatches: 20 })
|
|
216
|
+
assert.equal(high.confidence, 100)
|
|
217
|
+
|
|
218
|
+
const low = scoreDedupConfidence({ titleOverlap: -10, fileMentions: 0, labelMatches: 0 })
|
|
219
|
+
assert.equal(low.confidence, 0)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('defaults to zero for missing fields', () => {
|
|
223
|
+
const result = scoreDedupConfidence({})
|
|
224
|
+
assert.equal(result.confidence, 0)
|
|
225
|
+
assert.equal(result.level, 'none')
|
|
226
|
+
})
|
|
227
|
+
})
|
package/src/lib/defaults.js
CHANGED
|
@@ -53,6 +53,10 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
53
53
|
ollama: 'llama3.2',
|
|
54
54
|
'openai-compat': null, // user must set
|
|
55
55
|
command: null, // not applicable
|
|
56
|
+
'gemini-cli': null, // uses CLI default
|
|
57
|
+
'claude-cli': null, // uses CLI default
|
|
58
|
+
'codex-cli': null, // uses CLI default
|
|
59
|
+
openclaw: null, // set by gateway
|
|
56
60
|
},
|
|
57
61
|
keys: {
|
|
58
62
|
anthropic: null,
|
|
@@ -62,6 +66,11 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
62
66
|
},
|
|
63
67
|
ollama: { url: 'http://localhost:11434' },
|
|
64
68
|
custom: { url: null }, // openai-compat base URL
|
|
69
|
+
openclaw: { // OpenClaw gateway config
|
|
70
|
+
gatewayUrl: null, // default: http://localhost:18790
|
|
71
|
+
token: null, // or OPENCLAW_GATEWAY_TOKEN env var
|
|
72
|
+
agentId: null, // optional agent ID
|
|
73
|
+
},
|
|
65
74
|
command: null, // e.g. 'co inference'
|
|
66
75
|
providers: {}, // named custom providers: { "my-gemini": { command: "co gemini", timeout: 120000 } }
|
|
67
76
|
routes: [], // routing rules
|
|
@@ -95,6 +104,17 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
95
104
|
},
|
|
96
105
|
},
|
|
97
106
|
|
|
107
|
+
// Background sync
|
|
108
|
+
sync: {
|
|
109
|
+
enabled: false, // opt-in
|
|
110
|
+
repos: [], // empty = activeRepo only
|
|
111
|
+
interval: 300, // seconds (5 min default)
|
|
112
|
+
adaptiveInterval: true, // adjust based on user activity
|
|
113
|
+
issueLimit: 500, // max issues per repo per sync
|
|
114
|
+
includeClosedDays: 30, // sync issues closed within N days
|
|
115
|
+
autoStart: false, // auto-fork daemon on first CLI use
|
|
116
|
+
},
|
|
117
|
+
|
|
98
118
|
// Hooks (shell commands to run)
|
|
99
119
|
hooks: {
|
|
100
120
|
postCreate: null, // e.g., 'slack-notify.sh'
|
|
@@ -0,0 +1,217 @@
|
|
|
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 { BUILT_IN_DEFAULTS, findRepoRoot, loadConfig, getConfigValue, userConfigPath } from './defaults.js'
|
|
7
|
+
|
|
8
|
+
describe('BUILT_IN_DEFAULTS', () => {
|
|
9
|
+
test('has safety section with expected keys', () => {
|
|
10
|
+
assert.ok(BUILT_IN_DEFAULTS.safety)
|
|
11
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.maxPerHour, 'number')
|
|
12
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.burstLimit, 'number')
|
|
13
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.burstWindowMinutes, 'number')
|
|
14
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.tripThreshold, 'number')
|
|
15
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.cooldownMinutes, 'number')
|
|
16
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.globalMaxPerHour, 'number')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('has dedup section with thresholds', () => {
|
|
20
|
+
assert.ok(BUILT_IN_DEFAULTS.dedup)
|
|
21
|
+
assert.equal(BUILT_IN_DEFAULTS.dedup.blockAbove, 0.95)
|
|
22
|
+
assert.equal(BUILT_IN_DEFAULTS.dedup.warnAbove, 0.80)
|
|
23
|
+
assert.ok(typeof BUILT_IN_DEFAULTS.dedup.fingerprintTTLDays === 'number')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('has attribution section', () => {
|
|
27
|
+
assert.ok(BUILT_IN_DEFAULTS.attribution)
|
|
28
|
+
assert.equal(BUILT_IN_DEFAULTS.attribution.required, false)
|
|
29
|
+
assert.equal(BUILT_IN_DEFAULTS.attribution.defaultAgent, 'human')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('has templates section', () => {
|
|
33
|
+
assert.ok(BUILT_IN_DEFAULTS.templates)
|
|
34
|
+
assert.ok(typeof BUILT_IN_DEFAULTS.templates.dir === 'string')
|
|
35
|
+
assert.equal(BUILT_IN_DEFAULTS.templates.default, 'default')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('has ai section', () => {
|
|
39
|
+
assert.ok(BUILT_IN_DEFAULTS.ai)
|
|
40
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.ai.enabled, 'boolean')
|
|
41
|
+
assert.ok(typeof BUILT_IN_DEFAULTS.ai.provider === 'string')
|
|
42
|
+
assert.equal(BUILT_IN_DEFAULTS.ai.model, null)
|
|
43
|
+
assert.ok(BUILT_IN_DEFAULTS.ai.models)
|
|
44
|
+
assert.ok(typeof BUILT_IN_DEFAULTS.ai.models.anthropic === 'string')
|
|
45
|
+
assert.ok(BUILT_IN_DEFAULTS.ai.keys)
|
|
46
|
+
assert.ok(Array.isArray(BUILT_IN_DEFAULTS.ai.routes))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('has hooks section with null defaults', () => {
|
|
50
|
+
assert.ok(BUILT_IN_DEFAULTS.hooks)
|
|
51
|
+
assert.equal(BUILT_IN_DEFAULTS.hooks.postCreate, null)
|
|
52
|
+
assert.equal(BUILT_IN_DEFAULTS.hooks.postClose, null)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('findRepoRoot', () => {
|
|
57
|
+
test('finds a git repo root from a subdirectory', () => {
|
|
58
|
+
// The project itself is a git repo
|
|
59
|
+
const root = findRepoRoot(path.join(process.cwd(), 'src', 'lib'))
|
|
60
|
+
assert.ok(root, 'should find a repo root')
|
|
61
|
+
assert.ok(fs.existsSync(path.join(root, '.git')))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('returns null when no git repo found', () => {
|
|
65
|
+
// Use os.tmpdir() which (almost certainly) has no .git above it
|
|
66
|
+
const root = findRepoRoot(os.tmpdir())
|
|
67
|
+
// On some systems tmpdir might be inside a git repo; just check type
|
|
68
|
+
assert.ok(root === null || typeof root === 'string')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('returns the directory that contains .git, not a subdirectory', () => {
|
|
72
|
+
const root = findRepoRoot(process.cwd())
|
|
73
|
+
assert.ok(root)
|
|
74
|
+
assert.ok(fs.existsSync(path.join(root, '.git')))
|
|
75
|
+
// The .git directory should be a direct child, not deeper
|
|
76
|
+
const gitPath = path.join(root, '.git')
|
|
77
|
+
const stat = fs.statSync(gitPath)
|
|
78
|
+
assert.ok(stat.isDirectory() || stat.isFile()) // worktrees use a file
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('userConfigPath', () => {
|
|
83
|
+
test('returns a string path ending in config.json', () => {
|
|
84
|
+
const p = userConfigPath()
|
|
85
|
+
assert.ok(typeof p === 'string')
|
|
86
|
+
assert.ok(p.endsWith('config.json'))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('includes tissues in the path', () => {
|
|
90
|
+
const p = userConfigPath()
|
|
91
|
+
assert.ok(p.includes('tissues'))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('is under the home directory', () => {
|
|
95
|
+
const p = userConfigPath()
|
|
96
|
+
const home = os.homedir()
|
|
97
|
+
assert.ok(p.startsWith(home))
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('loadConfig', () => {
|
|
102
|
+
test('returns an object with all built-in sections when no files exist', () => {
|
|
103
|
+
// Use a temp dir with .git so loadConfig does not pick up project-level config
|
|
104
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-defaults-test-'))
|
|
105
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
106
|
+
try {
|
|
107
|
+
const cfg = loadConfig(tmpDir)
|
|
108
|
+
assert.ok(cfg.safety)
|
|
109
|
+
assert.ok(cfg.dedup)
|
|
110
|
+
assert.ok(cfg.attribution)
|
|
111
|
+
assert.ok(cfg.templates)
|
|
112
|
+
assert.ok(cfg.ai)
|
|
113
|
+
assert.ok(cfg.hooks)
|
|
114
|
+
} finally {
|
|
115
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('CLI overrides take highest priority', () => {
|
|
120
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-cli-override-test-'))
|
|
121
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
122
|
+
try {
|
|
123
|
+
const cfg = loadConfig(tmpDir, { safety: { maxPerHour: 999 } })
|
|
124
|
+
assert.equal(cfg.safety.maxPerHour, 999)
|
|
125
|
+
} finally {
|
|
126
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('repo-level config overrides built-in defaults', () => {
|
|
131
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-repo-cfg-test-'))
|
|
132
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
133
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues'))
|
|
134
|
+
fs.writeFileSync(
|
|
135
|
+
path.join(tmpDir, '.tissues', 'config.json'),
|
|
136
|
+
JSON.stringify({ safety: { maxPerHour: 50 } })
|
|
137
|
+
)
|
|
138
|
+
try {
|
|
139
|
+
const cfg = loadConfig(tmpDir)
|
|
140
|
+
assert.equal(cfg.safety.maxPerHour, 50)
|
|
141
|
+
// Other fields still come from defaults
|
|
142
|
+
assert.equal(cfg.safety.burstLimit, BUILT_IN_DEFAULTS.safety.burstLimit)
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('CLI overrides take priority over repo config', () => {
|
|
149
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-priority-test-'))
|
|
150
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
151
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues'))
|
|
152
|
+
fs.writeFileSync(
|
|
153
|
+
path.join(tmpDir, '.tissues', 'config.json'),
|
|
154
|
+
JSON.stringify({ safety: { maxPerHour: 50 } })
|
|
155
|
+
)
|
|
156
|
+
try {
|
|
157
|
+
const cfg = loadConfig(tmpDir, { safety: { maxPerHour: 100 } })
|
|
158
|
+
assert.equal(cfg.safety.maxPerHour, 100)
|
|
159
|
+
} finally {
|
|
160
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('deep merge does not clobber sibling keys', () => {
|
|
165
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-deep-merge-test-'))
|
|
166
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
167
|
+
try {
|
|
168
|
+
// Override only one key in safety; others should remain
|
|
169
|
+
const cfg = loadConfig(tmpDir, { safety: { maxPerHour: 20 } })
|
|
170
|
+
assert.equal(cfg.safety.maxPerHour, 20)
|
|
171
|
+
assert.equal(cfg.safety.burstLimit, BUILT_IN_DEFAULTS.safety.burstLimit)
|
|
172
|
+
assert.equal(cfg.safety.tripThreshold, BUILT_IN_DEFAULTS.safety.tripThreshold)
|
|
173
|
+
} finally {
|
|
174
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('invalid JSON in repo config is silently ignored', () => {
|
|
179
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-bad-json-test-'))
|
|
180
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
181
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues'))
|
|
182
|
+
fs.writeFileSync(path.join(tmpDir, '.tissues', 'config.json'), 'not json {{{')
|
|
183
|
+
try {
|
|
184
|
+
const cfg = loadConfig(tmpDir)
|
|
185
|
+
// Should fall back gracefully to defaults
|
|
186
|
+
assert.equal(cfg.safety.maxPerHour, BUILT_IN_DEFAULTS.safety.maxPerHour)
|
|
187
|
+
} finally {
|
|
188
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('getConfigValue', () => {
|
|
194
|
+
test('retrieves a nested value using dot notation', () => {
|
|
195
|
+
// Use the actual tissues repo root — the project itself is a git repo
|
|
196
|
+
const val = getConfigValue('safety.maxPerHour')
|
|
197
|
+
assert.ok(typeof val === 'number')
|
|
198
|
+
assert.ok(val > 0)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('returns undefined for unknown key path', () => {
|
|
202
|
+
const val = getConfigValue('nonexistent.key.path')
|
|
203
|
+
assert.equal(val, undefined)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('retrieves top-level section', () => {
|
|
207
|
+
const safety = getConfigValue('safety')
|
|
208
|
+
assert.ok(safety && typeof safety === 'object')
|
|
209
|
+
assert.ok('maxPerHour' in safety)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('retrieves deeply nested ai.models.anthropic', () => {
|
|
213
|
+
const model = getConfigValue('ai.models.anthropic')
|
|
214
|
+
assert.ok(typeof model === 'string')
|
|
215
|
+
assert.ok(model.length > 0)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance and adversarial tests for drafts.js.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that drafts are correct under load:
|
|
5
|
+
* - 100 concurrent writes produce no collisions
|
|
6
|
+
* - Reading 1000 items completes in < 500ms
|
|
7
|
+
* - Corrupt files don't prevent other items from processing
|
|
8
|
+
* - writeToDrafts during an active readDrafts doesn't cause double-processing
|
|
9
|
+
*
|
|
10
|
+
* Run standalone: node --test src/lib/drafts-perf.test.js
|
|
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
|
+
import {
|
|
20
|
+
getDraftsDir,
|
|
21
|
+
writeToDrafts,
|
|
22
|
+
readDrafts,
|
|
23
|
+
markComplete,
|
|
24
|
+
hasPending,
|
|
25
|
+
countPending,
|
|
26
|
+
} from './drafts.js'
|
|
27
|
+
|
|
28
|
+
function makeTempRepo() {
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-drafts-perf-'))
|
|
30
|
+
fs.mkdirSync(path.join(dir, '.git'))
|
|
31
|
+
return dir
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// 100 concurrent writes
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
describe('100 concurrent writes', () => {
|
|
39
|
+
let tmpDir
|
|
40
|
+
|
|
41
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
42
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
43
|
+
|
|
44
|
+
test('all 100 files exist with unique IDs', async () => {
|
|
45
|
+
const writes = Array.from({ length: 100 }, (_, i) =>
|
|
46
|
+
Promise.resolve(
|
|
47
|
+
writeToDrafts({ repo: 'perf/repo', title: `Issue ${i}`, body: `body ${i}` }, tmpDir),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const items = await Promise.all(writes)
|
|
52
|
+
const ids = new Set(items.map((i) => i.id))
|
|
53
|
+
|
|
54
|
+
assert.equal(ids.size, 100, 'all 100 IDs should be unique')
|
|
55
|
+
assert.equal(countPending(tmpDir), 100, 'all 100 files should be on disk')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Reading 1000 items
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
describe('readDrafts() at scale', () => {
|
|
64
|
+
let tmpDir
|
|
65
|
+
|
|
66
|
+
before(() => {
|
|
67
|
+
tmpDir = makeTempRepo()
|
|
68
|
+
const dir = getDraftsDir(tmpDir)
|
|
69
|
+
|
|
70
|
+
// Pre-seed 1000 items directly (faster than writeToDrafts loop)
|
|
71
|
+
for (let i = 0; i < 1000; i++) {
|
|
72
|
+
const id = `perf${String(i).padStart(6, '0')}`
|
|
73
|
+
const record = {
|
|
74
|
+
id,
|
|
75
|
+
repo: 'perf/repo',
|
|
76
|
+
title: `Issue ${i}`,
|
|
77
|
+
body: `body ${i}`,
|
|
78
|
+
labels: [],
|
|
79
|
+
createdAt: new Date(Date.now() - (1000 - i) * 1000).toISOString(),
|
|
80
|
+
status: 'pending',
|
|
81
|
+
attemptCount: 0,
|
|
82
|
+
}
|
|
83
|
+
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(record), 'utf8')
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
88
|
+
|
|
89
|
+
test('reads 1000 items in < 500ms', () => {
|
|
90
|
+
const start = Date.now()
|
|
91
|
+
const items = readDrafts(tmpDir)
|
|
92
|
+
const elapsed = Date.now() - start
|
|
93
|
+
|
|
94
|
+
assert.equal(items.length, 1000)
|
|
95
|
+
assert.ok(elapsed < 500, `readDrafts took ${elapsed}ms — expected < 500ms`)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('results are sorted by createdAt', () => {
|
|
99
|
+
const items = readDrafts(tmpDir)
|
|
100
|
+
for (let i = 1; i < items.length; i++) {
|
|
101
|
+
const prev = new Date(items[i - 1].createdAt)
|
|
102
|
+
const curr = new Date(items[i].createdAt)
|
|
103
|
+
assert.ok(prev <= curr, `item ${i - 1} should be before item ${i}`)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Corrupt files don't break the rest
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe('adversarial: corrupt files in drafts', () => {
|
|
113
|
+
let tmpDir
|
|
114
|
+
|
|
115
|
+
before(() => {
|
|
116
|
+
tmpDir = makeTempRepo()
|
|
117
|
+
const dir = getDraftsDir(tmpDir)
|
|
118
|
+
|
|
119
|
+
// Write 5 valid items
|
|
120
|
+
for (let i = 0; i < 5; i++) {
|
|
121
|
+
writeToDrafts({ repo: 'a/b', title: `Valid ${i}`, body: '' }, tmpDir)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Inject 3 corrupt files
|
|
125
|
+
fs.writeFileSync(path.join(dir, 'corrupt1.json'), '{ this is not json', 'utf8')
|
|
126
|
+
fs.writeFileSync(path.join(dir, 'corrupt2.json'), '', 'utf8')
|
|
127
|
+
fs.writeFileSync(path.join(dir, 'corrupt3.json'), 'null', 'utf8')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
131
|
+
|
|
132
|
+
test('readDrafts() skips corrupt files and returns all valid items', () => {
|
|
133
|
+
const items = readDrafts(tmpDir)
|
|
134
|
+
// Should get 5 valid items (null is valid JSON but has no .id, gets returned anyway)
|
|
135
|
+
const validItems = items.filter((i) => i && i.id)
|
|
136
|
+
assert.equal(validItems.length, 5, 'should return all 5 valid items')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('hasPending() does not throw on corrupt dir', () => {
|
|
140
|
+
assert.doesNotThrow(() => hasPending(tmpDir))
|
|
141
|
+
assert.equal(hasPending(tmpDir), true)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// New write during read doesn't cause double-processing
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
describe('concurrent write during read', () => {
|
|
150
|
+
let tmpDir
|
|
151
|
+
|
|
152
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
153
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
154
|
+
|
|
155
|
+
test('item written after readDrafts snapshot is not included in that batch', () => {
|
|
156
|
+
// Write 3 items before read
|
|
157
|
+
writeToDrafts({ repo: 'c/d', title: 'Pre A', body: '' }, tmpDir)
|
|
158
|
+
writeToDrafts({ repo: 'c/d', title: 'Pre B', body: '' }, tmpDir)
|
|
159
|
+
writeToDrafts({ repo: 'c/d', title: 'Pre C', body: '' }, tmpDir)
|
|
160
|
+
|
|
161
|
+
// Snapshot the batch
|
|
162
|
+
const batch = readDrafts(tmpDir)
|
|
163
|
+
assert.equal(batch.length, 3)
|
|
164
|
+
|
|
165
|
+
// Write a new item AFTER reading — simulates concurrent create
|
|
166
|
+
writeToDrafts({ repo: 'c/d', title: 'Post D', body: '' }, tmpDir)
|
|
167
|
+
|
|
168
|
+
// The batch we already read should not include the late arrival
|
|
169
|
+
const lateArrival = batch.find((i) => i.title === 'Post D')
|
|
170
|
+
assert.equal(lateArrival, undefined, 'late-arriving item should not appear in existing batch')
|
|
171
|
+
|
|
172
|
+
// A fresh read should see all 4
|
|
173
|
+
const fresh = readDrafts(tmpDir)
|
|
174
|
+
assert.equal(fresh.length, 4)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// SIGTERM survival (file persists if process would be killed mid-flush)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
describe('durability: file survives after write', () => {
|
|
183
|
+
let tmpDir
|
|
184
|
+
|
|
185
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
186
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
187
|
+
|
|
188
|
+
test('draft file exists immediately after writeToDrafts (before any network call)', () => {
|
|
189
|
+
// Simulate: we write draft, then "crash" (don't call createIssue)
|
|
190
|
+
const item = writeToDrafts({ repo: 'e/f', title: 'Crash survivor', body: 'important' }, tmpDir)
|
|
191
|
+
|
|
192
|
+
// The file should exist on disk right now
|
|
193
|
+
const dir = getDraftsDir(tmpDir)
|
|
194
|
+
const filePath = path.join(dir, `${item.id}.json`)
|
|
195
|
+
assert.ok(fs.existsSync(filePath), 'file must exist immediately after write')
|
|
196
|
+
|
|
197
|
+
// Simulate a re-run: readDrafts finds it
|
|
198
|
+
const recovered = readDrafts(tmpDir)
|
|
199
|
+
const mine = recovered.find((i) => i.id === item.id)
|
|
200
|
+
assert.ok(mine, 'file should be recoverable via readDrafts after simulated crash')
|
|
201
|
+
assert.equal(mine.status, 'pending')
|
|
202
|
+
})
|
|
203
|
+
})
|