tissues 0.3.0

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.
@@ -0,0 +1,216 @@
1
+ import { createRequire } from 'node:module'
2
+ import { fileURLToPath } from 'node:url'
3
+ import path from 'node:path'
4
+ import fs from 'node:fs'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Package version (read once at module load)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Resolve the package version from the nearest package.json.
12
+ * Falls back to '0.0.0' if unavailable.
13
+ *
14
+ * @returns {string}
15
+ */
16
+ function resolvePackageVersion() {
17
+ try {
18
+ const require = createRequire(import.meta.url)
19
+ // Walk up from this file to find package.json
20
+ let dir = path.dirname(fileURLToPath(import.meta.url))
21
+ const { root } = path.parse(dir)
22
+ while (dir !== root) {
23
+ const pkgPath = path.join(dir, 'package.json')
24
+ if (fs.existsSync(pkgPath)) {
25
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
26
+ if ((pkg.name === 'ghissue' || pkg.name === 'gitissues') && pkg.version) return pkg.version
27
+ }
28
+ dir = path.dirname(dir)
29
+ }
30
+ } catch {
31
+ // ignore
32
+ }
33
+ return '0.0.0'
34
+ }
35
+
36
+ const PKG_VERSION = resolvePackageVersion()
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Return the current UTC timestamp in ISO 8601 format.
44
+ *
45
+ * @returns {string}
46
+ */
47
+ function nowISO() {
48
+ return new Date().toISOString()
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Public API
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * @typedef {object} AttributionOpts
57
+ * @property {string} [agent] - agent identifier (e.g. 'claude-opus-4-6')
58
+ * @property {string} [session] - session or conversation ID
59
+ * @property {number} [pid] - process ID of the creating process
60
+ * @property {string} [model] - AI model used (if any)
61
+ * @property {string} [trigger] - how the issue was created (e.g. 'cli-create')
62
+ * @property {string} [fingerprint] - content fingerprint (sha256:...)
63
+ * @property {string} [idempotencyKey] - deterministic idempotency key
64
+ * @property {number} [risk] - risk score [0,1]
65
+ * @property {number} [complexity] - complexity estimate [0,1]
66
+ * @property {number} [confidence] - AI confidence score [0,1]
67
+ * @property {string[]} [contextTags] - free-form tags for filtering/search
68
+ * @property {string} [createdAt] - override timestamp (defaults to now)
69
+ */
70
+
71
+ /**
72
+ * Build a normalized attribution metadata object from raw options.
73
+ *
74
+ * The returned object includes all provided fields plus automatic defaults
75
+ * (`created_at`, `created_via`, `pid`). Undefined/null fields are omitted.
76
+ *
77
+ * @param {AttributionOpts} opts
78
+ * @returns {object} metadata record
79
+ */
80
+ export function buildAttribution(opts = {}) {
81
+ const {
82
+ agent,
83
+ session,
84
+ pid,
85
+ model,
86
+ trigger,
87
+ fingerprint,
88
+ idempotencyKey,
89
+ risk,
90
+ complexity,
91
+ confidence,
92
+ contextTags,
93
+ createdAt,
94
+ } = opts
95
+
96
+ const meta = {}
97
+
98
+ // Identity
99
+ if (agent != null) meta.agent = String(agent)
100
+ if (session != null) meta.session = String(session)
101
+ if (model != null) meta.model = String(model)
102
+
103
+ // Process info
104
+ meta.pid = pid != null ? Number(pid) : process.pid
105
+ meta.trigger = trigger != null ? String(trigger) : 'cli-create'
106
+
107
+ // Deduplication handles
108
+ if (fingerprint != null) meta.fingerprint = String(fingerprint)
109
+ if (idempotencyKey != null) meta.idempotency_key = String(idempotencyKey)
110
+
111
+ // Scoring (numeric, clamped to [0,1])
112
+ if (risk != null) meta.risk = Math.max(0, Math.min(1, Number(risk)))
113
+ if (complexity != null) meta.complexity = Math.max(0, Math.min(1, Number(complexity)))
114
+ if (confidence != null) meta.confidence = Math.max(0, Math.min(1, Number(confidence)))
115
+
116
+ // Tags
117
+ if (Array.isArray(contextTags) && contextTags.length > 0) {
118
+ meta.context_tags = contextTags.map(String)
119
+ }
120
+
121
+ // Timestamps / versioning
122
+ meta.created_at = createdAt ?? nowISO()
123
+ meta.created_via = `ghissue-cli/${PKG_VERSION}`
124
+
125
+ return meta
126
+ }
127
+
128
+ /**
129
+ * Render attribution metadata as an HTML comment block for inclusion at the
130
+ * bottom of a GitHub issue body.
131
+ *
132
+ * The format is a YAML-like key: value listing inside an HTML comment, which
133
+ * GitHub renders as invisible text. Other tools (including gitissues itself)
134
+ * can parse it back via `parseAttribution`.
135
+ *
136
+ * @example
137
+ * <!-- gitissues-meta
138
+ * agent: claude-opus-4-6
139
+ * session: abc123
140
+ * pid: 12345
141
+ * trigger: cli-create
142
+ * fingerprint: sha256:deadbeef
143
+ * created_at: 2026-02-19T15:30:00Z
144
+ * created_via: gitissues-cli/0.1.0
145
+ * -->
146
+ *
147
+ * @param {AttributionOpts} opts
148
+ * @returns {string}
149
+ */
150
+ export function renderAttribution(opts = {}) {
151
+ const meta = buildAttribution(opts)
152
+ const lines = ['<!-- gitissues-meta']
153
+
154
+ for (const [key, value] of Object.entries(meta)) {
155
+ if (Array.isArray(value)) {
156
+ // Render arrays as comma-separated values on a single line
157
+ lines.push(`${key}: ${value.join(', ')}`)
158
+ } else {
159
+ lines.push(`${key}: ${value}`)
160
+ }
161
+ }
162
+
163
+ lines.push('-->')
164
+ return lines.join('\n')
165
+ }
166
+
167
+ /**
168
+ * Parse a `<!-- gitissues-meta ... -->` attribution block from an issue body.
169
+ *
170
+ * Returns the parsed key/value pairs as a plain object, or `null` if no
171
+ * attribution block is present in `issueBody`.
172
+ *
173
+ * Numeric fields (`pid`, `risk`, `complexity`, `confidence`) are coerced to
174
+ * numbers. The `context_tags` field is split back into an array.
175
+ *
176
+ * @param {string} issueBody - raw GitHub issue body markdown
177
+ * @returns {object|null} parsed metadata, or null if not found
178
+ */
179
+ export function parseAttribution(issueBody) {
180
+ if (!issueBody) return null
181
+
182
+ // Match the comment block (non-greedy, handle CRLF)
183
+ const match = issueBody.match(/<!--\s*gitissues-meta\s*([\s\S]*?)-->/m)
184
+ if (!match) return null
185
+
186
+ const block = match[1]
187
+ const meta = {}
188
+
189
+ for (const line of block.split(/\r?\n/)) {
190
+ const trimmed = line.trim()
191
+ if (!trimmed) continue
192
+
193
+ const colonIdx = trimmed.indexOf(':')
194
+ if (colonIdx === -1) continue
195
+
196
+ const key = trimmed.slice(0, colonIdx).trim()
197
+ const rawValue = trimmed.slice(colonIdx + 1).trim()
198
+
199
+ if (!key) continue
200
+
201
+ // Type coercions
202
+ if (['pid'].includes(key)) {
203
+ const n = Number(rawValue)
204
+ meta[key] = Number.isNaN(n) ? rawValue : n
205
+ } else if (['risk', 'complexity', 'confidence'].includes(key)) {
206
+ const n = parseFloat(rawValue)
207
+ meta[key] = Number.isNaN(n) ? rawValue : n
208
+ } else if (key === 'context_tags') {
209
+ meta[key] = rawValue.split(',').map((t) => t.trim()).filter(Boolean)
210
+ } else {
211
+ meta[key] = rawValue
212
+ }
213
+ }
214
+
215
+ return Object.keys(meta).length > 0 ? meta : null
216
+ }
@@ -0,0 +1,16 @@
1
+ import Conf from 'conf'
2
+
3
+ // Conf handles OS-appropriate storage path + creates dir automatically
4
+ // macOS: ~/Library/Preferences/gitissues-nodejs/config.json
5
+ // Linux: ~/.config/gitissues-nodejs/config.json
6
+ export const store = new Conf({ projectName: 'gitissues' })
7
+
8
+ export function getConfig() {
9
+ return store.store
10
+ }
11
+
12
+ export function setConfig(updates) {
13
+ for (const [key, value] of Object.entries(updates)) {
14
+ store.set(key, value)
15
+ }
16
+ }
package/src/lib/db.js ADDED
@@ -0,0 +1,436 @@
1
+ /**
2
+ * SQLite state database for ghissue CLI.
3
+ *
4
+ * Stores fingerprints (deduplication), idempotency keys, circuit breaker
5
+ * state, and rate-limit event log. Uses better-sqlite3 (synchronous API).
6
+ *
7
+ * Database path: .gitissues/data/gitissues.db (relative to repo root).
8
+ * Created automatically on first use. Users can .gitignore the data dir
9
+ * for personal-only state, or commit it for shared team dedup history.
10
+ */
11
+
12
+ import Database from 'better-sqlite3'
13
+ import path from 'path'
14
+ import fs from 'fs'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Path resolution — repo-local .gitissues/data/gitissues.db
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Find the repo root by walking up from cwd looking for .git.
22
+ * @param {string} [startDir]
23
+ * @returns {string|null}
24
+ */
25
+ function findRepoRootForDb(startDir) {
26
+ let dir = startDir || process.cwd()
27
+ const { root } = path.parse(dir)
28
+ while (dir !== root) {
29
+ if (fs.existsSync(path.join(dir, '.git'))) return dir
30
+ dir = path.dirname(dir)
31
+ }
32
+ return null
33
+ }
34
+
35
+ function getDbPath() {
36
+ const repoRoot = findRepoRootForDb()
37
+ if (repoRoot) {
38
+ return path.join(repoRoot, '.gitissues', 'data', 'gitissues.db')
39
+ }
40
+ // Fallback: user home config dir (outside a git repo)
41
+ const home = process.env.HOME || process.env.USERPROFILE || ''
42
+ return path.join(home, '.config', 'gitissues', 'gitissues.db')
43
+ }
44
+
45
+ const DB_PATH = getDbPath()
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Schema
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const SCHEMA_SQL = `
52
+ CREATE TABLE IF NOT EXISTS fingerprints (
53
+ fingerprint TEXT PRIMARY KEY,
54
+ repo TEXT NOT NULL,
55
+ issue_number INTEGER NOT NULL,
56
+ title TEXT NOT NULL,
57
+ created_by TEXT,
58
+ created_at TEXT DEFAULT (datetime('now')),
59
+ expires_at TEXT
60
+ );
61
+ CREATE INDEX IF NOT EXISTS idx_fp_repo ON fingerprints(repo);
62
+
63
+ CREATE TABLE IF NOT EXISTS idempotency_keys (
64
+ key TEXT PRIMARY KEY,
65
+ repo TEXT NOT NULL,
66
+ issue_number INTEGER NOT NULL,
67
+ created_at TEXT DEFAULT (datetime('now')),
68
+ expires_at TEXT DEFAULT (datetime('now', '+48 hours'))
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS circuit_breaker (
72
+ id TEXT PRIMARY KEY,
73
+ repo TEXT NOT NULL,
74
+ agent TEXT NOT NULL DEFAULT 'human',
75
+ status TEXT NOT NULL DEFAULT 'closed',
76
+ failure_count INTEGER DEFAULT 0,
77
+ last_trip_at TEXT,
78
+ cooldown_until TEXT,
79
+ updated_at TEXT DEFAULT (datetime('now'))
80
+ );
81
+
82
+ CREATE TABLE IF NOT EXISTS rate_events (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ repo TEXT NOT NULL,
85
+ agent TEXT NOT NULL DEFAULT 'human',
86
+ event_type TEXT NOT NULL DEFAULT 'create',
87
+ created_at TEXT DEFAULT (datetime('now'))
88
+ );
89
+ CREATE INDEX IF NOT EXISTS idx_rate_repo_agent ON rate_events(repo, agent, created_at);
90
+
91
+ CREATE TABLE IF NOT EXISTS _meta (
92
+ key TEXT PRIMARY KEY,
93
+ value TEXT NOT NULL
94
+ );
95
+ `
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Lazy singleton
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** @type {import('better-sqlite3').Database | null} */
102
+ let _db = null
103
+
104
+ /**
105
+ * Return the initialised Database instance, creating it on first call.
106
+ * Also runs periodic cleanup (fingerprints + idempotency keys) at most once
107
+ * per hour.
108
+ *
109
+ * @returns {import('better-sqlite3').Database}
110
+ */
111
+ export function getDb() {
112
+ if (_db) return _db
113
+
114
+ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
115
+
116
+ _db = new Database(DB_PATH)
117
+
118
+ // WAL mode for better concurrent read performance
119
+ _db.pragma('journal_mode = WAL')
120
+ _db.pragma('foreign_keys = ON')
121
+
122
+ // Create tables
123
+ _db.exec(SCHEMA_SQL)
124
+
125
+ // Lazy cleanup — run at most once per hour
126
+ _runPeriodicCleanup()
127
+
128
+ return _db
129
+ }
130
+
131
+ function _runPeriodicCleanup() {
132
+ const db = _db
133
+ const now = Date.now()
134
+ const oneHourMs = 60 * 60 * 1000
135
+
136
+ const row = db
137
+ .prepare("SELECT value FROM _meta WHERE key = 'last_cleanup_at'")
138
+ .get()
139
+ const lastCleanup = row ? Number(row.value) : 0
140
+
141
+ if (now - lastCleanup < oneHourMs) return
142
+
143
+ expireOldFingerprints()
144
+ cleanExpiredKeys()
145
+
146
+ db.prepare("INSERT OR REPLACE INTO _meta (key, value) VALUES ('last_cleanup_at', ?)").run(
147
+ String(now),
148
+ )
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Fingerprint operations
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Check whether a fingerprint already exists for a given repo.
157
+ *
158
+ * @param {string} repo - e.g. 'owner/repo'
159
+ * @param {string} fingerprint - SHA-256 hex or similar unique string
160
+ * @returns {{ exists: true, issueNumber: number, title: string } | null}
161
+ */
162
+ export function checkFingerprint(repo, fingerprint) {
163
+ const db = getDb()
164
+ const row = db
165
+ .prepare(
166
+ `SELECT issue_number, title
167
+ FROM fingerprints
168
+ WHERE fingerprint = ? AND repo = ?
169
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`,
170
+ )
171
+ .get(fingerprint, repo)
172
+
173
+ if (!row) return null
174
+ return { exists: true, issueNumber: row.issue_number, title: row.title }
175
+ }
176
+
177
+ /**
178
+ * Persist a new fingerprint entry.
179
+ *
180
+ * @param {string} repo
181
+ * @param {string} fingerprint
182
+ * @param {number} issueNumber
183
+ * @param {string} title
184
+ * @param {string|null} createdBy - agent identifier or null
185
+ */
186
+ export function storeFingerprint(repo, fingerprint, issueNumber, title, createdBy = null) {
187
+ const db = getDb()
188
+ db.prepare(
189
+ `INSERT OR REPLACE INTO fingerprints
190
+ (fingerprint, repo, issue_number, title, created_by)
191
+ VALUES (?, ?, ?, ?, ?)`,
192
+ ).run(fingerprint, repo, issueNumber, title, createdBy)
193
+ }
194
+
195
+ /**
196
+ * Delete fingerprints whose expires_at is in the past.
197
+ */
198
+ export function expireOldFingerprints() {
199
+ const db = getDb()
200
+ db.prepare(
201
+ `DELETE FROM fingerprints WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`,
202
+ ).run()
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Idempotency keys
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * Look up an idempotency key.
211
+ *
212
+ * @param {string} key
213
+ * @returns {number | null} issueNumber if the key exists and is unexpired, else null
214
+ */
215
+ export function checkIdempotencyKey(key) {
216
+ const db = getDb()
217
+ const row = db
218
+ .prepare(
219
+ `SELECT issue_number FROM idempotency_keys
220
+ WHERE key = ? AND expires_at > datetime('now')`,
221
+ )
222
+ .get(key)
223
+
224
+ return row ? row.issue_number : null
225
+ }
226
+
227
+ /**
228
+ * Store a new idempotency key (expires after 48 hours via schema default).
229
+ *
230
+ * @param {string} key
231
+ * @param {string} repo
232
+ * @param {number} issueNumber
233
+ */
234
+ export function storeIdempotencyKey(key, repo, issueNumber) {
235
+ const db = getDb()
236
+ db.prepare(
237
+ `INSERT OR IGNORE INTO idempotency_keys (key, repo, issue_number)
238
+ VALUES (?, ?, ?)`,
239
+ ).run(key, repo, issueNumber)
240
+ }
241
+
242
+ /**
243
+ * Delete idempotency keys that have passed their expires_at.
244
+ */
245
+ export function cleanExpiredKeys() {
246
+ const db = getDb()
247
+ db.prepare(`DELETE FROM idempotency_keys WHERE expires_at < datetime('now')`).run()
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Circuit breaker
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /** @typedef {{ status: 'closed'|'open'|'half-open', failureCount: number, cooldownUntil: string|null }} CircuitState */
255
+
256
+ /**
257
+ * Get current circuit state for a repo+agent pair.
258
+ * Returns a default closed state if no record exists yet.
259
+ *
260
+ * @param {string} repo
261
+ * @param {string} [agent='human']
262
+ * @returns {CircuitState}
263
+ */
264
+ export function getCircuitState(repo, agent = 'human') {
265
+ const db = getDb()
266
+ const id = `${repo}:${agent}`
267
+ const row = db.prepare(`SELECT * FROM circuit_breaker WHERE id = ?`).get(id)
268
+
269
+ if (!row) {
270
+ return { status: 'closed', failureCount: 0, cooldownUntil: null }
271
+ }
272
+
273
+ return {
274
+ status: row.status,
275
+ failureCount: row.failure_count,
276
+ cooldownUntil: row.cooldown_until,
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Trip (open) the circuit breaker and set a cooldown window.
282
+ *
283
+ * @param {string} repo
284
+ * @param {string} [agent='human']
285
+ * @param {number} [cooldownMinutes=30]
286
+ */
287
+ export function tripCircuit(repo, agent = 'human', cooldownMinutes = 30) {
288
+ const db = getDb()
289
+ const id = `${repo}:${agent}`
290
+ db.prepare(
291
+ `INSERT INTO circuit_breaker (id, repo, agent, status, failure_count, last_trip_at, cooldown_until, updated_at)
292
+ VALUES (?, ?, ?, 'open', 1, datetime('now'), datetime('now', ?), datetime('now'))
293
+ ON CONFLICT(id) DO UPDATE SET
294
+ status = 'open',
295
+ failure_count = failure_count + 1,
296
+ last_trip_at = datetime('now'),
297
+ cooldown_until = datetime('now', ?),
298
+ updated_at = datetime('now')`,
299
+ ).run(id, repo, agent, `+${cooldownMinutes} minutes`, `+${cooldownMinutes} minutes`)
300
+ }
301
+
302
+ /**
303
+ * Reset the circuit breaker to closed state.
304
+ *
305
+ * @param {string} repo
306
+ * @param {string} [agent='human']
307
+ */
308
+ export function resetCircuit(repo, agent = 'human') {
309
+ const db = getDb()
310
+ const id = `${repo}:${agent}`
311
+ db.prepare(
312
+ `INSERT INTO circuit_breaker (id, repo, agent, status, failure_count, cooldown_until, updated_at)
313
+ VALUES (?, ?, ?, 'closed', 0, NULL, datetime('now'))
314
+ ON CONFLICT(id) DO UPDATE SET
315
+ status = 'closed',
316
+ failure_count = 0,
317
+ cooldown_until = NULL,
318
+ updated_at = datetime('now')`,
319
+ ).run(id, repo, agent)
320
+ }
321
+
322
+ /**
323
+ * Transition an open circuit to half-open if the cooldown has expired.
324
+ * No-op if the circuit is not open or cooldown has not elapsed.
325
+ *
326
+ * @param {string} repo
327
+ * @param {string} [agent='human']
328
+ * @returns {CircuitState} updated state
329
+ */
330
+ export function probeCircuit(repo, agent = 'human') {
331
+ const db = getDb()
332
+ const id = `${repo}:${agent}`
333
+
334
+ db.prepare(
335
+ `UPDATE circuit_breaker
336
+ SET status = 'half-open',
337
+ updated_at = datetime('now')
338
+ WHERE id = ?
339
+ AND status = 'open'
340
+ AND cooldown_until IS NOT NULL
341
+ AND cooldown_until < datetime('now')`,
342
+ ).run(id)
343
+
344
+ return getCircuitState(repo, agent)
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Rate limiting
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * Record a new rate-limit event (e.g. issue creation).
353
+ *
354
+ * @param {string} repo
355
+ * @param {string} [agent='human']
356
+ * @param {string} [eventType='create']
357
+ */
358
+ export function recordRateEvent(repo, agent = 'human', eventType = 'create') {
359
+ const db = getDb()
360
+ db.prepare(
361
+ `INSERT INTO rate_events (repo, agent, event_type) VALUES (?, ?, ?)`,
362
+ ).run(repo, agent, eventType)
363
+ }
364
+
365
+ /**
366
+ * Count events within a sliding window.
367
+ *
368
+ * @param {string} repo
369
+ * @param {string} [agent='human']
370
+ * @param {number} windowMinutes
371
+ * @returns {number}
372
+ */
373
+ export function countRecentEvents(repo, agent = 'human', windowMinutes = 60) {
374
+ const db = getDb()
375
+ const row = db
376
+ .prepare(
377
+ `SELECT COUNT(*) AS cnt
378
+ FROM rate_events
379
+ WHERE repo = ?
380
+ AND agent = ?
381
+ AND created_at > datetime('now', ?)`,
382
+ )
383
+ .get(repo, agent, `-${windowMinutes} minutes`)
384
+
385
+ return row ? row.cnt : 0
386
+ }
387
+
388
+ /**
389
+ * Check whether a new event is allowed given a rate-limit config.
390
+ *
391
+ * @param {string} repo
392
+ * @param {string} [agent='human']
393
+ * @param {{ maxPerHour?: number, burstLimit?: number, burstWindowMinutes?: number }} [config]
394
+ * @returns {{ allowed: boolean, remaining: number, resetIn: number }}
395
+ * remaining — how many more events are allowed in the current hour window
396
+ * resetIn — seconds until the oldest event in the window falls out
397
+ */
398
+ export function checkRateLimit(
399
+ repo,
400
+ agent = 'human',
401
+ config = {},
402
+ ) {
403
+ const { maxPerHour = 10, burstLimit = 5, burstWindowMinutes = 5 } = config
404
+ const db = getDb()
405
+
406
+ const hourCount = countRecentEvents(repo, agent, 60)
407
+ const burstCount = countRecentEvents(repo, agent, burstWindowMinutes)
408
+
409
+ const hourBlocked = hourCount >= maxPerHour
410
+ const burstBlocked = burstCount >= burstLimit
411
+ const allowed = !hourBlocked && !burstBlocked
412
+
413
+ const remaining = Math.max(0, maxPerHour - hourCount)
414
+
415
+ // Seconds until the oldest hourly event falls out of the 60-minute window
416
+ let resetIn = 0
417
+ if (!allowed) {
418
+ const oldest = db
419
+ .prepare(
420
+ `SELECT created_at FROM rate_events
421
+ WHERE repo = ? AND agent = ?
422
+ AND created_at > datetime('now', '-60 minutes')
423
+ ORDER BY created_at ASC
424
+ LIMIT 1`,
425
+ )
426
+ .get(repo, agent)
427
+
428
+ if (oldest) {
429
+ const oldestMs = new Date(oldest.created_at + 'Z').getTime()
430
+ const windowEndMs = oldestMs + 60 * 60 * 1000
431
+ resetIn = Math.max(0, Math.ceil((windowEndMs - Date.now()) / 1000))
432
+ }
433
+ }
434
+
435
+ return { allowed, remaining, resetIn }
436
+ }