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,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
|
+
})
|