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,205 @@
1
+ import crypto from 'node:crypto'
2
+ import { checkFingerprint, storeFingerprint, checkIdempotencyKey, storeIdempotencyKey } from './db.js'
3
+ import { listIssues } from './gh.js'
4
+
5
+ /** @type {Map<string, { issues: Array, fetchedAt: number }>} */
6
+ const issuesCache = new Map()
7
+ const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
8
+
9
+ /**
10
+ * Normalize text for comparison: lowercase, replace non-alphanumeric with
11
+ * space, collapse multiple spaces, trim.
12
+ *
13
+ * @param {string} text
14
+ * @returns {string}
15
+ */
16
+ export function normalizeText(text) {
17
+ return text
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, ' ')
20
+ .replace(/\s+/g, ' ')
21
+ .trim()
22
+ }
23
+
24
+ /**
25
+ * Compute a SHA-256 content fingerprint from a title and the first 500
26
+ * characters of a body.
27
+ *
28
+ * @param {string} title
29
+ * @param {string} body
30
+ * @returns {string} hex digest
31
+ */
32
+ export function computeFingerprint(title, body) {
33
+ const normalized = normalizeText(title) + '|||' + normalizeText((body ?? '').slice(0, 500))
34
+ return crypto.createHash('sha256').update(normalized).digest('hex')
35
+ }
36
+
37
+ /**
38
+ * Compute a deterministic idempotency key from trigger context.
39
+ *
40
+ * @param {{ agent: string, trigger: string, issueType: string, repo: string }} context
41
+ * @returns {string} hex digest
42
+ */
43
+ export function computeIdempotencyKey({ agent, trigger, issueType, repo }) {
44
+ const raw = [agent, trigger, issueType, repo].join('|')
45
+ return crypto.createHash('sha256').update(raw).digest('hex')
46
+ }
47
+
48
+ /**
49
+ * Compute Levenshtein distance between two strings.
50
+ *
51
+ * @param {string} a
52
+ * @param {string} b
53
+ * @returns {number}
54
+ */
55
+ function levenshteinDistance(a, b) {
56
+ const m = a.length
57
+ const n = b.length
58
+ const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
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]
69
+ }
70
+
71
+ /**
72
+ * Compute similarity between two strings as a value in [0, 1].
73
+ * 1.0 means identical, 0.0 means completely different.
74
+ *
75
+ * @param {string} a
76
+ * @param {string} b
77
+ * @returns {number}
78
+ */
79
+ export function levenshteinSimilarity(a, b) {
80
+ if (a === b) return 1
81
+ const maxLen = Math.max(a.length, b.length)
82
+ if (maxLen === 0) return 1
83
+ return 1 - levenshteinDistance(a, b) / maxLen
84
+ }
85
+
86
+ /**
87
+ * Fetch open issues for a repo, using a session-level cache that expires after
88
+ * 5 minutes.
89
+ *
90
+ * @param {string} repo
91
+ * @returns {Promise<Array>}
92
+ */
93
+ async function getCachedIssues(repo) {
94
+ const cached = issuesCache.get(repo)
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
101
+ }
102
+
103
+ /**
104
+ * @typedef {{ action: 'block' | 'warn' | 'allow', reason: string, existingIssue?: { number: number, title: string, url: string } }} LayerResult
105
+ */
106
+
107
+ /**
108
+ * Run the full deduplication pipeline for a prospective issue.
109
+ *
110
+ * Layers run cheapest-first:
111
+ * 1. Idempotency key (O(1) DB lookup)
112
+ * 2. Content fingerprint (O(1) DB lookup)
113
+ * 3. Fuzzy title match against open GitHub issues (O(n))
114
+ *
115
+ * @param {string} repo e.g. "owner/name"
116
+ * @param {{ title: string, body: string, idempotencyKey?: string, agent?: string }} options
117
+ * @returns {Promise<{ action: 'block' | 'warn' | 'allow', results: LayerResult[] }>}
118
+ */
119
+ export async function checkDuplicate(repo, { title, body, idempotencyKey, agent }) {
120
+ const results = []
121
+
122
+ // Layer 1: idempotency key
123
+ if (idempotencyKey) {
124
+ const existingNumber = await checkIdempotencyKey(idempotencyKey)
125
+ if (existingNumber != null) {
126
+ results.push({
127
+ action: 'block',
128
+ reason: 'Idempotency key matches existing issue',
129
+ existingIssue: { number: existingNumber, title: null, url: null },
130
+ })
131
+ }
132
+ }
133
+
134
+ // Layer 2: content fingerprint
135
+ const fingerprint = computeFingerprint(title, body ?? '')
136
+ const fpResult = await checkFingerprint(repo, fingerprint)
137
+ if (fpResult && fpResult.exists) {
138
+ results.push({
139
+ action: 'block',
140
+ reason: 'Content fingerprint matches existing issue',
141
+ existingIssue: {
142
+ number: fpResult.issueNumber,
143
+ title: fpResult.title,
144
+ url: null,
145
+ },
146
+ })
147
+ }
148
+
149
+ // Layer 3: fuzzy title match
150
+ const normalizedTitle = normalizeText(title)
151
+ const openIssues = await getCachedIssues(repo)
152
+ for (const issue of openIssues) {
153
+ const similarity = levenshteinSimilarity(normalizedTitle, normalizeText(issue.title))
154
+ if (similarity > 0.95) {
155
+ results.push({
156
+ action: 'block',
157
+ reason: `Fuzzy title match (similarity ${(similarity * 100).toFixed(1)}%) with existing issue`,
158
+ existingIssue: {
159
+ number: issue.number,
160
+ title: issue.title,
161
+ url: issue.url,
162
+ },
163
+ })
164
+ break
165
+ } else if (similarity > 0.80) {
166
+ results.push({
167
+ action: 'warn',
168
+ reason: `Fuzzy title is similar (similarity ${(similarity * 100).toFixed(1)}%) to existing issue`,
169
+ existingIssue: {
170
+ number: issue.number,
171
+ title: issue.title,
172
+ url: issue.url,
173
+ },
174
+ })
175
+ break
176
+ }
177
+ }
178
+
179
+ // Determine overall action
180
+ let action = 'allow'
181
+ if (results.some((r) => r.action === 'block')) {
182
+ action = 'block'
183
+ } else if (results.some((r) => r.action === 'warn')) {
184
+ action = 'warn'
185
+ }
186
+
187
+ return { action, results }
188
+ }
189
+
190
+ /**
191
+ * Persist deduplication records after a successful issue creation.
192
+ * Stores both the content fingerprint and, if provided, the idempotency key.
193
+ *
194
+ * @param {string} repo
195
+ * @param {{ title: string, body: string, issueNumber: number, idempotencyKey?: string, agent?: string }} options
196
+ * @returns {Promise<void>}
197
+ */
198
+ export async function recordCreation(repo, { title, body, issueNumber, idempotencyKey, agent }) {
199
+ const fingerprint = computeFingerprint(title, body ?? '')
200
+ await storeFingerprint(repo, fingerprint, issueNumber, title, agent ?? 'unknown')
201
+
202
+ if (idempotencyKey) {
203
+ await storeIdempotencyKey(idempotencyKey, repo, issueNumber)
204
+ }
205
+ }
@@ -0,0 +1,252 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Built-in defaults
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const BUILT_IN_DEFAULTS = {
10
+ // Safety
11
+ safety: {
12
+ maxPerHour: 10,
13
+ burstLimit: 5,
14
+ burstWindowMinutes: 5,
15
+ tripThreshold: 3,
16
+ cooldownMinutes: 30,
17
+ globalMaxPerHour: 30,
18
+ },
19
+
20
+ // Deduplication
21
+ dedup: {
22
+ blockAbove: 0.95, // fuzzy similarity threshold to block
23
+ warnAbove: 0.80, // fuzzy similarity threshold to warn
24
+ fingerprintTTLDays: 90, // expire fingerprints after this many days
25
+ },
26
+
27
+ // Attribution (can be set globally or per-repo)
28
+ attribution: {
29
+ required: false, // if true, --agent is mandatory
30
+ defaultAgent: 'human',
31
+ },
32
+
33
+ // Templates
34
+ templates: {
35
+ dir: '.gitissues/templates', // relative to repo root, or absolute
36
+ default: 'default', // default template name
37
+ },
38
+
39
+ // AI
40
+ ai: {
41
+ enabled: true,
42
+ provider: 'anthropic', // or 'openai'
43
+ model: 'claude-haiku-4-5-20251001',
44
+ },
45
+
46
+ // Hooks (shell commands to run)
47
+ hooks: {
48
+ postCreate: null, // e.g., 'slack-notify.sh'
49
+ postClose: null,
50
+ },
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Env var prefix
55
+ // ---------------------------------------------------------------------------
56
+
57
+ const ENV_PREFIX = 'GITISSUES_'
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Helpers
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Deep-merge two plain objects. `override` values take priority over `base`.
65
+ * Arrays in `override` replace (not append to) arrays in `base`.
66
+ *
67
+ * @param {object} base
68
+ * @param {object} override
69
+ * @returns {object}
70
+ */
71
+ function deepMerge(base, override) {
72
+ if (override === null || typeof override !== 'object' || Array.isArray(override)) {
73
+ return override !== undefined ? override : base
74
+ }
75
+ const result = { ...base }
76
+ for (const key of Object.keys(override)) {
77
+ const baseVal = base[key]
78
+ const overrideVal = override[key]
79
+ if (
80
+ overrideVal !== null &&
81
+ typeof overrideVal === 'object' &&
82
+ !Array.isArray(overrideVal) &&
83
+ baseVal !== null &&
84
+ typeof baseVal === 'object' &&
85
+ !Array.isArray(baseVal)
86
+ ) {
87
+ result[key] = deepMerge(baseVal, overrideVal)
88
+ } else if (overrideVal !== undefined) {
89
+ result[key] = overrideVal
90
+ }
91
+ }
92
+ return result
93
+ }
94
+
95
+ /**
96
+ * Attempt to read and parse a JSON file. Returns `null` if the file does not
97
+ * exist or cannot be parsed.
98
+ *
99
+ * @param {string} filePath
100
+ * @returns {object|null}
101
+ */
102
+ function readJsonFile(filePath) {
103
+ try {
104
+ const raw = fs.readFileSync(filePath, 'utf8')
105
+ return JSON.parse(raw)
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Get the path to the user-level config file.
113
+ *
114
+ * @returns {string}
115
+ */
116
+ function userConfigPath() {
117
+ return path.join(os.homedir(), '.config', 'gitissues', 'config.json')
118
+ }
119
+
120
+ /**
121
+ * Convert a flat `GITISSUES_SECTION_KEY=value` environment variable map into
122
+ * a nested object matching the config shape.
123
+ *
124
+ * Variable names are lowercased and split on `_` to build the path:
125
+ * GITISSUES_SAFETY_MAX_PER_HOUR=20 → { safety: { maxPerHour: 20 } }
126
+ *
127
+ * Simple camelCase reconstruction: after stripping the prefix and splitting on
128
+ * `_`, the first segment is the section; the remaining segments are joined in
129
+ * camelCase.
130
+ *
131
+ * Only variables whose section exists in BUILT_IN_DEFAULTS are included so we
132
+ * do not accidentally pollute the config with unrelated env vars.
133
+ *
134
+ * @returns {object}
135
+ */
136
+ function configFromEnv() {
137
+ const result = {}
138
+ const knownSections = new Set(Object.keys(BUILT_IN_DEFAULTS))
139
+
140
+ for (const [rawKey, rawValue] of Object.entries(process.env)) {
141
+ if (!rawKey.startsWith(ENV_PREFIX)) continue
142
+ const stripped = rawKey.slice(ENV_PREFIX.length).toLowerCase()
143
+ const parts = stripped.split('_')
144
+ if (parts.length < 2) continue
145
+
146
+ const section = parts[0]
147
+ if (!knownSections.has(section)) continue
148
+
149
+ // Join remaining parts as camelCase
150
+ const remainingParts = parts.slice(1)
151
+ const fieldName = remainingParts
152
+ .map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)))
153
+ .join('')
154
+
155
+ // Coerce value type based on built-in defaults
156
+ let value = rawValue
157
+ const builtInSection = BUILT_IN_DEFAULTS[section]
158
+ if (builtInSection && fieldName in builtInSection) {
159
+ const defaultVal = builtInSection[fieldName]
160
+ if (typeof defaultVal === 'number') {
161
+ value = Number(rawValue)
162
+ } else if (typeof defaultVal === 'boolean') {
163
+ value = rawValue === 'true' || rawValue === '1'
164
+ }
165
+ }
166
+
167
+ if (!result[section]) result[section] = {}
168
+ result[section][fieldName] = value
169
+ }
170
+
171
+ return result
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Public API
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Walk up from `startDir` (defaults to `process.cwd()`) looking for a `.git`
180
+ * directory. Returns the first directory that contains `.git`, or `null` if no
181
+ * git repo is found.
182
+ *
183
+ * @param {string} [startDir]
184
+ * @returns {string|null}
185
+ */
186
+ export function findRepoRoot(startDir) {
187
+ let dir = path.resolve(startDir ?? process.cwd())
188
+ const { root } = path.parse(dir)
189
+
190
+ while (true) {
191
+ const gitDir = path.join(dir, '.git')
192
+ if (fs.existsSync(gitDir)) return dir
193
+ if (dir === root) return null
194
+ dir = path.dirname(dir)
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Load merged configuration from all sources in ascending priority order:
200
+ * 1. Built-in defaults (lowest)
201
+ * 2. User-level config (~/.config/gitissues/config.json)
202
+ * 3. Repo-level config (<repoRoot>/.gitissues/config.json)
203
+ * 4. Environment vars (GITISSUES_*)
204
+ * 5. CLI flags (passed as `cliOverrides`, highest)
205
+ *
206
+ * @param {string} [repoRoot] - path to the repo root; auto-detected if omitted
207
+ * @param {object} [cliOverrides] - values from parsed CLI flags (already nested)
208
+ * @returns {object} merged config
209
+ */
210
+ export function loadConfig(repoRoot, cliOverrides) {
211
+ const root = repoRoot ?? findRepoRoot()
212
+
213
+ // 1. Built-in defaults
214
+ let merged = deepMerge({}, BUILT_IN_DEFAULTS)
215
+
216
+ // 2. User-level config
217
+ const userCfg = readJsonFile(userConfigPath())
218
+ if (userCfg) merged = deepMerge(merged, userCfg)
219
+
220
+ // 3. Repo-level config
221
+ if (root) {
222
+ const repoCfg = readJsonFile(path.join(root, '.gitissues', 'config.json'))
223
+ if (repoCfg) merged = deepMerge(merged, repoCfg)
224
+ }
225
+
226
+ // 4. Environment variables
227
+ const envCfg = configFromEnv()
228
+ if (Object.keys(envCfg).length > 0) merged = deepMerge(merged, envCfg)
229
+
230
+ // 5. CLI overrides
231
+ if (cliOverrides && typeof cliOverrides === 'object') {
232
+ merged = deepMerge(merged, cliOverrides)
233
+ }
234
+
235
+ return merged
236
+ }
237
+
238
+ /**
239
+ * Get a specific config value using dot notation.
240
+ *
241
+ * @example
242
+ * getConfigValue('safety.maxPerHour') // 10
243
+ * getConfigValue('ai.model', '/path/to/repo') // 'claude-haiku-4-5-20251001'
244
+ *
245
+ * @param {string} key - dot-separated path (e.g. 'safety.maxPerHour')
246
+ * @param {string} [repoRoot] - optional repo root for repo-level config lookup
247
+ * @returns {*} the resolved value, or `undefined` if not found
248
+ */
249
+ export function getConfigValue(key, repoRoot) {
250
+ const cfg = loadConfig(repoRoot)
251
+ return key.split('.').reduce((obj, part) => (obj != null ? obj[part] : undefined), cfg)
252
+ }