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,360 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { Command } from 'commander'
3
+ import { input, select, confirm } from '@inquirer/prompts'
4
+ import { store } from '../lib/config.js'
5
+ import { loadConfig, findRepoRoot } from '../lib/defaults.js'
6
+ import { checkSafety, recordSuccess, recordFailure } from '../lib/safety.js'
7
+ import { checkDuplicate, recordCreation } from '../lib/dedup.js'
8
+ import { listTemplates, loadTemplate, renderTemplate } from '../lib/templates.js'
9
+ import { renderAttribution } from '../lib/attribution.js'
10
+ import { computeFingerprint } from '../lib/dedup.js'
11
+ import { pickRepo } from '../lib/repo-picker.js'
12
+ import { requireAuth, createIssue } from '../lib/gh.js'
13
+ import chalk from 'chalk'
14
+ import ora from 'ora'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Print the circuit breaker state if it is not 'closed'.
22
+ *
23
+ * @param {string} circuitState
24
+ */
25
+ function warnIfCircuitNotClosed(circuitState) {
26
+ if (circuitState === 'closed') return
27
+ const stateColor = circuitState === 'open' ? chalk.red : chalk.yellow
28
+ console.warn(
29
+ chalk.yellow('[safety]') +
30
+ ' Circuit breaker is ' +
31
+ stateColor(circuitState.toUpperCase()) +
32
+ '. Use `ghissue status` to view details.',
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Format a dedup result for display, including link to existing issue when
38
+ * available.
39
+ *
40
+ * @param {{ action: string, reason: string, existingIssue?: { number: number, title: string, url: string } }} result
41
+ * @returns {string}
42
+ */
43
+ function formatDedupResult(result) {
44
+ const parts = [result.reason]
45
+ if (result.existingIssue) {
46
+ const { number, title, url } = result.existingIssue
47
+ if (number) parts.push(` Existing issue: #${number}${title ? ` — ${title}` : ''}`)
48
+ if (url) parts.push(` URL: ${chalk.cyan(url)}`)
49
+ }
50
+ return parts.join('\n')
51
+ }
52
+
53
+ /**
54
+ * Run postCreate shell hook if configured.
55
+ *
56
+ * @param {string} hookCmd
57
+ * @param {{ repo: string, issueNumber: number, issueUrl: string }} ctx
58
+ */
59
+ function runPostCreateHook(hookCmd, ctx) {
60
+ if (!hookCmd) return
61
+ try {
62
+ execSync(hookCmd, {
63
+ env: {
64
+ ...process.env,
65
+ GITISSUES_REPO: ctx.repo,
66
+ GITISSUES_ISSUE_NUMBER: String(ctx.issueNumber),
67
+ GITISSUES_ISSUE_URL: ctx.issueUrl,
68
+ },
69
+ stdio: 'inherit',
70
+ })
71
+ } catch (err) {
72
+ console.warn(chalk.yellow(`[hooks] postCreate hook failed: ${err.message}`))
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // AI enhancement stub
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Enhance issue body with AI. Currently a structured placeholder.
82
+ * Wire up real AI (OpenAI / Anthropic) here.
83
+ *
84
+ * @param {string} title
85
+ * @param {string} description
86
+ * @param {string} templateBody - already-rendered template body to use as context
87
+ * @returns {Promise<string>}
88
+ */
89
+ async function enhanceWithAI(title, description, templateBody) {
90
+ // TODO: wire up real AI (OpenAI / Anthropic)
91
+ // templateBody is provided as context for the AI to refine
92
+ return templateBody
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Command
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export const createCommand = new Command('create')
100
+ .description('Create a new GitHub issue')
101
+ .option('--repo <repo>', 'Repository override (owner/name)')
102
+ .option('--template <name>', 'Template to use (bug, feature, security, performance, refactor)')
103
+ .option('--agent <name>', 'Agent identifier for attribution (default: human)')
104
+ .option('--session <id>', 'Session ID for attribution')
105
+ .option('--title <title>', 'Issue title (skips interactive prompt)')
106
+ .option('--body <body>', 'Issue body / description (skips interactive prompt)')
107
+ .option('--labels <labels>', 'Comma-separated labels to apply')
108
+ .option('--force', 'Skip dedup warnings (still blocks on exact matches)')
109
+ .option('--dry-run', 'Check dedup and safety without creating the issue')
110
+ .action(async (opts) => {
111
+ // -----------------------------------------------------------------------
112
+ // 0. Ensure authenticated
113
+ // -----------------------------------------------------------------------
114
+ requireAuth()
115
+
116
+ // -----------------------------------------------------------------------
117
+ // 1. Resolve repo
118
+ // -----------------------------------------------------------------------
119
+ let repo = opts.repo
120
+ if (!repo) {
121
+ repo = store.get('activeRepo')
122
+ }
123
+ if (!repo) {
124
+ console.log(chalk.yellow('No active repo. Pick one:\n'))
125
+ repo = await pickRepo()
126
+ console.log()
127
+ }
128
+
129
+ // -----------------------------------------------------------------------
130
+ // 2. Load config
131
+ // -----------------------------------------------------------------------
132
+ const repoRoot = findRepoRoot()
133
+ const config = loadConfig(repoRoot)
134
+
135
+ // -----------------------------------------------------------------------
136
+ // 3. Check safety
137
+ // -----------------------------------------------------------------------
138
+ const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
139
+ const safetyResult = checkSafety(repo, agent, config.safety)
140
+
141
+ warnIfCircuitNotClosed(safetyResult.circuitState)
142
+
143
+ if (!safetyResult.allowed) {
144
+ recordFailure(repo, agent, config.safety)
145
+ console.error(chalk.red('Safety check failed:'), safetyResult.reason)
146
+ process.exit(1)
147
+ }
148
+
149
+ // -----------------------------------------------------------------------
150
+ // 4. Get inputs
151
+ // -----------------------------------------------------------------------
152
+ const title = opts.title ?? (await input({ message: 'Issue title' }))
153
+ if (!title || !title.trim()) {
154
+ console.error(chalk.red('Title is required.'))
155
+ process.exit(1)
156
+ }
157
+
158
+ const description =
159
+ opts.body ??
160
+ (await input({
161
+ message: 'Brief description (optional, used for AI enhancement)',
162
+ }))
163
+
164
+ // -----------------------------------------------------------------------
165
+ // 5. Pick template
166
+ // -----------------------------------------------------------------------
167
+ let templateName = opts.template
168
+
169
+ // Fall back to config default
170
+ if (!templateName) {
171
+ templateName = config.templates?.default ?? null
172
+ }
173
+
174
+ // If still unresolved and interactive, let user pick
175
+ if (!templateName) {
176
+ const available = listTemplates(repoRoot)
177
+ // Deduplicate by key (higher-priority sources shadow lower ones)
178
+ const seen = new Set()
179
+ const choices = []
180
+ for (const tpl of available) {
181
+ if (!seen.has(tpl.key)) {
182
+ seen.add(tpl.key)
183
+ choices.push({
184
+ name: `${tpl.name} ${chalk.dim(`(${tpl.source})`)}`,
185
+ value: tpl.key,
186
+ })
187
+ }
188
+ }
189
+
190
+ if (choices.length > 0) {
191
+ templateName = await select({
192
+ message: 'Choose a template',
193
+ choices,
194
+ })
195
+ } else {
196
+ templateName = 'default'
197
+ }
198
+ }
199
+
200
+ let template
201
+ try {
202
+ template = loadTemplate(templateName, repoRoot)
203
+ } catch (err) {
204
+ console.error(chalk.red(`Template error: ${err.message}`))
205
+ process.exit(1)
206
+ }
207
+
208
+ // -----------------------------------------------------------------------
209
+ // 6. Check dedup
210
+ // -----------------------------------------------------------------------
211
+ const session = opts.session ?? null
212
+ const dedupSpinner = ora('Checking for duplicates...').start()
213
+ let dedupResult
214
+ try {
215
+ dedupResult = await checkDuplicate(repo, {
216
+ title,
217
+ body: description,
218
+ agent,
219
+ // No idempotency key by default — callers who need deterministic keys
220
+ // can pass --session and combine with agent + title in future work
221
+ })
222
+ dedupSpinner.stop()
223
+ } catch (err) {
224
+ dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
225
+ dedupResult = { action: 'allow', results: [] }
226
+ }
227
+
228
+ if (dedupResult.action === 'block') {
229
+ recordFailure(repo, agent, config.safety)
230
+ console.error(chalk.red('Duplicate detected — issue not created.'))
231
+ for (const r of dedupResult.results.filter((r) => r.action === 'block')) {
232
+ console.error(formatDedupResult(r))
233
+ }
234
+ process.exit(1)
235
+ }
236
+
237
+ if (dedupResult.action === 'warn' && !opts.force) {
238
+ console.warn(chalk.yellow('\nSimilar issue(s) found:'))
239
+ for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
240
+ console.warn(formatDedupResult(r))
241
+ }
242
+ console.warn()
243
+
244
+ const proceed = await confirm({
245
+ message: 'Create anyway?',
246
+ default: false,
247
+ })
248
+
249
+ if (!proceed) {
250
+ console.log(chalk.dim('Aborted.'))
251
+ process.exit(0)
252
+ }
253
+ }
254
+
255
+ // -----------------------------------------------------------------------
256
+ // 7. Render template
257
+ // -----------------------------------------------------------------------
258
+ const renderedTemplate = renderTemplate(template.body, {
259
+ title,
260
+ description: description || '',
261
+ agent,
262
+ session: session || '',
263
+ date: new Date().toISOString().slice(0, 10),
264
+ repo,
265
+ })
266
+
267
+ // -----------------------------------------------------------------------
268
+ // 8. AI enhancement (stub — passes rendered template as context)
269
+ // -----------------------------------------------------------------------
270
+ let body = renderedTemplate
271
+ const aiSpinner = ora('Enhancing with AI...').start()
272
+ try {
273
+ body = await enhanceWithAI(title, description, renderedTemplate)
274
+ aiSpinner.succeed('Enhanced')
275
+ } catch {
276
+ aiSpinner.warn('AI enhancement unavailable — using template as-is')
277
+ }
278
+
279
+ // -----------------------------------------------------------------------
280
+ // 9. Add attribution
281
+ // -----------------------------------------------------------------------
282
+ const fingerprint = computeFingerprint(title, body)
283
+ const attribution = renderAttribution({
284
+ agent,
285
+ session: session ?? undefined,
286
+ trigger: 'cli-create',
287
+ fingerprint: `sha256:${fingerprint}`,
288
+ })
289
+
290
+ body = body.trimEnd() + '\n\n' + attribution
291
+
292
+ // -----------------------------------------------------------------------
293
+ // 10. Parse labels
294
+ // -----------------------------------------------------------------------
295
+ const labels = opts.labels
296
+ ? opts.labels
297
+ .split(',')
298
+ .map((l) => l.trim())
299
+ .filter(Boolean)
300
+ : []
301
+
302
+ // -----------------------------------------------------------------------
303
+ // Dry run — stop here
304
+ // -----------------------------------------------------------------------
305
+ if (opts.dryRun) {
306
+ console.log(chalk.cyan('\n--- DRY RUN ---'))
307
+ console.log(chalk.bold('Repo: '), repo)
308
+ console.log(chalk.bold('Title: '), title)
309
+ console.log(chalk.bold('Agent: '), agent)
310
+ console.log(chalk.bold('Template:'), `${template.name} (${template.source})`)
311
+ if (labels.length > 0) console.log(chalk.bold('Labels: '), labels.join(', '))
312
+ console.log(chalk.bold('\nBody preview:'))
313
+ console.log(chalk.dim(body))
314
+ console.log(chalk.cyan('--- END DRY RUN (no issue created) ---\n'))
315
+ process.exit(0)
316
+ }
317
+
318
+ // -----------------------------------------------------------------------
319
+ // 11. Create issue
320
+ // -----------------------------------------------------------------------
321
+ const createSpinner = ora('Creating issue...').start()
322
+ let issue
323
+ try {
324
+ issue = await createIssue(repo, { title, body, labels })
325
+ createSpinner.succeed(`Issue created: ${chalk.cyan(issue.url)}`)
326
+ } catch (err) {
327
+ // GitHub API failures are NOT safety failures — do not record to circuit
328
+ createSpinner.fail(`Failed to create issue: ${err.message}`)
329
+ process.exit(1)
330
+ }
331
+
332
+ // -----------------------------------------------------------------------
333
+ // 12. Record success
334
+ // -----------------------------------------------------------------------
335
+ try {
336
+ await recordCreation(repo, {
337
+ title,
338
+ body,
339
+ issueNumber: issue.number,
340
+ agent,
341
+ })
342
+ } catch (err) {
343
+ // Non-fatal — dedup DB write failure shouldn't abort the command
344
+ console.warn(chalk.dim(`[dedup] Failed to record fingerprint: ${err.message}`))
345
+ }
346
+
347
+ recordSuccess(repo, agent)
348
+
349
+ // -----------------------------------------------------------------------
350
+ // Run postCreate hook if configured
351
+ // -----------------------------------------------------------------------
352
+ const hookCmd = config.hooks?.postCreate
353
+ if (hookCmd) {
354
+ runPostCreateHook(hookCmd, {
355
+ repo,
356
+ issueNumber: issue.number,
357
+ issueUrl: issue.url,
358
+ })
359
+ }
360
+ })
@@ -0,0 +1,59 @@
1
+ import { Command } from 'commander'
2
+ import { search } from '@inquirer/prompts'
3
+ import { store } from '../lib/config.js'
4
+ import { pickRepo } from '../lib/repo-picker.js'
5
+ import { requireAuth, listIssues } from '../lib/gh.js'
6
+ import chalk from 'chalk'
7
+ import ora from 'ora'
8
+ import { execSync } from 'child_process'
9
+
10
+ export const listCommand = new Command('list')
11
+ .description('List open issues in the active repository')
12
+ .option('--repo <repo>', 'Repository override (owner/name)')
13
+ .action(async (opts) => {
14
+ requireAuth()
15
+
16
+ let repo = opts.repo
17
+ if (!repo) {
18
+ repo = store.get('activeRepo')
19
+ }
20
+
21
+ if (!repo) {
22
+ console.log(chalk.yellow('No active repo. Pick one:\n'))
23
+ repo = await pickRepo()
24
+ console.log()
25
+ }
26
+
27
+ const spinner = ora('Fetching issues...').start()
28
+ const issues = listIssues(repo)
29
+ spinner.stop()
30
+
31
+ if (issues.length === 0) {
32
+ console.log(chalk.dim('No open issues.'))
33
+ return
34
+ }
35
+
36
+ const selected = await search({
37
+ message: `Open issues in ${chalk.cyan(repo)}`,
38
+ source: (input) => {
39
+ const term = (input || '').toLowerCase()
40
+ return issues
41
+ .filter(
42
+ (i) =>
43
+ i.title.toLowerCase().includes(term) ||
44
+ String(i.number).includes(term),
45
+ )
46
+ .map((i) => ({
47
+ name: `#${i.number} ${i.title}`,
48
+ value: i.url,
49
+ }))
50
+ },
51
+ })
52
+
53
+ // Open in browser
54
+ try {
55
+ execSync(`open "${selected}"`)
56
+ } catch {
57
+ console.log(`\nOpen: ${chalk.cyan(selected)}`)
58
+ }
59
+ })
@@ -0,0 +1,17 @@
1
+ import { Command } from 'commander'
2
+ import { pickRepo } from '../lib/repo-picker.js'
3
+ import { setConfig } from '../lib/config.js'
4
+ import chalk from 'chalk'
5
+
6
+ export const openCommand = new Command('open')
7
+ .description('Set the active repository context')
8
+ .argument('[repo]', 'Repository in owner/name format (e.g. owner/repo)')
9
+ .action(async (repo) => {
10
+ if (repo) {
11
+ setConfig({ activeRepo: repo })
12
+ console.log(chalk.green(`✓ Active repo set to ${repo}`))
13
+ } else {
14
+ const selected = await pickRepo()
15
+ console.log(chalk.green(`\n✓ Active repo set to ${selected}`))
16
+ }
17
+ })
@@ -0,0 +1,166 @@
1
+ import { Command } from 'commander'
2
+ import { store } from '../lib/config.js'
3
+ import { loadConfig, findRepoRoot } from '../lib/defaults.js'
4
+ import { getSafetyStatus, forceReset } from '../lib/safety.js'
5
+ import { getDb } from '../lib/db.js'
6
+ import { getCircuitState, countRecentEvents, probeCircuit } from '../lib/db.js'
7
+ import chalk from 'chalk'
8
+
9
+ /**
10
+ * Query fingerprint count and last creation time for a repo from the DB.
11
+ *
12
+ * @param {string} repo
13
+ * @returns {{ fingerprintCount: number, lastCreatedAt: string|null }}
14
+ */
15
+ function getDbStats(repo) {
16
+ const db = getDb()
17
+
18
+ const fpRow = db
19
+ .prepare(`SELECT COUNT(*) AS cnt FROM fingerprints WHERE repo = ?`)
20
+ .get(repo)
21
+ const fingerprintCount = fpRow ? fpRow.cnt : 0
22
+
23
+ const lastRow = db
24
+ .prepare(
25
+ `SELECT created_at FROM rate_events
26
+ WHERE repo = ? AND event_type = 'create'
27
+ ORDER BY created_at DESC
28
+ LIMIT 1`
29
+ )
30
+ .get(repo)
31
+ const lastCreatedAt = lastRow ? lastRow.created_at : null
32
+
33
+ return { fingerprintCount, lastCreatedAt }
34
+ }
35
+
36
+ /**
37
+ * Format an ISO timestamp as a human-readable "X minutes/hours/days ago" string.
38
+ *
39
+ * @param {string} isoString - SQLite datetime string (UTC, no trailing Z)
40
+ * @returns {string}
41
+ */
42
+ function timeAgo(isoString) {
43
+ // SQLite stores datetime('now') without a trailing 'Z'; add it for correct parsing.
44
+ const ts = new Date(isoString.endsWith('Z') ? isoString : isoString + 'Z')
45
+ const diffMs = Date.now() - ts.getTime()
46
+
47
+ if (diffMs < 0) return 'just now'
48
+
49
+ const seconds = Math.floor(diffMs / 1000)
50
+ if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'} ago`
51
+
52
+ const minutes = Math.floor(diffMs / 60_000)
53
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`
54
+
55
+ const hours = Math.floor(diffMs / 3_600_000)
56
+ if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
57
+
58
+ const days = Math.floor(diffMs / 86_400_000)
59
+ return `${days} day${days === 1 ? '' : 's'} ago`
60
+ }
61
+
62
+ /**
63
+ * Chalk-color a circuit breaker state label.
64
+ *
65
+ * @param {string} state - 'closed' | 'open' | 'half-open'
66
+ * @returns {string}
67
+ */
68
+ function colorCircuitState(state) {
69
+ if (state === 'closed') return chalk.green('closed') + ' ' + chalk.green('✓')
70
+ if (state === 'open') return chalk.red('open') + ' ' + chalk.red('✗')
71
+ return chalk.yellow('half-open') + ' ' + chalk.yellow('~')
72
+ }
73
+
74
+ export const statusCommand = new Command('status')
75
+ .description('Show safety status (circuit breaker + rate limits) for the active repo')
76
+ .option('--repo <repo>', 'Repository override (owner/name)')
77
+ .option('--agent <name>', "Check a specific agent's status (default: 'human')", 'human')
78
+ .option('--reset', 'Force-reset the circuit breaker to closed')
79
+ .action((opts) => {
80
+ let repo = opts.repo
81
+ if (!repo) {
82
+ repo = store.get('activeRepo')
83
+ }
84
+
85
+ if (!repo) {
86
+ console.error(chalk.red('No active repo. Set one with: ghissue open'))
87
+ process.exit(1)
88
+ }
89
+
90
+ const agent = opts.agent
91
+
92
+ // Handle --reset before displaying status
93
+ if (opts.reset) {
94
+ forceReset(repo, agent)
95
+ console.log(chalk.green(`Circuit breaker reset to closed for ${repo} / ${agent}`))
96
+ }
97
+
98
+ const config = loadConfig(findRepoRoot())
99
+ const safetyCfg = config.safety ?? {}
100
+
101
+ const cfg = {
102
+ maxPerHour: safetyCfg.maxPerHour ?? 10,
103
+ burstLimit: safetyCfg.burstLimit ?? 5,
104
+ burstWindowMinutes: safetyCfg.burstWindowMinutes ?? 5,
105
+ globalMaxPerHour: safetyCfg.globalMaxPerHour ?? 30,
106
+ }
107
+
108
+ // Probe the circuit so open → half-open transitions happen before we read state
109
+ probeCircuit(repo, agent)
110
+ const circuit = getCircuitState(repo, agent)
111
+ const circuitState = circuit.status ?? 'closed'
112
+
113
+ const agentHourCount = countRecentEvents(repo, agent, 60)
114
+ const agentBurstCount = countRecentEvents(repo, agent, cfg.burstWindowMinutes)
115
+
116
+ // Global count across all agents
117
+ const db = getDb()
118
+ const cutoff = new Date(Date.now() - 60 * 60_000).toISOString()
119
+ const globalRow = db
120
+ .prepare(
121
+ `SELECT COUNT(*) AS cnt
122
+ FROM rate_events
123
+ WHERE repo = ? AND event_type = 'create' AND created_at >= ?`
124
+ )
125
+ .get(repo, cutoff)
126
+ const globalHourCount = globalRow ? globalRow.cnt : 0
127
+
128
+ const { fingerprintCount, lastCreatedAt } = getDbStats(repo)
129
+
130
+ const agentHourRemaining = Math.max(0, cfg.maxPerHour - agentHourCount)
131
+ const globalHourRemaining = Math.max(0, cfg.globalMaxPerHour - globalHourCount)
132
+
133
+ console.log(`Circuit Breaker: ${colorCircuitState(circuitState)}`)
134
+ if (circuitState === 'open' && circuit.cooldownUntil) {
135
+ const until = new Date(
136
+ circuit.cooldownUntil.endsWith('Z') ? circuit.cooldownUntil : circuit.cooldownUntil + 'Z'
137
+ )
138
+ const diffMs = until - Date.now()
139
+ if (diffMs > 0) {
140
+ const mins = Math.ceil(diffMs / 60_000)
141
+ console.log(chalk.dim(` Cooldown: ${mins} minute${mins === 1 ? '' : 's'} remaining`))
142
+ }
143
+ }
144
+
145
+ console.log(
146
+ `Rate Limit: ${agentHourCount}/${cfg.maxPerHour} per hour` +
147
+ chalk.dim(` (${agentHourRemaining} remaining)`)
148
+ )
149
+ console.log(
150
+ `Burst: ${agentBurstCount}/${cfg.burstLimit} in last ${cfg.burstWindowMinutes} min`
151
+ )
152
+ console.log(
153
+ `Global: ${globalHourCount}/${cfg.globalMaxPerHour} per hour` +
154
+ chalk.dim(` (${globalHourRemaining} remaining)`)
155
+ )
156
+
157
+ console.log()
158
+
159
+ if (lastCreatedAt) {
160
+ console.log(`Last issue created: ${timeAgo(lastCreatedAt)}`)
161
+ } else {
162
+ console.log(chalk.dim('Last issue created: never'))
163
+ }
164
+
165
+ console.log(`Fingerprints stored: ${fingerprintCount}`)
166
+ })