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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/bin/gitissues.js +11 -0
- package/package.json +56 -0
- package/src/cli.js +64 -0
- package/src/commands/auth.js +62 -0
- package/src/commands/create.js +360 -0
- package/src/commands/list.js +59 -0
- package/src/commands/open.js +17 -0
- package/src/commands/status.js +166 -0
- package/src/lib/attribution.js +216 -0
- package/src/lib/config.js +16 -0
- package/src/lib/db.js +436 -0
- package/src/lib/dedup.js +205 -0
- package/src/lib/defaults.js +252 -0
- package/src/lib/gh.js +273 -0
- package/src/lib/repo-picker.js +54 -0
- package/src/lib/safety.js +259 -0
- package/src/lib/templates.js +220 -0
|
@@ -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
|
+
}
|