tissues 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +10 -1
  4. package/src/commands/ai.js +147 -173
  5. package/src/commands/create.js +6 -6
  6. package/src/commands/create.test.js +381 -0
  7. package/src/commands/flush.test.js +299 -0
  8. package/src/commands/list.js +3 -2
  9. package/src/commands/providers.js +347 -0
  10. package/src/commands/providers.test.js +28 -0
  11. package/src/commands/storage.js +167 -0
  12. package/src/commands/sync.js +225 -0
  13. package/src/daemon/sync.js +189 -0
  14. package/src/lib/ai/adapters/claude-cli.js +55 -0
  15. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  16. package/src/lib/ai/adapters/codex-cli.js +77 -0
  17. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  18. package/src/lib/ai/adapters/openclaw.js +91 -0
  19. package/src/lib/ai/agent-actions.js +271 -0
  20. package/src/lib/ai/agent.js +323 -0
  21. package/src/lib/ai/discovery.js +89 -0
  22. package/src/lib/ai/discovery.test.js +74 -0
  23. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  24. package/src/lib/ai/pipeline.test.js +257 -0
  25. package/src/lib/ai/prompt.test.js +30 -0
  26. package/src/lib/ai/router.js +23 -0
  27. package/src/lib/ai/router.test.js +481 -0
  28. package/src/lib/ai/steps.test.js +335 -0
  29. package/src/lib/attribution.test.js +64 -0
  30. package/src/lib/cache.js +408 -0
  31. package/src/lib/db.js +42 -0
  32. package/src/lib/dedup.js +8 -18
  33. package/src/lib/dedup.test.js +227 -0
  34. package/src/lib/defaults.js +20 -0
  35. package/src/lib/defaults.test.js +217 -0
  36. package/src/lib/drafts-perf.test.js +203 -0
  37. package/src/lib/drafts.test.js +300 -0
  38. package/src/lib/enhancements.test.js +294 -0
  39. package/src/lib/gh.js +60 -0
  40. package/src/lib/safety.test.js +217 -0
  41. package/src/lib/storage.js +298 -0
  42. package/src/lib/templates.test.js +207 -0
@@ -0,0 +1,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
+ })
@@ -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, listIssues } from '../lib/gh.js'
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 = listIssues(repo)
30
+ const issues = ensureFresh(repo, 'issues', { state: 'open' })
30
31
  spinner.stop()
31
32
 
32
33
  if (issues.length === 0) {