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,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the outbox wiring in create.js.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that the outbox file is written, kept, or deleted
|
|
5
|
+
* correctly across the four critical paths:
|
|
6
|
+
*
|
|
7
|
+
* 1. Safety blocked → file stays pending, process exits 0
|
|
8
|
+
* 2. createIssue() throws → file gets status='failed'
|
|
9
|
+
* 3. verifyIssue() returns false → file stays pending
|
|
10
|
+
* 4. Full success → file deleted
|
|
11
|
+
*
|
|
12
|
+
* Requires: --experimental-test-module-mocks (Node 22+)
|
|
13
|
+
*
|
|
14
|
+
* Run:
|
|
15
|
+
* node --experimental-test-module-mocks --test src/commands/create.test.js
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { test, describe, before, after, mock } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import os from 'node:os'
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Mutable stubs — each test overrides these before calling runCreate
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
let mockSafetyAllowed = true
|
|
29
|
+
let mockSafetyReason = ''
|
|
30
|
+
let mockCreateIssueResult = { number: 42, url: 'https://github.com/test/repo/issues/42' }
|
|
31
|
+
let mockCreateIssueThrows = null // set to an Error to simulate failure
|
|
32
|
+
let mockVerifyResult = true
|
|
33
|
+
let mockDedupResult = { action: 'allow', results: [] }
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Mock modules — must be set up before importing the module under test
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
mock.module('../lib/gh.js', {
|
|
40
|
+
namedExports: {
|
|
41
|
+
requireAuth: () => {},
|
|
42
|
+
createIssue: (_repo, _opts) => {
|
|
43
|
+
if (mockCreateIssueThrows) throw mockCreateIssueThrows
|
|
44
|
+
return mockCreateIssueResult
|
|
45
|
+
},
|
|
46
|
+
verifyIssue: (_repo, _number) => mockVerifyResult,
|
|
47
|
+
listLabels: () => [],
|
|
48
|
+
createLabel: () => {},
|
|
49
|
+
addLabelsToIssue: () => {},
|
|
50
|
+
listRepos: () => [],
|
|
51
|
+
listIssues: () => [],
|
|
52
|
+
fetchIssuesApi: () => [],
|
|
53
|
+
fetchLabelsApi: () => [],
|
|
54
|
+
uploadImageToRepo: () => ({ url: 'https://raw.githubusercontent.com/test/repo/HEAD/.tissues/images/test.png', path: '.tissues/images/test.png' }),
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
mock.module('../lib/safety.js', {
|
|
59
|
+
namedExports: {
|
|
60
|
+
checkSafety: () => ({
|
|
61
|
+
allowed: mockSafetyAllowed,
|
|
62
|
+
reason: mockSafetyReason || 'rate limited',
|
|
63
|
+
circuitState: 'closed',
|
|
64
|
+
rateInfo: {},
|
|
65
|
+
}),
|
|
66
|
+
recordSuccess: () => {},
|
|
67
|
+
recordFailure: () => {},
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
mock.module('../lib/dedup.js', {
|
|
72
|
+
namedExports: {
|
|
73
|
+
checkDuplicate: async () => mockDedupResult,
|
|
74
|
+
recordCreation: async () => {},
|
|
75
|
+
computeFingerprint: (title, body) => {
|
|
76
|
+
// simple stub fingerprint
|
|
77
|
+
return Buffer.from(title + body).toString('hex').slice(0, 64)
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Skip AI enhancement entirely
|
|
83
|
+
let mockCheckAvailable = () => false
|
|
84
|
+
mock.module('../lib/ai/index.js', {
|
|
85
|
+
namedExports: {
|
|
86
|
+
checkAvailable: (...args) => mockCheckAvailable(...args),
|
|
87
|
+
enhance: async (_cfg, _t, _d, body) => body,
|
|
88
|
+
checkBudgets: () => {},
|
|
89
|
+
recordUsage: () => {},
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
let mockPipelineResult = null
|
|
94
|
+
let mockPipelineThrows = null
|
|
95
|
+
mock.module('../lib/ai/enhance.js', {
|
|
96
|
+
namedExports: {
|
|
97
|
+
runEnhancePipeline: async () => {
|
|
98
|
+
if (mockPipelineThrows) throw mockPipelineThrows
|
|
99
|
+
return mockPipelineResult
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Import the module under test AFTER mocks are set up
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
const { runCreate } = await import('./create.js')
|
|
109
|
+
const { readDrafts, markComplete } = await import('../lib/drafts.js')
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Temp dir setup
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
let tmpDir
|
|
116
|
+
let originalCwd
|
|
117
|
+
|
|
118
|
+
before(() => {
|
|
119
|
+
originalCwd = process.cwd()
|
|
120
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-create-test-'))
|
|
121
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
122
|
+
process.chdir(tmpDir)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
after(() => {
|
|
126
|
+
try { process.chdir(originalCwd) } catch { /* ignore */ }
|
|
127
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Helper: base opts that avoid interactive prompts
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
function baseOpts(overrides = {}) {
|
|
135
|
+
return {
|
|
136
|
+
repo: 'test/repo',
|
|
137
|
+
title: 'Test issue title',
|
|
138
|
+
body: 'Test body',
|
|
139
|
+
instructions: undefined,
|
|
140
|
+
template: 'default',
|
|
141
|
+
agent: 'human',
|
|
142
|
+
session: null,
|
|
143
|
+
labels: undefined,
|
|
144
|
+
force: true,
|
|
145
|
+
dryRun: false,
|
|
146
|
+
enhance: false,
|
|
147
|
+
...overrides,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Helper: intercept process.exit so it doesn't kill the test runner
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
class ExitError extends Error {
|
|
156
|
+
constructor(code) {
|
|
157
|
+
super(`process.exit(${code})`)
|
|
158
|
+
this.exitCode = code
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function trapExit() {
|
|
163
|
+
return mock.method(process, 'exit', (code) => {
|
|
164
|
+
throw new ExitError(code)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Tests
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe('create.js outbox integration', () => {
|
|
173
|
+
// Reset stubs before each test
|
|
174
|
+
before(() => {
|
|
175
|
+
mockSafetyAllowed = true
|
|
176
|
+
mockSafetyReason = ''
|
|
177
|
+
mockCreateIssueResult = { number: 42, url: 'https://github.com/test/repo/issues/42' }
|
|
178
|
+
mockCreateIssueThrows = null
|
|
179
|
+
mockVerifyResult = true
|
|
180
|
+
mockDedupResult = { action: 'allow', results: [] }
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('full success path — outbox file deleted after verify', async () => {
|
|
184
|
+
mockSafetyAllowed = true
|
|
185
|
+
mockVerifyResult = true
|
|
186
|
+
|
|
187
|
+
const result = await runCreate(baseOpts())
|
|
188
|
+
|
|
189
|
+
assert.ok(result, 'should return result')
|
|
190
|
+
assert.equal(result.number, 42)
|
|
191
|
+
assert.equal(result.url, 'https://github.com/test/repo/issues/42')
|
|
192
|
+
|
|
193
|
+
// Outbox file should be gone
|
|
194
|
+
const pending = readDrafts(tmpDir)
|
|
195
|
+
const mine = pending.filter((i) => i.title === 'Test issue title' && i.status !== 'failed')
|
|
196
|
+
assert.equal(mine.length, 0, 'outbox file should be deleted after success')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('safety blocked — outbox file stays pending, exits 0', async () => {
|
|
200
|
+
mockSafetyAllowed = false
|
|
201
|
+
mockSafetyReason = 'Burst rate limit reached'
|
|
202
|
+
|
|
203
|
+
const exitTrap = trapExit()
|
|
204
|
+
|
|
205
|
+
let exitCode = null
|
|
206
|
+
try {
|
|
207
|
+
await runCreate(baseOpts({ title: 'Safety blocked issue' }))
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (err instanceof ExitError) {
|
|
210
|
+
exitCode = err.exitCode
|
|
211
|
+
} else {
|
|
212
|
+
throw err
|
|
213
|
+
}
|
|
214
|
+
} finally {
|
|
215
|
+
exitTrap.mock.restore()
|
|
216
|
+
mockSafetyAllowed = true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
assert.equal(exitCode, 0, 'should exit 0 on safety block (not a failure)')
|
|
220
|
+
|
|
221
|
+
// Outbox file must still be there
|
|
222
|
+
const pending = readDrafts(tmpDir)
|
|
223
|
+
const mine = pending.filter((i) => i.title === 'Safety blocked issue')
|
|
224
|
+
assert.equal(mine.length, 1, 'outbox file should be kept when safety blocks')
|
|
225
|
+
assert.equal(mine[0].status, 'pending')
|
|
226
|
+
|
|
227
|
+
// Clean up
|
|
228
|
+
markComplete(mine[0].id, {}, tmpDir)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('createIssue() throws — outbox file gets status=failed', async () => {
|
|
232
|
+
mockSafetyAllowed = true
|
|
233
|
+
mockCreateIssueThrows = new Error('API rate limit exceeded')
|
|
234
|
+
|
|
235
|
+
const exitTrap = trapExit()
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await runCreate(baseOpts({ title: 'Failed create issue' }))
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (!(err instanceof ExitError) && !err.message.includes('Failed to create issue')) {
|
|
241
|
+
throw err
|
|
242
|
+
}
|
|
243
|
+
} finally {
|
|
244
|
+
exitTrap.mock.restore()
|
|
245
|
+
mockCreateIssueThrows = null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// File should still exist with status=failed
|
|
249
|
+
const allItems = readDrafts(tmpDir)
|
|
250
|
+
const mine = allItems.filter((i) => i.title === 'Failed create issue')
|
|
251
|
+
assert.equal(mine.length, 1, 'outbox file should be kept when createIssue throws')
|
|
252
|
+
assert.equal(mine[0].status, 'failed')
|
|
253
|
+
assert.ok(mine[0].error, 'error field should be set')
|
|
254
|
+
assert.ok(mine[0].failedAt, 'failedAt should be set')
|
|
255
|
+
|
|
256
|
+
// Clean up
|
|
257
|
+
markComplete(mine[0].id, {}, tmpDir)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('verifyIssue() returns false — outbox file kept as pending', async () => {
|
|
261
|
+
mockSafetyAllowed = true
|
|
262
|
+
mockCreateIssueResult = { number: 99, url: 'https://github.com/test/repo/issues/99' }
|
|
263
|
+
mockVerifyResult = false
|
|
264
|
+
|
|
265
|
+
await runCreate(baseOpts({ title: 'Unverified issue' }))
|
|
266
|
+
|
|
267
|
+
const allItems = readDrafts(tmpDir)
|
|
268
|
+
const mine = allItems.filter((i) => i.title === 'Unverified issue')
|
|
269
|
+
assert.equal(mine.length, 1, 'outbox file should remain when verify fails')
|
|
270
|
+
// Status should still be pending (not completed, not failed)
|
|
271
|
+
assert.equal(mine[0].status, 'pending')
|
|
272
|
+
|
|
273
|
+
// Clean up
|
|
274
|
+
markComplete(mine[0].id, {}, tmpDir)
|
|
275
|
+
mockVerifyResult = true
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('duplicate detected — no draft file created, throws', async () => {
|
|
279
|
+
const before = readDrafts(tmpDir).length
|
|
280
|
+
|
|
281
|
+
mockDedupResult = {
|
|
282
|
+
action: 'block',
|
|
283
|
+
results: [{ action: 'block', reason: 'Exact duplicate', existingIssue: null }],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await assert.rejects(
|
|
287
|
+
() => runCreate(baseOpts({ title: 'Duplicate issue' })),
|
|
288
|
+
/Duplicate detected/,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
// No draft file should be created for dedup blocks
|
|
292
|
+
const after = readDrafts(tmpDir).length
|
|
293
|
+
assert.equal(after, before, 'no draft file should be created for duplicates')
|
|
294
|
+
|
|
295
|
+
mockDedupResult = { action: 'allow', results: [] }
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('dry run — no outbox file written', async () => {
|
|
299
|
+
const before = readDrafts(tmpDir).length
|
|
300
|
+
|
|
301
|
+
const result = await runCreate(baseOpts({ title: 'Dry run issue', dryRun: true }))
|
|
302
|
+
|
|
303
|
+
assert.equal(result, null, 'dry run should return null')
|
|
304
|
+
const after = readDrafts(tmpDir).length
|
|
305
|
+
assert.equal(after, before, 'no outbox file should be written for dry run')
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Pipeline integration tests
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
describe('create.js pipeline integration', () => {
|
|
314
|
+
before(() => {
|
|
315
|
+
mockSafetyAllowed = true
|
|
316
|
+
mockSafetyReason = ''
|
|
317
|
+
mockCreateIssueResult = { number: 42, url: 'https://github.com/test/repo/issues/42' }
|
|
318
|
+
mockCreateIssueThrows = null
|
|
319
|
+
mockVerifyResult = true
|
|
320
|
+
mockDedupResult = { action: 'allow', results: [] }
|
|
321
|
+
mockCheckAvailable = () => false
|
|
322
|
+
mockPipelineResult = null
|
|
323
|
+
mockPipelineThrows = null
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('pipeline disabled (AI unavailable) — falls back to template', async () => {
|
|
327
|
+
mockCheckAvailable = () => false
|
|
328
|
+
mockPipelineResult = null
|
|
329
|
+
|
|
330
|
+
const result = await runCreate(baseOpts({ title: 'No AI issue' }))
|
|
331
|
+
assert.ok(result)
|
|
332
|
+
assert.equal(result.number, 42)
|
|
333
|
+
// Clean up
|
|
334
|
+
const items = readDrafts(tmpDir).filter((i) => i.title === 'No AI issue')
|
|
335
|
+
for (const i of items) markComplete(i.id, {}, tmpDir)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('--no-pipeline flag forces single-shot even if pipeline enabled', async () => {
|
|
339
|
+
// With AI unavailable, pipeline flag doesn't matter — body stays as template
|
|
340
|
+
mockCheckAvailable = () => false
|
|
341
|
+
|
|
342
|
+
const result = await runCreate(baseOpts({ title: 'No pipeline flag', pipeline: false }))
|
|
343
|
+
assert.ok(result)
|
|
344
|
+
assert.equal(result.number, 42)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('pipeline failure degrades gracefully to template', async () => {
|
|
348
|
+
mockCheckAvailable = () => true
|
|
349
|
+
mockPipelineThrows = new Error('Pipeline exploded')
|
|
350
|
+
|
|
351
|
+
const result = await runCreate(baseOpts({ title: 'Pipeline fail issue' }))
|
|
352
|
+
assert.ok(result)
|
|
353
|
+
assert.equal(result.number, 42)
|
|
354
|
+
|
|
355
|
+
// Clean up
|
|
356
|
+
mockPipelineThrows = null
|
|
357
|
+
mockCheckAvailable = () => false
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('pipeline result title is used for GitHub issue', async () => {
|
|
361
|
+
mockCheckAvailable = () => true
|
|
362
|
+
mockPipelineResult = {
|
|
363
|
+
title: 'AI-refined title',
|
|
364
|
+
body: '## Summary\nAI body',
|
|
365
|
+
aiLabels: [],
|
|
366
|
+
dedupScore: null,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const result = await runCreate(baseOpts({
|
|
370
|
+
title: 'Original title from splitInput',
|
|
371
|
+
pipeline: true,
|
|
372
|
+
enhance: true,
|
|
373
|
+
}))
|
|
374
|
+
assert.ok(result)
|
|
375
|
+
assert.equal(result.number, 42)
|
|
376
|
+
|
|
377
|
+
// Clean up
|
|
378
|
+
mockPipelineResult = null
|
|
379
|
+
mockCheckAvailable = () => false
|
|
380
|
+
})
|
|
381
|
+
})
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for flush.js.
|
|
3
|
+
*
|
|
4
|
+
* Tests the five critical flush paths:
|
|
5
|
+
* 1. Empty outbox → "nothing to flush" message, no exit
|
|
6
|
+
* 2. All items flush successfully → files deleted, summary printed
|
|
7
|
+
* 3. Rate limited mid-flush → partial flush, remaining files stay, exit 0
|
|
8
|
+
* 4. Duplicate found → file deleted, skipped in summary
|
|
9
|
+
* 5. createIssue() throws → file gets status=failed, continues to next
|
|
10
|
+
* 6. verifyIssue() returns false → file kept, warning printed
|
|
11
|
+
*
|
|
12
|
+
* Requires: --experimental-test-module-mocks (Node 22+)
|
|
13
|
+
*
|
|
14
|
+
* Run:
|
|
15
|
+
* node --experimental-test-module-mocks --test src/commands/flush.test.js
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { test, describe, before, after, mock } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import os from 'node:os'
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Mutable stubs
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
let mockSafetyAllowed = true
|
|
29
|
+
let mockSafetyCallCount = 0
|
|
30
|
+
let mockSafetyBlockAfter = Infinity // block after N successful calls
|
|
31
|
+
let mockCreateIssueImpl = () => ({ number: 42, url: 'https://github.com/test/repo/issues/42' })
|
|
32
|
+
let mockVerifyImpl = () => true
|
|
33
|
+
let mockDedupImpl = async () => ({ action: 'allow', results: [] })
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Module mocks — must be set up before importing module under test
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
mock.module('../lib/gh.js', {
|
|
40
|
+
namedExports: {
|
|
41
|
+
requireAuth: () => {},
|
|
42
|
+
createIssue: (...args) => mockCreateIssueImpl(...args),
|
|
43
|
+
verifyIssue: (...args) => mockVerifyImpl(...args),
|
|
44
|
+
listRepos: () => [],
|
|
45
|
+
listIssues: () => [],
|
|
46
|
+
listLabels: () => [],
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
mock.module('../lib/safety.js', {
|
|
51
|
+
namedExports: {
|
|
52
|
+
checkSafety: () => {
|
|
53
|
+
mockSafetyCallCount++
|
|
54
|
+
const allowed = mockSafetyCallCount <= mockSafetyBlockAfter
|
|
55
|
+
return {
|
|
56
|
+
allowed,
|
|
57
|
+
reason: allowed ? undefined : 'Rate limit reached',
|
|
58
|
+
circuitState: 'closed',
|
|
59
|
+
rateInfo: {},
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
recordSuccess: () => {},
|
|
63
|
+
recordFailure: () => {},
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
mock.module('../lib/dedup.js', {
|
|
68
|
+
namedExports: {
|
|
69
|
+
checkDuplicate: async (...args) => mockDedupImpl(...args),
|
|
70
|
+
recordCreation: async () => {},
|
|
71
|
+
computeFingerprint: (title, body) =>
|
|
72
|
+
Buffer.from(title + body).toString('hex').slice(0, 64),
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Import after mocks
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const { flushCommand } = await import('./flush.js')
|
|
81
|
+
const { writeToDrafts, readDrafts, markComplete } = await import('../lib/drafts.js')
|
|
82
|
+
const { store } = await import('../lib/config.js')
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Temp dir setup
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
let tmpDir
|
|
89
|
+
let originalCwd
|
|
90
|
+
|
|
91
|
+
before(() => {
|
|
92
|
+
originalCwd = process.cwd()
|
|
93
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-flush-test-'))
|
|
94
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
95
|
+
process.chdir(tmpDir)
|
|
96
|
+
store.set('activeRepo', 'test/repo')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
after(() => {
|
|
100
|
+
try { process.chdir(originalCwd) } catch { /* ignore */ }
|
|
101
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Helpers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
class ExitError extends Error {
|
|
109
|
+
constructor(code) {
|
|
110
|
+
super(`process.exit(${code})`)
|
|
111
|
+
this.exitCode = code
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function trapExit() {
|
|
116
|
+
return mock.method(process, 'exit', (code) => { throw new ExitError(code) })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resetStubs() {
|
|
120
|
+
mockSafetyAllowed = true
|
|
121
|
+
mockSafetyCallCount = 0
|
|
122
|
+
mockSafetyBlockAfter = Infinity
|
|
123
|
+
mockCreateIssueImpl = (_repo, _opts) => ({ number: 42, url: 'https://github.com/test/repo/issues/42' })
|
|
124
|
+
mockVerifyImpl = () => true
|
|
125
|
+
mockDedupImpl = async () => ({ action: 'allow', results: [] })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runFlush(opts = {}) {
|
|
129
|
+
return flushCommand.parseAsync(['node', 'tissues', 'flush', ...Object.entries(opts).flatMap(([k, v]) => [`--${k}`, v])])
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function clearOutbox() {
|
|
133
|
+
const items = readDrafts(tmpDir)
|
|
134
|
+
for (const item of items) markComplete(item.id, {}, tmpDir)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Tests
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
describe('flush.js integration', () => {
|
|
142
|
+
before(() => { resetStubs(); clearOutbox() })
|
|
143
|
+
|
|
144
|
+
test('empty outbox — exits cleanly with no-op message', async () => {
|
|
145
|
+
clearOutbox()
|
|
146
|
+
// Should not throw, should not call createIssue
|
|
147
|
+
let createCalled = false
|
|
148
|
+
mockCreateIssueImpl = () => { createCalled = true; return { number: 1, url: '' } }
|
|
149
|
+
|
|
150
|
+
await runFlush()
|
|
151
|
+
|
|
152
|
+
assert.equal(createCalled, false, 'createIssue should not be called for empty outbox')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('full success — all files deleted, summary shown', async () => {
|
|
156
|
+
resetStubs()
|
|
157
|
+
clearOutbox()
|
|
158
|
+
|
|
159
|
+
let issueNum = 100
|
|
160
|
+
mockCreateIssueImpl = (_repo, _opts) => ({
|
|
161
|
+
number: ++issueNum,
|
|
162
|
+
url: `https://github.com/test/repo/issues/${issueNum}`,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
writeToDrafts({ repo: 'test/repo', title: 'Issue Alpha', body: 'body a' }, tmpDir)
|
|
166
|
+
writeToDrafts({ repo: 'test/repo', title: 'Issue Beta', body: 'body b' }, tmpDir)
|
|
167
|
+
writeToDrafts({ repo: 'test/repo', title: 'Issue Gamma', body: 'body c' }, tmpDir)
|
|
168
|
+
|
|
169
|
+
await runFlush()
|
|
170
|
+
|
|
171
|
+
const remaining = readDrafts(tmpDir).filter((i) => i.repo === 'test/repo')
|
|
172
|
+
assert.equal(remaining.length, 0, 'all outbox files should be deleted after full success')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('rate limited mid-flush — partial flush, remaining files stay, exit 0', async () => {
|
|
176
|
+
resetStubs()
|
|
177
|
+
clearOutbox()
|
|
178
|
+
|
|
179
|
+
// Allow only the first createIssue call (safety blocks before 2nd)
|
|
180
|
+
mockSafetyBlockAfter = 1
|
|
181
|
+
|
|
182
|
+
writeToDrafts({ repo: 'test/repo', title: 'Partial One', body: '' }, tmpDir)
|
|
183
|
+
writeToDrafts({ repo: 'test/repo', title: 'Partial Two', body: '' }, tmpDir)
|
|
184
|
+
writeToDrafts({ repo: 'test/repo', title: 'Partial Three', body: '' }, tmpDir)
|
|
185
|
+
|
|
186
|
+
const exitTrap = trapExit()
|
|
187
|
+
let exitCode = null
|
|
188
|
+
try {
|
|
189
|
+
await runFlush()
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (err instanceof ExitError) exitCode = err.exitCode
|
|
192
|
+
else throw err
|
|
193
|
+
} finally {
|
|
194
|
+
exitTrap.mock.restore()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
assert.equal(exitCode, 0, 'rate-limited flush should exit 0')
|
|
198
|
+
|
|
199
|
+
const remaining = readDrafts(tmpDir).filter(
|
|
200
|
+
(i) => i.repo === 'test/repo' && i.status !== 'failed',
|
|
201
|
+
)
|
|
202
|
+
assert.ok(remaining.length > 0, 'some files should remain after partial flush')
|
|
203
|
+
assert.ok(remaining.length < 3, 'at least one file should have been sent')
|
|
204
|
+
|
|
205
|
+
clearOutbox()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('duplicate detected — file deleted, counted as skipped', async () => {
|
|
209
|
+
resetStubs()
|
|
210
|
+
clearOutbox()
|
|
211
|
+
|
|
212
|
+
mockDedupImpl = async () => ({
|
|
213
|
+
action: 'block',
|
|
214
|
+
results: [{ action: 'block', reason: 'Exact match', existingIssue: null }],
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
writeToDrafts({ repo: 'test/repo', title: 'Dupe Issue', body: 'same body' }, tmpDir)
|
|
218
|
+
|
|
219
|
+
let createCalled = false
|
|
220
|
+
mockCreateIssueImpl = () => { createCalled = true; return { number: 1, url: '' } }
|
|
221
|
+
|
|
222
|
+
await runFlush()
|
|
223
|
+
|
|
224
|
+
assert.equal(createCalled, false, 'createIssue should not be called for duplicates')
|
|
225
|
+
|
|
226
|
+
const remaining = readDrafts(tmpDir).filter((i) => i.title === 'Dupe Issue')
|
|
227
|
+
assert.equal(remaining.length, 0, 'duplicate outbox file should be deleted')
|
|
228
|
+
|
|
229
|
+
resetStubs()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('createIssue throws — file gets status=failed, continues to next item', async () => {
|
|
233
|
+
resetStubs()
|
|
234
|
+
clearOutbox()
|
|
235
|
+
|
|
236
|
+
let callCount = 0
|
|
237
|
+
mockCreateIssueImpl = (_repo, opts) => {
|
|
238
|
+
callCount++
|
|
239
|
+
if (opts.title === 'Will Fail') throw new Error('API error')
|
|
240
|
+
return { number: 200 + callCount, url: `https://github.com/test/repo/issues/${200 + callCount}` }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
writeToDrafts({ repo: 'test/repo', title: 'Will Fail', body: '' }, tmpDir)
|
|
244
|
+
writeToDrafts({ repo: 'test/repo', title: 'Will Succeed', body: '' }, tmpDir)
|
|
245
|
+
|
|
246
|
+
await runFlush()
|
|
247
|
+
|
|
248
|
+
const all = readDrafts(tmpDir)
|
|
249
|
+
const failed = all.filter((i) => i.title === 'Will Fail')
|
|
250
|
+
const succeeded = all.filter((i) => i.title === 'Will Succeed')
|
|
251
|
+
|
|
252
|
+
assert.equal(failed.length, 1, 'failed item should remain in outbox')
|
|
253
|
+
assert.equal(failed[0].status, 'failed', 'failed item should have status=failed')
|
|
254
|
+
assert.ok(failed[0].error, 'failed item should have error message')
|
|
255
|
+
|
|
256
|
+
assert.equal(succeeded.length, 0, 'successful item should be deleted from outbox')
|
|
257
|
+
|
|
258
|
+
clearOutbox()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('verifyIssue returns false — file kept as pending', async () => {
|
|
262
|
+
resetStubs()
|
|
263
|
+
clearOutbox()
|
|
264
|
+
|
|
265
|
+
mockCreateIssueImpl = () => ({ number: 999, url: 'https://github.com/test/repo/issues/999' })
|
|
266
|
+
mockVerifyImpl = () => false
|
|
267
|
+
|
|
268
|
+
writeToDrafts({ repo: 'test/repo', title: 'Unverifiable', body: '' }, tmpDir)
|
|
269
|
+
|
|
270
|
+
await runFlush()
|
|
271
|
+
|
|
272
|
+
const remaining = readDrafts(tmpDir).filter((i) => i.title === 'Unverifiable')
|
|
273
|
+
assert.equal(remaining.length, 1, 'unverified file should remain in outbox')
|
|
274
|
+
assert.equal(remaining[0].status, 'pending', 'status should stay pending, not failed')
|
|
275
|
+
|
|
276
|
+
clearOutbox()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('only flushes items for active repo — ignores other repos', async () => {
|
|
280
|
+
resetStubs()
|
|
281
|
+
clearOutbox()
|
|
282
|
+
|
|
283
|
+
let createCalled = 0
|
|
284
|
+
mockCreateIssueImpl = (_repo, _opts) => { createCalled++; return { number: createCalled, url: '' } }
|
|
285
|
+
|
|
286
|
+
writeToDrafts({ repo: 'test/repo', title: 'Mine', body: '' }, tmpDir)
|
|
287
|
+
writeToDrafts({ repo: 'other/repo', title: 'Not mine', body: '' }, tmpDir)
|
|
288
|
+
|
|
289
|
+
await runFlush()
|
|
290
|
+
|
|
291
|
+
assert.equal(createCalled, 1, 'should only create issues for active repo')
|
|
292
|
+
|
|
293
|
+
// Other repo item should still be there
|
|
294
|
+
const other = readDrafts(tmpDir).filter((i) => i.repo === 'other/repo')
|
|
295
|
+
assert.equal(other.length, 1, 'other-repo item should remain untouched')
|
|
296
|
+
|
|
297
|
+
clearOutbox()
|
|
298
|
+
})
|
|
299
|
+
})
|
package/src/commands/list.js
CHANGED
|
@@ -3,7 +3,8 @@ import { search } from '@inquirer/prompts'
|
|
|
3
3
|
import { store } from '../lib/config.js'
|
|
4
4
|
import { theme } from '../lib/theme.js'
|
|
5
5
|
import { pickRepo } from '../lib/repo-picker.js'
|
|
6
|
-
import { requireAuth
|
|
6
|
+
import { requireAuth } from '../lib/gh.js'
|
|
7
|
+
import { ensureFresh } from '../lib/cache.js'
|
|
7
8
|
import { yellow, dim, cyan } from '../lib/color.js'
|
|
8
9
|
import ora from 'ora'
|
|
9
10
|
import { execSync } from 'child_process'
|
|
@@ -26,7 +27,7 @@ export const listCommand = new Command('list')
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const spinner = ora('Fetching issues...').start()
|
|
29
|
-
const issues =
|
|
30
|
+
const issues = ensureFresh(repo, 'issues', { state: 'open' })
|
|
30
31
|
spinner.stop()
|
|
31
32
|
|
|
32
33
|
if (issues.length === 0) {
|