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
package/src/lib/cache.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local SQLite cache for issues, labels, and repos.
|
|
3
|
+
*
|
|
4
|
+
* Eliminates redundant network calls by storing GitHub data locally.
|
|
5
|
+
* Uses incremental sync for issues (since-based) and full-replace for
|
|
6
|
+
* labels and repos.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getDb } from './db.js'
|
|
10
|
+
import { fetchIssuesApi, fetchLabelsApi, listIssues, listLabels, listRepos } from './gh.js'
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Staleness TTLs (milliseconds)
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const TTL = {
|
|
17
|
+
issues_open: { fresh: 2 * 60 * 1000, stale: 10 * 60 * 1000 },
|
|
18
|
+
issues_closed: { fresh: 24 * 60 * 60 * 1000, stale: 7 * 24 * 60 * 60 * 1000 },
|
|
19
|
+
labels: { fresh: 15 * 60 * 1000, stale: 60 * 60 * 1000 },
|
|
20
|
+
repos: { fresh: 30 * 60 * 1000, stale: 2 * 60 * 60 * 1000 },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Sync metadata CRUD
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get sync metadata for a repo+resource pair.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} repo
|
|
31
|
+
* @param {string} resource
|
|
32
|
+
* @returns {{ lastSyncedAt: string|null, lastCursor: string|null } | null}
|
|
33
|
+
*/
|
|
34
|
+
export function getSyncMeta(repo, resource) {
|
|
35
|
+
const db = getDb()
|
|
36
|
+
const row = db.prepare(
|
|
37
|
+
'SELECT last_synced_at, last_cursor FROM sync_meta WHERE repo = ? AND resource = ?',
|
|
38
|
+
).get(repo, resource)
|
|
39
|
+
if (!row) return null
|
|
40
|
+
return { lastSyncedAt: row.last_synced_at, lastCursor: row.last_cursor }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Update sync metadata for a repo+resource pair.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} repo
|
|
47
|
+
* @param {string} resource
|
|
48
|
+
* @param {{ lastSyncedAt?: string, lastCursor?: string }} meta
|
|
49
|
+
*/
|
|
50
|
+
export function setSyncMeta(repo, resource, meta) {
|
|
51
|
+
const db = getDb()
|
|
52
|
+
db.prepare(`
|
|
53
|
+
INSERT INTO sync_meta (repo, resource, last_synced_at, last_cursor)
|
|
54
|
+
VALUES (?, ?, ?, ?)
|
|
55
|
+
ON CONFLICT(repo, resource) DO UPDATE SET
|
|
56
|
+
last_synced_at = COALESCE(?, last_synced_at),
|
|
57
|
+
last_cursor = COALESCE(?, last_cursor)
|
|
58
|
+
`).run(
|
|
59
|
+
repo, resource,
|
|
60
|
+
meta.lastSyncedAt || null, meta.lastCursor || null,
|
|
61
|
+
meta.lastSyncedAt || null, meta.lastCursor || null,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Freshness checks
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check freshness of cached data for a repo+resource.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} repo
|
|
73
|
+
* @param {string} resource - 'issues' | 'labels' | 'repos'
|
|
74
|
+
* @returns {'fresh' | 'stale' | 'expired' | 'empty'}
|
|
75
|
+
*/
|
|
76
|
+
export function checkFreshness(repo, resource) {
|
|
77
|
+
const meta = getSyncMeta(repo, resource)
|
|
78
|
+
if (!meta || !meta.lastSyncedAt) {
|
|
79
|
+
// Check if we have any rows at all
|
|
80
|
+
const db = getDb()
|
|
81
|
+
let count = 0
|
|
82
|
+
if (resource === 'issues') {
|
|
83
|
+
count = db.prepare('SELECT COUNT(*) as cnt FROM cached_issues WHERE repo = ?').get(repo)?.cnt || 0
|
|
84
|
+
} else if (resource === 'labels') {
|
|
85
|
+
count = db.prepare('SELECT COUNT(*) as cnt FROM cached_labels WHERE repo = ?').get(repo)?.cnt || 0
|
|
86
|
+
} else if (resource === 'repos') {
|
|
87
|
+
count = db.prepare('SELECT COUNT(*) as cnt FROM cached_repos').get()?.cnt || 0
|
|
88
|
+
}
|
|
89
|
+
return count > 0 ? 'expired' : 'empty'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const syncedAt = new Date(meta.lastSyncedAt + 'Z').getTime()
|
|
93
|
+
const age = Date.now() - syncedAt
|
|
94
|
+
const ttlKey = resource === 'issues' ? 'issues_open' : resource
|
|
95
|
+
const ttl = TTL[ttlKey]
|
|
96
|
+
if (!ttl) return 'expired'
|
|
97
|
+
|
|
98
|
+
if (age <= ttl.fresh) return 'fresh'
|
|
99
|
+
if (age <= ttl.stale) return 'stale'
|
|
100
|
+
return 'expired'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Cache read operations
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get cached issues for a repo.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} repo
|
|
111
|
+
* @param {{ state?: string, limit?: number }} [opts]
|
|
112
|
+
* @returns {Array<{ number: number, title: string, body: string|null, state: string, labels: string[], url: string|null, author: string|null, createdAt: string|null, updatedAt: string|null }>}
|
|
113
|
+
*/
|
|
114
|
+
export function getCachedIssues(repo, opts = {}) {
|
|
115
|
+
const db = getDb()
|
|
116
|
+
const { state = 'open', limit = 500 } = opts
|
|
117
|
+
const rows = db.prepare(
|
|
118
|
+
`SELECT number, title, body, state, labels, url, author, created_at, updated_at
|
|
119
|
+
FROM cached_issues
|
|
120
|
+
WHERE repo = ? AND state = ?
|
|
121
|
+
ORDER BY updated_at DESC
|
|
122
|
+
LIMIT ?`,
|
|
123
|
+
).all(repo, state, limit)
|
|
124
|
+
|
|
125
|
+
return rows.map(r => ({
|
|
126
|
+
number: r.number,
|
|
127
|
+
title: r.title,
|
|
128
|
+
body: r.body,
|
|
129
|
+
state: r.state,
|
|
130
|
+
labels: JSON.parse(r.labels || '[]'),
|
|
131
|
+
url: r.url,
|
|
132
|
+
author: r.author,
|
|
133
|
+
createdAt: r.created_at,
|
|
134
|
+
updatedAt: r.updated_at,
|
|
135
|
+
}))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get cached labels for a repo.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} repo
|
|
142
|
+
* @returns {string[]}
|
|
143
|
+
*/
|
|
144
|
+
export function getCachedLabels(repo) {
|
|
145
|
+
const db = getDb()
|
|
146
|
+
const rows = db.prepare(
|
|
147
|
+
'SELECT name FROM cached_labels WHERE repo = ? ORDER BY name',
|
|
148
|
+
).all(repo)
|
|
149
|
+
return rows.map(r => r.name)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get cached repos.
|
|
154
|
+
*
|
|
155
|
+
* @returns {string[]}
|
|
156
|
+
*/
|
|
157
|
+
export function getCachedRepos() {
|
|
158
|
+
const db = getDb()
|
|
159
|
+
const rows = db.prepare(
|
|
160
|
+
'SELECT name_with_owner FROM cached_repos ORDER BY name_with_owner',
|
|
161
|
+
).all()
|
|
162
|
+
return rows.map(r => r.name_with_owner)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Sync operations
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Sync issues for a repo using incremental `since`-based fetching.
|
|
171
|
+
* Uses `gh api` with `since` parameter to fetch only changed issues.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} repo
|
|
174
|
+
* @param {{ force?: boolean }} [opts]
|
|
175
|
+
* @returns {{ synced: number, total: number }}
|
|
176
|
+
*/
|
|
177
|
+
export function syncIssues(repo, opts = {}) {
|
|
178
|
+
const db = getDb()
|
|
179
|
+
const meta = opts.force ? null : getSyncMeta(repo, 'issues')
|
|
180
|
+
const since = meta?.lastCursor || undefined
|
|
181
|
+
|
|
182
|
+
let totalSynced = 0
|
|
183
|
+
let maxUpdatedAt = since || ''
|
|
184
|
+
let page = 1
|
|
185
|
+
|
|
186
|
+
while (true) {
|
|
187
|
+
const issues = fetchIssuesApi(repo, { since, state: 'all', perPage: 100, page })
|
|
188
|
+
if (issues.length === 0) break
|
|
189
|
+
|
|
190
|
+
const upsert = db.prepare(`
|
|
191
|
+
INSERT INTO cached_issues (repo, number, title, body, state, labels, url, author, created_at, updated_at, closed_at, synced_at)
|
|
192
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
193
|
+
ON CONFLICT(repo, number) DO UPDATE SET
|
|
194
|
+
title = excluded.title,
|
|
195
|
+
body = excluded.body,
|
|
196
|
+
state = excluded.state,
|
|
197
|
+
labels = excluded.labels,
|
|
198
|
+
url = excluded.url,
|
|
199
|
+
author = excluded.author,
|
|
200
|
+
updated_at = excluded.updated_at,
|
|
201
|
+
closed_at = excluded.closed_at,
|
|
202
|
+
synced_at = datetime('now')
|
|
203
|
+
`)
|
|
204
|
+
|
|
205
|
+
const upsertMany = db.transaction((items) => {
|
|
206
|
+
for (const issue of items) {
|
|
207
|
+
const labels = JSON.stringify((issue.labels || []).map(l => typeof l === 'string' ? l : l.name))
|
|
208
|
+
upsert.run(
|
|
209
|
+
repo,
|
|
210
|
+
issue.number,
|
|
211
|
+
issue.title,
|
|
212
|
+
issue.body || null,
|
|
213
|
+
issue.state || 'open',
|
|
214
|
+
labels,
|
|
215
|
+
issue.html_url || null,
|
|
216
|
+
issue.user?.login || null,
|
|
217
|
+
issue.created_at || null,
|
|
218
|
+
issue.updated_at || null,
|
|
219
|
+
issue.closed_at || null,
|
|
220
|
+
)
|
|
221
|
+
if (issue.updated_at && issue.updated_at > maxUpdatedAt) {
|
|
222
|
+
maxUpdatedAt = issue.updated_at
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
upsertMany(issues)
|
|
228
|
+
totalSynced += issues.length
|
|
229
|
+
|
|
230
|
+
if (issues.length < 100) break
|
|
231
|
+
page++
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const now = new Date().toISOString().replace('Z', '')
|
|
235
|
+
setSyncMeta(repo, 'issues', {
|
|
236
|
+
lastSyncedAt: now,
|
|
237
|
+
lastCursor: maxUpdatedAt || now,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const total = db.prepare(
|
|
241
|
+
'SELECT COUNT(*) as cnt FROM cached_issues WHERE repo = ?',
|
|
242
|
+
).get(repo)?.cnt || 0
|
|
243
|
+
|
|
244
|
+
return { synced: totalSynced, total }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Sync labels for a repo (full-replace strategy).
|
|
249
|
+
*
|
|
250
|
+
* @param {string} repo
|
|
251
|
+
* @param {{ force?: boolean }} [opts]
|
|
252
|
+
* @returns {{ synced: number }}
|
|
253
|
+
*/
|
|
254
|
+
export function syncLabels(repo, opts = {}) {
|
|
255
|
+
const db = getDb()
|
|
256
|
+
|
|
257
|
+
// Fetch all labels via API
|
|
258
|
+
const allLabels = []
|
|
259
|
+
let page = 1
|
|
260
|
+
while (true) {
|
|
261
|
+
const batch = fetchLabelsApi(repo, { perPage: 100, page })
|
|
262
|
+
allLabels.push(...batch)
|
|
263
|
+
if (batch.length < 100) break
|
|
264
|
+
page++
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Full replace in transaction
|
|
268
|
+
const sync = db.transaction(() => {
|
|
269
|
+
db.prepare('DELETE FROM cached_labels WHERE repo = ?').run(repo)
|
|
270
|
+
const insert = db.prepare(
|
|
271
|
+
`INSERT INTO cached_labels (repo, name, color, description, synced_at)
|
|
272
|
+
VALUES (?, ?, ?, ?, datetime('now'))`,
|
|
273
|
+
)
|
|
274
|
+
for (const label of allLabels) {
|
|
275
|
+
insert.run(repo, label.name, label.color, label.description)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
sync()
|
|
279
|
+
|
|
280
|
+
const now = new Date().toISOString().replace('Z', '')
|
|
281
|
+
setSyncMeta(repo, 'labels', { lastSyncedAt: now })
|
|
282
|
+
|
|
283
|
+
return { synced: allLabels.length }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Sync repos (full-replace strategy).
|
|
288
|
+
*
|
|
289
|
+
* @param {{ force?: boolean }} [opts]
|
|
290
|
+
* @returns {{ synced: number }}
|
|
291
|
+
*/
|
|
292
|
+
export function syncRepos(opts = {}) {
|
|
293
|
+
const db = getDb()
|
|
294
|
+
const repos = listRepos({ limit: 200 })
|
|
295
|
+
|
|
296
|
+
const sync = db.transaction(() => {
|
|
297
|
+
db.prepare('DELETE FROM cached_repos').run()
|
|
298
|
+
const insert = db.prepare(
|
|
299
|
+
`INSERT INTO cached_repos (name_with_owner, synced_at) VALUES (?, datetime('now'))`,
|
|
300
|
+
)
|
|
301
|
+
for (const r of repos) {
|
|
302
|
+
insert.run(r)
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
sync()
|
|
306
|
+
|
|
307
|
+
const now = new Date().toISOString().replace('Z', '')
|
|
308
|
+
setSyncMeta('_global', 'repos', { lastSyncedAt: now })
|
|
309
|
+
|
|
310
|
+
return { synced: repos.length }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// ensureFresh — the main API for consumers
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Ensure cached data is fresh, syncing if needed, and return it.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} repo - repo for issues/labels, ignored for repos
|
|
321
|
+
* @param {string} resource - 'issues' | 'labels' | 'repos'
|
|
322
|
+
* @param {{ acceptStale?: boolean, force?: boolean, state?: string, limit?: number }} [opts]
|
|
323
|
+
* @returns {Array} cached data
|
|
324
|
+
*/
|
|
325
|
+
export function ensureFresh(repo, resource, opts = {}) {
|
|
326
|
+
const { acceptStale = true, force = false } = opts
|
|
327
|
+
const effectiveRepo = resource === 'repos' ? '_global' : repo
|
|
328
|
+
|
|
329
|
+
if (!force) {
|
|
330
|
+
const freshness = checkFreshness(effectiveRepo, resource)
|
|
331
|
+
if (freshness === 'fresh') {
|
|
332
|
+
return readFromCache(repo, resource, opts)
|
|
333
|
+
}
|
|
334
|
+
if (freshness === 'stale' && acceptStale) {
|
|
335
|
+
return readFromCache(repo, resource, opts)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Need to sync
|
|
340
|
+
try {
|
|
341
|
+
if (resource === 'issues') {
|
|
342
|
+
syncIssues(repo, { force })
|
|
343
|
+
} else if (resource === 'labels') {
|
|
344
|
+
syncLabels(repo, { force })
|
|
345
|
+
} else if (resource === 'repos') {
|
|
346
|
+
syncRepos({ force })
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
// Sync failed — return stale data if available, else fall back to live fetch
|
|
350
|
+
const cached = readFromCache(repo, resource, opts)
|
|
351
|
+
if (cached.length > 0) return cached
|
|
352
|
+
|
|
353
|
+
// Last resort: live fetch (original behavior)
|
|
354
|
+
return liveFallback(repo, resource, opts)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return readFromCache(repo, resource, opts)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Read from cache based on resource type.
|
|
362
|
+
* @private
|
|
363
|
+
*/
|
|
364
|
+
function readFromCache(repo, resource, opts) {
|
|
365
|
+
if (resource === 'issues') return getCachedIssues(repo, opts)
|
|
366
|
+
if (resource === 'labels') return getCachedLabels(repo)
|
|
367
|
+
if (resource === 'repos') return getCachedRepos()
|
|
368
|
+
return []
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Live fallback when cache is empty and sync fails.
|
|
373
|
+
* @private
|
|
374
|
+
*/
|
|
375
|
+
function liveFallback(repo, resource, opts) {
|
|
376
|
+
if (resource === 'issues') {
|
|
377
|
+
return listIssues(repo, { limit: opts?.limit || 100 })
|
|
378
|
+
}
|
|
379
|
+
if (resource === 'labels') {
|
|
380
|
+
return listLabels(repo)
|
|
381
|
+
}
|
|
382
|
+
if (resource === 'repos') {
|
|
383
|
+
return listRepos({ limit: 200 })
|
|
384
|
+
}
|
|
385
|
+
return []
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Upsert a single issue into the cache (e.g. after creation).
|
|
390
|
+
*
|
|
391
|
+
* @param {string} repo
|
|
392
|
+
* @param {{ number: number, title: string, url?: string, labels?: string[], state?: string }} issue
|
|
393
|
+
*/
|
|
394
|
+
export function upsertCachedIssue(repo, issue) {
|
|
395
|
+
const db = getDb()
|
|
396
|
+
const labels = JSON.stringify(issue.labels || [])
|
|
397
|
+
db.prepare(`
|
|
398
|
+
INSERT INTO cached_issues (repo, number, title, state, labels, url, synced_at, updated_at)
|
|
399
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
400
|
+
ON CONFLICT(repo, number) DO UPDATE SET
|
|
401
|
+
title = excluded.title,
|
|
402
|
+
state = COALESCE(excluded.state, state),
|
|
403
|
+
labels = excluded.labels,
|
|
404
|
+
url = COALESCE(excluded.url, url),
|
|
405
|
+
synced_at = datetime('now'),
|
|
406
|
+
updated_at = datetime('now')
|
|
407
|
+
`).run(repo, issue.number, issue.title, issue.state || 'open', labels, issue.url || null)
|
|
408
|
+
}
|
package/src/lib/db.js
CHANGED
|
@@ -42,6 +42,8 @@ function getDbPath() {
|
|
|
42
42
|
return path.join(home, '.config', 'tissues', 'tissues.db')
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export { getDbPath }
|
|
46
|
+
|
|
45
47
|
const DB_PATH = getDbPath()
|
|
46
48
|
|
|
47
49
|
// ---------------------------------------------------------------------------
|
|
@@ -92,6 +94,46 @@ CREATE TABLE IF NOT EXISTS _meta (
|
|
|
92
94
|
key TEXT PRIMARY KEY,
|
|
93
95
|
value TEXT NOT NULL
|
|
94
96
|
);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS cached_issues (
|
|
99
|
+
repo TEXT NOT NULL,
|
|
100
|
+
number INTEGER NOT NULL,
|
|
101
|
+
title TEXT NOT NULL,
|
|
102
|
+
body TEXT,
|
|
103
|
+
state TEXT NOT NULL DEFAULT 'open',
|
|
104
|
+
labels TEXT DEFAULT '[]',
|
|
105
|
+
url TEXT,
|
|
106
|
+
author TEXT,
|
|
107
|
+
created_at TEXT,
|
|
108
|
+
updated_at TEXT,
|
|
109
|
+
closed_at TEXT,
|
|
110
|
+
synced_at TEXT DEFAULT (datetime('now')),
|
|
111
|
+
PRIMARY KEY (repo, number)
|
|
112
|
+
);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_ci_repo_state ON cached_issues(repo, state);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_ci_repo_updated ON cached_issues(repo, updated_at);
|
|
115
|
+
|
|
116
|
+
CREATE TABLE IF NOT EXISTS cached_labels (
|
|
117
|
+
repo TEXT NOT NULL,
|
|
118
|
+
name TEXT NOT NULL,
|
|
119
|
+
color TEXT,
|
|
120
|
+
description TEXT,
|
|
121
|
+
synced_at TEXT DEFAULT (datetime('now')),
|
|
122
|
+
PRIMARY KEY (repo, name)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
CREATE TABLE IF NOT EXISTS cached_repos (
|
|
126
|
+
name_with_owner TEXT PRIMARY KEY,
|
|
127
|
+
synced_at TEXT DEFAULT (datetime('now'))
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
CREATE TABLE IF NOT EXISTS sync_meta (
|
|
131
|
+
repo TEXT NOT NULL,
|
|
132
|
+
resource TEXT NOT NULL,
|
|
133
|
+
last_synced_at TEXT,
|
|
134
|
+
last_cursor TEXT,
|
|
135
|
+
PRIMARY KEY (repo, resource)
|
|
136
|
+
);
|
|
95
137
|
`
|
|
96
138
|
|
|
97
139
|
// ---------------------------------------------------------------------------
|
package/src/lib/dedup.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import { checkFingerprint, storeFingerprint, checkIdempotencyKey, storeIdempotencyKey } from './db.js'
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
/** @type {Map<string, { issues: Array, fetchedAt: number }>} */
|
|
6
|
-
const issuesCache = new Map()
|
|
7
|
-
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
3
|
+
import { ensureFresh, upsertCachedIssue } from './cache.js'
|
|
8
4
|
|
|
9
5
|
/**
|
|
10
6
|
* Normalize text for comparison: lowercase, replace non-alphanumeric with
|
|
@@ -86,20 +82,13 @@ export function jaccardSimilarity(a, b) {
|
|
|
86
82
|
}
|
|
87
83
|
|
|
88
84
|
/**
|
|
89
|
-
*
|
|
90
|
-
* 5 minutes.
|
|
85
|
+
* Get open issues for a repo from the SQLite cache, syncing if stale.
|
|
91
86
|
*
|
|
92
87
|
* @param {string} repo
|
|
93
88
|
* @returns {Promise<Array>}
|
|
94
89
|
*/
|
|
95
90
|
async function getCachedIssues(repo) {
|
|
96
|
-
|
|
97
|
-
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
98
|
-
return cached.issues
|
|
99
|
-
}
|
|
100
|
-
const issues = listIssues(repo)
|
|
101
|
-
issuesCache.set(repo, { issues, fetchedAt: Date.now() })
|
|
102
|
-
return issues
|
|
91
|
+
return ensureFresh(repo, 'issues', { state: 'open', acceptStale: true })
|
|
103
92
|
}
|
|
104
93
|
|
|
105
94
|
/**
|
|
@@ -232,9 +221,10 @@ export async function recordCreation(repo, { title, body, issueNumber, url, idem
|
|
|
232
221
|
await storeIdempotencyKey(idempotencyKey, repo, issueNumber)
|
|
233
222
|
}
|
|
234
223
|
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
224
|
+
// Upsert into SQLite cache so back-to-back runs catch duplicates immediately
|
|
225
|
+
try {
|
|
226
|
+
upsertCachedIssue(repo, { number: issueNumber, title, url: url ?? null })
|
|
227
|
+
} catch {
|
|
228
|
+
// Non-fatal — cache upsert failure shouldn't abort
|
|
239
229
|
}
|
|
240
230
|
}
|