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
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
|
|
@@ -45,59 +41,54 @@ export function computeIdempotencyKey({ agent, trigger, issueType, repo }) {
|
|
|
45
41
|
return crypto.createHash('sha256').update(raw).digest('hex')
|
|
46
42
|
}
|
|
47
43
|
|
|
44
|
+
const STOP_WORDS = new Set([
|
|
45
|
+
'a','an','the','in','on','at','to','for','of','with','by','from',
|
|
46
|
+
'is','are','was','were','be','been','being','has','have','had',
|
|
47
|
+
'do','does','did','will','would','could','should','can','may',
|
|
48
|
+
'not','no','but','or','and','if','then','so','as','it','its',
|
|
49
|
+
'this','that','we','our','i','my','you','your','they','their',
|
|
50
|
+
])
|
|
51
|
+
|
|
48
52
|
/**
|
|
49
|
-
*
|
|
53
|
+
* Tokenize text: normalize, split on spaces, remove stop words.
|
|
50
54
|
*
|
|
51
|
-
* @param {string}
|
|
52
|
-
* @
|
|
53
|
-
* @returns {number}
|
|
55
|
+
* @param {string} text
|
|
56
|
+
* @returns {string[]}
|
|
54
57
|
*/
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
for (let j = 0; j <= n; j++) dp[0][j] = j
|
|
60
|
-
for (let i = 1; i <= m; i++) {
|
|
61
|
-
for (let j = 1; j <= n; j++) {
|
|
62
|
-
dp[i][j] =
|
|
63
|
-
a[i - 1] === b[j - 1]
|
|
64
|
-
? dp[i - 1][j - 1]
|
|
65
|
-
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return dp[m][n]
|
|
58
|
+
function tokenize(text) {
|
|
59
|
+
return normalizeText(text)
|
|
60
|
+
.split(' ')
|
|
61
|
+
.filter(w => w.length > 0 && !STOP_WORDS.has(w))
|
|
69
62
|
}
|
|
70
63
|
|
|
71
64
|
/**
|
|
72
|
-
* Compute similarity between two strings
|
|
73
|
-
*
|
|
65
|
+
* Compute Jaccard similarity between two strings based on token sets.
|
|
66
|
+
* Tokens are words with stop words removed.
|
|
67
|
+
* Returns a value in [0, 1]: 1.0 = identical token sets, 0.0 = no overlap.
|
|
74
68
|
*
|
|
75
69
|
* @param {string} a
|
|
76
70
|
* @param {string} b
|
|
77
71
|
* @returns {number}
|
|
78
72
|
*/
|
|
79
|
-
export function
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
|
|
73
|
+
export function jaccardSimilarity(a, b) {
|
|
74
|
+
const setA = new Set(tokenize(a))
|
|
75
|
+
const setB = new Set(tokenize(b))
|
|
76
|
+
if (setA.size === 0 && setB.size === 0) return 1
|
|
77
|
+
if (setA.size === 0 || setB.size === 0) return 0
|
|
78
|
+
let intersection = 0
|
|
79
|
+
for (const w of setA) if (setB.has(w)) intersection++
|
|
80
|
+
const union = new Set([...setA, ...setB]).size
|
|
81
|
+
return intersection / union
|
|
84
82
|
}
|
|
85
83
|
|
|
86
84
|
/**
|
|
87
|
-
*
|
|
88
|
-
* 5 minutes.
|
|
85
|
+
* Get open issues for a repo from the SQLite cache, syncing if stale.
|
|
89
86
|
*
|
|
90
87
|
* @param {string} repo
|
|
91
88
|
* @returns {Promise<Array>}
|
|
92
89
|
*/
|
|
93
90
|
async function getCachedIssues(repo) {
|
|
94
|
-
|
|
95
|
-
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
96
|
-
return cached.issues
|
|
97
|
-
}
|
|
98
|
-
const issues = listIssues(repo)
|
|
99
|
-
issuesCache.set(repo, { issues, fetchedAt: Date.now() })
|
|
100
|
-
return issues
|
|
91
|
+
return ensureFresh(repo, 'issues', { state: 'open', acceptStale: true })
|
|
101
92
|
}
|
|
102
93
|
|
|
103
94
|
/**
|
|
@@ -167,12 +158,16 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
|
|
|
167
158
|
})
|
|
168
159
|
}
|
|
169
160
|
|
|
170
|
-
// Layer 3: fuzzy title match
|
|
171
|
-
const
|
|
161
|
+
// Layer 3: fuzzy title match (token-set Jaccard)
|
|
162
|
+
const newTokens = tokenize(title)
|
|
163
|
+
const isShortTitle = newTokens.length <= 2
|
|
172
164
|
const openIssues = await getCachedIssues(repo)
|
|
173
165
|
for (const issue of openIssues) {
|
|
174
|
-
const similarity =
|
|
175
|
-
|
|
166
|
+
const similarity = jaccardSimilarity(title, issue.title)
|
|
167
|
+
// Short-title guard: ≤2 meaningful tokens require exact match to block, ≥0.80 to warn
|
|
168
|
+
const blockThreshold = isShortTitle ? 1.0 : 0.80
|
|
169
|
+
const warnThreshold = isShortTitle ? 0.80 : 0.50
|
|
170
|
+
if (similarity >= blockThreshold) {
|
|
176
171
|
results.push({
|
|
177
172
|
action: 'block',
|
|
178
173
|
reason: `Fuzzy title match (similarity ${(similarity * 100).toFixed(1)}%) with existing issue`,
|
|
@@ -183,7 +178,7 @@ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent
|
|
|
183
178
|
},
|
|
184
179
|
})
|
|
185
180
|
break
|
|
186
|
-
} else if (similarity >=
|
|
181
|
+
} else if (similarity >= warnThreshold) {
|
|
187
182
|
results.push({
|
|
188
183
|
action: 'warn',
|
|
189
184
|
reason: `Fuzzy title is similar (similarity ${(similarity * 100).toFixed(1)}%) to existing issue`,
|
|
@@ -226,9 +221,10 @@ export async function recordCreation(repo, { title, body, issueNumber, url, idem
|
|
|
226
221
|
await storeIdempotencyKey(idempotencyKey, repo, issueNumber)
|
|
227
222
|
}
|
|
228
223
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
233
229
|
}
|
|
234
230
|
}
|