tissues 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -1
- package/package.json +4 -2
- package/src/cli.js +12 -1
- package/src/commands/ai.js +149 -173
- package/src/commands/config.js +143 -69
- package/src/commands/create.js +16 -9
- package/src/commands/create.test.js +381 -0
- package/src/commands/enhancements.js +282 -0
- package/src/commands/flush.test.js +299 -0
- package/src/commands/list.js +3 -2
- package/src/commands/providers.js +347 -0
- package/src/commands/providers.test.js +28 -0
- package/src/commands/storage.js +167 -0
- package/src/commands/sync.js +225 -0
- package/src/daemon/sync.js +189 -0
- package/src/lib/ai/adapters/claude-cli.js +55 -0
- package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
- package/src/lib/ai/adapters/codex-cli.js +77 -0
- package/src/lib/ai/adapters/command.js +23 -13
- package/src/lib/ai/adapters/gemini-cli.js +55 -0
- package/src/lib/ai/adapters/openclaw.js +91 -0
- package/src/lib/ai/agent-actions.js +271 -0
- package/src/lib/ai/agent.js +323 -0
- package/src/lib/ai/body-template.js +15 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhance.js +48 -11
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/index.js +2 -2
- package/src/lib/ai/pipeline.js +20 -2
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +118 -7
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.js +23 -3
- package/src/lib/ai/steps.test.js +335 -0
- package/src/lib/attribution.test.js +64 -0
- package/src/lib/cache.js +408 -0
- package/src/lib/db.js +42 -0
- package/src/lib/dedup.js +44 -48
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +37 -1
- package/src/lib/defaults.test.js +217 -0
- package/src/lib/drafts-perf.test.js +203 -0
- package/src/lib/drafts.test.js +300 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +76 -10
- package/src/lib/safety.test.js +217 -0
- package/src/lib/storage.js +298 -0
- package/src/lib/templates.test.js +207 -0
|
@@ -0,0 +1,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
|
+
})
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for drafts.js
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories — no mocks. Each describe block gets its own
|
|
5
|
+
* isolated tmpDir so tests remain independent and clean up after themselves.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { test, describe, before, after, beforeEach } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
getDraftsDir,
|
|
16
|
+
writeToDrafts,
|
|
17
|
+
readDrafts,
|
|
18
|
+
markComplete,
|
|
19
|
+
markFailed,
|
|
20
|
+
hasPending,
|
|
21
|
+
countPending,
|
|
22
|
+
} from './drafts.js'
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helper: create a temp dir that looks like a repo root
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function makeTempRepo() {
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-drafts-test-'))
|
|
30
|
+
fs.mkdirSync(path.join(dir, '.git'))
|
|
31
|
+
return dir
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// writeToDrafts
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
describe('writeToDrafts()', () => {
|
|
39
|
+
let tmpDir
|
|
40
|
+
|
|
41
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
42
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
43
|
+
|
|
44
|
+
test('creates a JSON file with correct structure', () => {
|
|
45
|
+
const item = writeToDrafts(
|
|
46
|
+
{ repo: 'owner/repo', title: 'My issue', body: 'Some body', labels: ['bug'] },
|
|
47
|
+
tmpDir,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
assert.equal(item.repo, 'owner/repo')
|
|
51
|
+
assert.equal(item.title, 'My issue')
|
|
52
|
+
assert.equal(item.body, 'Some body')
|
|
53
|
+
assert.deepEqual(item.labels, ['bug'])
|
|
54
|
+
assert.equal(item.status, 'pending')
|
|
55
|
+
assert.equal(item.attemptCount, 0)
|
|
56
|
+
assert.ok(item.id, 'id should be set')
|
|
57
|
+
assert.ok(item.createdAt, 'createdAt should be set')
|
|
58
|
+
|
|
59
|
+
const dir = getDraftsDir(tmpDir)
|
|
60
|
+
const filePath = path.join(dir, `${item.id}.json`)
|
|
61
|
+
assert.ok(fs.existsSync(filePath), 'file should exist on disk')
|
|
62
|
+
|
|
63
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
64
|
+
assert.deepEqual(parsed, item)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('uses empty array for labels when omitted', () => {
|
|
68
|
+
const item = writeToDrafts({ repo: 'owner/repo', title: 'No labels', body: 'body' }, tmpDir)
|
|
69
|
+
assert.deepEqual(item.labels, [])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('two concurrent writeToDrafts() calls produce unique IDs', () => {
|
|
73
|
+
const a = writeToDrafts({ repo: 'a/b', title: 'A', body: '' }, tmpDir)
|
|
74
|
+
const b = writeToDrafts({ repo: 'a/b', title: 'B', body: '' }, tmpDir)
|
|
75
|
+
assert.notEqual(a.id, b.id)
|
|
76
|
+
|
|
77
|
+
const dir = getDraftsDir(tmpDir)
|
|
78
|
+
assert.ok(fs.existsSync(path.join(dir, `${a.id}.json`)))
|
|
79
|
+
assert.ok(fs.existsSync(path.join(dir, `${b.id}.json`)))
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// readDrafts
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('readDrafts()', () => {
|
|
88
|
+
let tmpDir
|
|
89
|
+
|
|
90
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
91
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
92
|
+
|
|
93
|
+
test('returns empty array when drafts is empty', () => {
|
|
94
|
+
const items = readDrafts(tmpDir)
|
|
95
|
+
assert.deepEqual(items, [])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('returns items sorted by createdAt ascending', async () => {
|
|
99
|
+
// Write three items with intentional ordering
|
|
100
|
+
const a = writeToDrafts({ repo: 'r/r', title: 'First', body: '' }, tmpDir)
|
|
101
|
+
await new Promise((r) => setTimeout(r, 5)) // tiny gap so timestamps differ
|
|
102
|
+
const b = writeToDrafts({ repo: 'r/r', title: 'Second', body: '' }, tmpDir)
|
|
103
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
104
|
+
const c = writeToDrafts({ repo: 'r/r', title: 'Third', body: '' }, tmpDir)
|
|
105
|
+
|
|
106
|
+
const items = readDrafts(tmpDir)
|
|
107
|
+
const titles = items.map((i) => i.title)
|
|
108
|
+
assert.ok(titles.indexOf('First') < titles.indexOf('Second'), 'First should be before Second')
|
|
109
|
+
assert.ok(titles.indexOf('Second') < titles.indexOf('Third'), 'Second should be before Third')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('ignores non-JSON files gracefully', () => {
|
|
113
|
+
const dir = getDraftsDir(tmpDir)
|
|
114
|
+
fs.writeFileSync(path.join(dir, '.DS_Store'), 'junk')
|
|
115
|
+
fs.writeFileSync(path.join(dir, 'README.txt'), 'not json')
|
|
116
|
+
|
|
117
|
+
// Should not throw and should not include the non-JSON files
|
|
118
|
+
const items = readDrafts(tmpDir)
|
|
119
|
+
const nonDraft = items.filter((i) => !i.id)
|
|
120
|
+
assert.equal(nonDraft.length, 0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('skips corrupt JSON files', () => {
|
|
124
|
+
const dir = getDraftsDir(tmpDir)
|
|
125
|
+
fs.writeFileSync(path.join(dir, 'corrupt.json'), '{ invalid json !!!')
|
|
126
|
+
|
|
127
|
+
// Should not throw
|
|
128
|
+
const items = readDrafts(tmpDir)
|
|
129
|
+
// The corrupt file should be silently skipped
|
|
130
|
+
const corrupt = items.find((i) => i === undefined)
|
|
131
|
+
assert.equal(corrupt, undefined)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// markComplete
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe('markComplete()', () => {
|
|
140
|
+
let tmpDir
|
|
141
|
+
|
|
142
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
143
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
144
|
+
|
|
145
|
+
test('deletes the draft file', () => {
|
|
146
|
+
const item = writeToDrafts({ repo: 'r/r', title: 'To delete', body: '' }, tmpDir)
|
|
147
|
+
const dir = getDraftsDir(tmpDir)
|
|
148
|
+
const filePath = path.join(dir, `${item.id}.json`)
|
|
149
|
+
|
|
150
|
+
assert.ok(fs.existsSync(filePath))
|
|
151
|
+
markComplete(item.id, { issueNumber: 42, issueUrl: 'https://github.com/r/r/issues/42' }, tmpDir)
|
|
152
|
+
assert.ok(!fs.existsSync(filePath), 'file should be deleted')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('is idempotent — does not throw if file already gone', () => {
|
|
156
|
+
assert.doesNotThrow(() => markComplete('nonexistent-id', {}, tmpDir))
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// markFailed
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe('markFailed()', () => {
|
|
165
|
+
let tmpDir
|
|
166
|
+
|
|
167
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
168
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
169
|
+
|
|
170
|
+
test('updates file with status=failed and error message', () => {
|
|
171
|
+
const item = writeToDrafts({ repo: 'r/r', title: 'Fail me', body: '' }, tmpDir)
|
|
172
|
+
markFailed(item.id, 'Connection refused', tmpDir)
|
|
173
|
+
|
|
174
|
+
const dir = getDraftsDir(tmpDir)
|
|
175
|
+
const filePath = path.join(dir, `${item.id}.json`)
|
|
176
|
+
assert.ok(fs.existsSync(filePath), 'file should still exist')
|
|
177
|
+
|
|
178
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
179
|
+
assert.equal(parsed.status, 'failed')
|
|
180
|
+
assert.equal(parsed.error, 'Connection refused')
|
|
181
|
+
assert.ok(parsed.failedAt, 'failedAt should be set')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('is idempotent — does not throw if file is gone', () => {
|
|
185
|
+
assert.doesNotThrow(() => markFailed('nonexistent-id', 'error', tmpDir))
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// hasPending / countPending
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('hasPending() and countPending()', () => {
|
|
194
|
+
let tmpDir
|
|
195
|
+
|
|
196
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
197
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
198
|
+
|
|
199
|
+
test('hasPending() returns false when drafts dir does not exist', () => {
|
|
200
|
+
const freshDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-fresh-'))
|
|
201
|
+
fs.mkdirSync(path.join(freshDir, '.git'))
|
|
202
|
+
try {
|
|
203
|
+
assert.equal(hasPending(freshDir), false)
|
|
204
|
+
} finally {
|
|
205
|
+
fs.rmSync(freshDir, { recursive: true, force: true })
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('countPending() returns 0 when drafts is empty', () => {
|
|
210
|
+
assert.equal(countPending(tmpDir), 0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('hasPending() returns true after writing an item', () => {
|
|
214
|
+
writeToDrafts({ repo: 'r/r', title: 'Pending', body: '' }, tmpDir)
|
|
215
|
+
assert.equal(hasPending(tmpDir), true)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('countPending() counts correctly', () => {
|
|
219
|
+
writeToDrafts({ repo: 'r/r', title: 'One more', body: '' }, tmpDir)
|
|
220
|
+
const count = countPending(tmpDir)
|
|
221
|
+
assert.ok(count >= 2, `Expected at least 2 pending, got ${count}`)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('hasPending() returns false after all items are removed', () => {
|
|
225
|
+
const items = readDrafts(tmpDir)
|
|
226
|
+
for (const item of items) {
|
|
227
|
+
markComplete(item.id, {}, tmpDir)
|
|
228
|
+
}
|
|
229
|
+
assert.equal(hasPending(tmpDir), false)
|
|
230
|
+
assert.equal(countPending(tmpDir), 0)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Full round-trip lifecycle
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe('Full round-trip: write → read → markComplete', () => {
|
|
239
|
+
let tmpDir
|
|
240
|
+
|
|
241
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
242
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
243
|
+
|
|
244
|
+
test('lifecycle ends with empty drafts', async () => {
|
|
245
|
+
// Write three items
|
|
246
|
+
const a = writeToDrafts({ repo: 'lifecycle/repo', title: 'Issue A', body: 'body a' }, tmpDir)
|
|
247
|
+
await new Promise((r) => setTimeout(r, 2))
|
|
248
|
+
const b = writeToDrafts({ repo: 'lifecycle/repo', title: 'Issue B', body: 'body b' }, tmpDir)
|
|
249
|
+
await new Promise((r) => setTimeout(r, 2))
|
|
250
|
+
const c = writeToDrafts({ repo: 'lifecycle/repo', title: 'Issue C', body: 'body c' }, tmpDir)
|
|
251
|
+
|
|
252
|
+
// Read and verify order
|
|
253
|
+
const items = readDrafts(tmpDir)
|
|
254
|
+
assert.equal(items.length, 3)
|
|
255
|
+
assert.equal(items[0].title, 'Issue A')
|
|
256
|
+
assert.equal(items[1].title, 'Issue B')
|
|
257
|
+
assert.equal(items[2].title, 'Issue C')
|
|
258
|
+
|
|
259
|
+
// Complete all
|
|
260
|
+
for (const item of items) {
|
|
261
|
+
markComplete(item.id, {}, tmpDir)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Drafts should be empty
|
|
265
|
+
assert.equal(hasPending(tmpDir), false)
|
|
266
|
+
assert.deepEqual(readDrafts(tmpDir), [])
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Migration: old outbox dir → drafts
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
describe('outbox → drafts migration', () => {
|
|
275
|
+
let tmpDir
|
|
276
|
+
|
|
277
|
+
before(() => { tmpDir = makeTempRepo() })
|
|
278
|
+
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
|
|
279
|
+
|
|
280
|
+
test('auto-renames .tissues/outbox/ to .tissues/drafts/ on first access', () => {
|
|
281
|
+
// Simulate an old outbox directory
|
|
282
|
+
const oldDir = path.join(tmpDir, '.tissues', 'outbox')
|
|
283
|
+
fs.mkdirSync(oldDir, { recursive: true })
|
|
284
|
+
fs.writeFileSync(path.join(oldDir, 'test-issue.json'), JSON.stringify({
|
|
285
|
+
id: 'test-issue',
|
|
286
|
+
repo: 'r/r',
|
|
287
|
+
title: 'Test issue',
|
|
288
|
+
body: '',
|
|
289
|
+
labels: [],
|
|
290
|
+
createdAt: new Date().toISOString(),
|
|
291
|
+
status: 'pending',
|
|
292
|
+
}))
|
|
293
|
+
|
|
294
|
+
// getDraftsDir should migrate
|
|
295
|
+
const dir = getDraftsDir(tmpDir)
|
|
296
|
+
assert.ok(dir.endsWith('.tissues/drafts'), `Expected drafts dir, got ${dir}`)
|
|
297
|
+
assert.ok(!fs.existsSync(oldDir), 'old outbox dir should be gone')
|
|
298
|
+
assert.ok(fs.existsSync(path.join(dir, 'test-issue.json')), 'migrated file should exist')
|
|
299
|
+
})
|
|
300
|
+
})
|