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/src/lib/gh.js ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Thin wrapper around the `gh` CLI.
3
+ * All GitHub operations go through `gh` — auth, API calls, issue creation.
4
+ */
5
+
6
+ import { execFileSync, execSync } from 'node:child_process'
7
+ import chalk from 'chalk'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // gh availability
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Check if `gh` is installed and return its path. Exits with install
15
+ * instructions if not found.
16
+ */
17
+ export function requireGh() {
18
+ try {
19
+ const path = execFileSync('which', ['gh'], {
20
+ encoding: 'utf8',
21
+ stdio: ['ignore', 'pipe', 'ignore'],
22
+ }).trim()
23
+ return path
24
+ } catch {
25
+ console.error(chalk.red('\n gh CLI is required but not installed.\n'))
26
+ console.error(' Install it with one of:')
27
+ console.error(chalk.cyan(' brew install gh ') + chalk.dim('# macOS'))
28
+ console.error(chalk.cyan(' sudo apt install gh ') + chalk.dim('# Debian/Ubuntu'))
29
+ console.error(chalk.cyan(' winget install GitHub.cli ') + chalk.dim('# Windows'))
30
+ console.error(chalk.cyan(' conda install -c conda-forge gh ') + chalk.dim('# conda'))
31
+ console.error()
32
+ console.error(chalk.dim(' More: https://cli.github.com'))
33
+ console.error()
34
+ process.exit(1)
35
+ }
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Auth
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Get the active auth token from `gh`. Returns null if not authenticated.
44
+ * @returns {string | null}
45
+ */
46
+ export function getToken() {
47
+ // Env vars take priority (CI/CD)
48
+ const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
49
+ if (envToken) return envToken
50
+
51
+ try {
52
+ return execFileSync('gh', ['auth', 'token'], {
53
+ encoding: 'utf8',
54
+ stdio: ['ignore', 'pipe', 'ignore'],
55
+ }).trim() || null
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Require a valid auth token. Exits with instructions if not authenticated.
63
+ * @returns {string}
64
+ */
65
+ export function requireAuth() {
66
+ const token = getToken()
67
+ if (token) return token
68
+
69
+ console.error(chalk.red('\n Not authenticated with GitHub.\n'))
70
+ console.error(' Run:')
71
+ console.error(chalk.cyan(' gh auth login'))
72
+ console.error()
73
+ process.exit(1)
74
+ }
75
+
76
+ /**
77
+ * Get auth status from `gh auth status`. Returns parsed account info.
78
+ * @returns {{ accounts: Array<{ login: string, active: boolean, scopes: string[], tokenType: string }> }}
79
+ */
80
+ export function getAuthStatus() {
81
+ try {
82
+ const raw = execFileSync('gh', ['auth', 'status', '--json'], {
83
+ encoding: 'utf8',
84
+ stdio: ['ignore', 'pipe', 'ignore'],
85
+ }).trim()
86
+ return JSON.parse(raw)
87
+ } catch {
88
+ // Older gh versions may not support --json, fall back to text parsing
89
+ try {
90
+ const text = execFileSync('gh', ['auth', 'status'], {
91
+ encoding: 'utf8',
92
+ stdio: ['ignore', 'pipe', 'pipe'],
93
+ })
94
+ return { raw: text }
95
+ } catch {
96
+ return { accounts: [] }
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Get the authenticated user's login.
103
+ * @returns {string | null}
104
+ */
105
+ export function getAuthenticatedUser() {
106
+ try {
107
+ const raw = execFileSync('gh', ['api', 'user', '--jq', '.login'], {
108
+ encoding: 'utf8',
109
+ stdio: ['ignore', 'pipe', 'ignore'],
110
+ }).trim()
111
+ return raw || null
112
+ } catch {
113
+ return null
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Check if the current token has the required scopes for issue creation.
119
+ * @returns {{ ok: boolean, scopes: string[], missing: string[] }}
120
+ */
121
+ export function checkScopes() {
122
+ try {
123
+ // gh api returns headers including X-OAuth-Scopes
124
+ const raw = execFileSync('gh', ['api', '-i', 'user'], {
125
+ encoding: 'utf8',
126
+ stdio: ['ignore', 'pipe', 'ignore'],
127
+ })
128
+
129
+ const scopeLine = raw.split('\n').find((l) => l.toLowerCase().startsWith('x-oauth-scopes:'))
130
+ if (!scopeLine) {
131
+ // Fine-grained PATs don't have X-OAuth-Scopes header — they use permissions
132
+ // If the API call succeeded, the token is valid
133
+ return { ok: true, scopes: ['fine-grained'], missing: [] }
134
+ }
135
+
136
+ const scopes = scopeLine
137
+ .replace(/^x-oauth-scopes:\s*/i, '')
138
+ .split(',')
139
+ .map((s) => s.trim())
140
+ .filter(Boolean)
141
+
142
+ const required = ['repo']
143
+ const missing = required.filter(
144
+ (r) => !scopes.includes(r) && !scopes.includes('admin:org'), // admin:org implies repo
145
+ )
146
+
147
+ return { ok: missing.length === 0, scopes, missing }
148
+ } catch {
149
+ return { ok: false, scopes: [], missing: ['repo'] }
150
+ }
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Auth commands (delegate to gh)
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Run `gh auth login` interactively.
159
+ */
160
+ export function authLogin() {
161
+ execSync('gh auth login', { stdio: 'inherit' })
162
+ }
163
+
164
+ /**
165
+ * Run `gh auth status` and print to stdout.
166
+ */
167
+ export function authStatus() {
168
+ try {
169
+ execSync('gh auth status', { stdio: 'inherit' })
170
+ } catch {
171
+ // gh auth status exits non-zero when not logged in, output already printed
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Run `gh auth switch` interactively.
177
+ */
178
+ export function authSwitch() {
179
+ execSync('gh auth switch', { stdio: 'inherit' })
180
+ }
181
+
182
+ /**
183
+ * Run `gh auth logout` interactively.
184
+ */
185
+ export function authLogout() {
186
+ execSync('gh auth logout', { stdio: 'inherit' })
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // GitHub API
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Create an issue via `gh issue create`.
195
+ * @param {string} repo - owner/name
196
+ * @param {{ title: string, body: string, labels?: string[] }} opts
197
+ * @returns {{ number: number, url: string }}
198
+ */
199
+ export function createIssue(repo, { title, body, labels }) {
200
+ const args = [
201
+ 'issue', 'create',
202
+ '--repo', repo,
203
+ '--title', title,
204
+ '--body', body,
205
+ ]
206
+
207
+ if (labels?.length) {
208
+ args.push('--label', labels.join(','))
209
+ }
210
+
211
+ const output = execFileSync('gh', args, {
212
+ encoding: 'utf8',
213
+ stdio: ['ignore', 'pipe', 'pipe'],
214
+ }).trim()
215
+
216
+ // gh issue create prints the URL on success
217
+ const url = output.trim()
218
+ const numberMatch = url.match(/\/issues\/(\d+)$/)
219
+ const number = numberMatch ? parseInt(numberMatch[1], 10) : 0
220
+
221
+ return { number, url }
222
+ }
223
+
224
+ /**
225
+ * List open issues for a repo.
226
+ * @param {string} repo - owner/name
227
+ * @param {{ limit?: number }} [opts]
228
+ * @returns {Array<{ number: number, title: string, url: string, labels: string[], createdAt: string }>}
229
+ */
230
+ export function listIssues(repo, opts = {}) {
231
+ const limit = opts.limit ?? 100
232
+ const raw = execFileSync('gh', [
233
+ 'issue', 'list',
234
+ '--repo', repo,
235
+ '--state', 'open',
236
+ '--limit', String(limit),
237
+ '--json', 'number,title,url,labels,createdAt',
238
+ ], {
239
+ encoding: 'utf8',
240
+ stdio: ['ignore', 'pipe', 'ignore'],
241
+ }).trim()
242
+
243
+ if (!raw) return []
244
+ const issues = JSON.parse(raw)
245
+ return issues.map((i) => ({
246
+ number: i.number,
247
+ title: i.title,
248
+ url: i.url,
249
+ labels: (i.labels || []).map((l) => l.name),
250
+ createdAt: i.createdAt,
251
+ }))
252
+ }
253
+
254
+ /**
255
+ * List repos the user has access to.
256
+ * @param {{ limit?: number }} [opts]
257
+ * @returns {string[]} - array of "owner/name"
258
+ */
259
+ export function listRepos(opts = {}) {
260
+ const limit = opts.limit ?? 100
261
+ const raw = execFileSync('gh', [
262
+ 'repo', 'list',
263
+ '--limit', String(limit),
264
+ '--json', 'nameWithOwner',
265
+ '--jq', '.[].nameWithOwner',
266
+ ], {
267
+ encoding: 'utf8',
268
+ stdio: ['ignore', 'pipe', 'ignore'],
269
+ }).trim()
270
+
271
+ if (!raw) return []
272
+ return raw.split('\n').filter(Boolean)
273
+ }
@@ -0,0 +1,54 @@
1
+ import { search } from '@inquirer/prompts'
2
+ import { store, setConfig } from './config.js'
3
+ import { listRepos } from './gh.js'
4
+ import ora from 'ora'
5
+
6
+ const USAGE_KEY = 'repoUsage'
7
+
8
+ function getRepoUsage() {
9
+ return store.get(USAGE_KEY) || {}
10
+ }
11
+
12
+ function trackRepoUsage(repo) {
13
+ const usage = getRepoUsage()
14
+ usage[repo] = { count: (usage[repo]?.count || 0) + 1, lastUsed: Date.now() }
15
+ store.set(USAGE_KEY, usage)
16
+ }
17
+
18
+ function sortByUsage(repos, usage) {
19
+ return [...repos].sort((a, b) => {
20
+ const ua = usage[a] || { count: 0, lastUsed: 0 }
21
+ const ub = usage[b] || { count: 0, lastUsed: 0 }
22
+ if (ub.count !== ua.count) return ub.count - ua.count
23
+ return ub.lastUsed - ua.lastUsed
24
+ })
25
+ }
26
+
27
+ export async function pickRepo() {
28
+ const spinner = ora('Fetching repos...').start()
29
+ let repos
30
+ try {
31
+ repos = listRepos()
32
+ spinner.stop()
33
+ } catch (err) {
34
+ spinner.fail('Failed to fetch repos')
35
+ throw err
36
+ }
37
+
38
+ const usage = getRepoUsage()
39
+ const sorted = sortByUsage(repos, usage)
40
+
41
+ const repo = await search({
42
+ message: 'Select a repository',
43
+ source: (input) => {
44
+ const term = (input || '').toLowerCase()
45
+ return sorted
46
+ .filter((r) => r.toLowerCase().includes(term))
47
+ .map((r) => ({ name: r, value: r }))
48
+ },
49
+ })
50
+
51
+ trackRepoUsage(repo)
52
+ setConfig({ activeRepo: repo })
53
+ return repo
54
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Circuit breaker and rate limiter for ghissue CLI.
3
+ *
4
+ * Prevents runaway issue creation loops by:
5
+ * - Rate limiting per agent (hourly + burst window)
6
+ * - Rate limiting globally across all agents
7
+ * - Circuit breaker that trips after repeated failures and enters cooldown
8
+ *
9
+ * All functions are synchronous (better-sqlite3 is sync).
10
+ */
11
+
12
+ import {
13
+ getCircuitState,
14
+ tripCircuit,
15
+ resetCircuit,
16
+ probeCircuit,
17
+ recordRateEvent,
18
+ countRecentEvents,
19
+ getDb,
20
+ } from './db.js'
21
+
22
+ const DEFAULT_SAFETY_CONFIG = {
23
+ // Rate limits
24
+ maxPerHour: 10, // max issues per hour per agent
25
+ burstLimit: 5, // max issues in burst window
26
+ burstWindowMinutes: 5, // burst window size
27
+
28
+ // Circuit breaker
29
+ tripThreshold: 3, // trip after N blocked attempts (dedup hits count as failures)
30
+ cooldownMinutes: 30, // time before half-open probe
31
+
32
+ // Global limits
33
+ globalMaxPerHour: 30, // across all agents for this repo
34
+ }
35
+
36
+ /**
37
+ * Format a future Date as a human-readable remaining duration.
38
+ * e.g. "23 minutes remaining" or "1 minute remaining"
39
+ *
40
+ * @param {Date|string|number} until - The end time
41
+ * @returns {string}
42
+ */
43
+ function formatCooldownRemaining(until) {
44
+ const end = new Date(until)
45
+ const now = new Date()
46
+ const diffMs = end - now
47
+ if (diffMs <= 0) return '0 minutes remaining'
48
+ const minutes = Math.ceil(diffMs / 60_000)
49
+ return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} remaining`
50
+ }
51
+
52
+ /**
53
+ * Count recent issue creation events across ALL agents for a repo.
54
+ * Uses getDb() directly because countRecentEvents only filters by agent.
55
+ *
56
+ * @param {string} repo - e.g. "owner/repo"
57
+ * @param {number} windowMinutes
58
+ * @returns {number}
59
+ */
60
+ function countAllRecentEvents(repo, windowMinutes) {
61
+ const db = getDb()
62
+ const row = db
63
+ .prepare(
64
+ `SELECT COUNT(*) AS cnt
65
+ FROM rate_events
66
+ WHERE repo = ? AND event_type = 'create' AND created_at > datetime('now', ?)`
67
+ )
68
+ .get(repo, `-${windowMinutes} minutes`)
69
+ return row ? row.cnt : 0
70
+ }
71
+
72
+ /**
73
+ * Check if issue creation is allowed for the given repo/agent.
74
+ *
75
+ * Steps:
76
+ * 1. Probe the circuit (transitions open → half-open if cooldown expired).
77
+ * 2. If circuit is open, block immediately.
78
+ * 3. If circuit is half-open, allow exactly one probe through.
79
+ * 4. Check per-agent hourly rate limit.
80
+ * 5. Check per-agent burst rate limit.
81
+ * 6. Check global (all-agent) hourly rate limit.
82
+ *
83
+ * @param {string} repo - e.g. "owner/repo"
84
+ * @param {string} agent - identifier for the calling agent/user
85
+ * @param {object} [config] - overrides for DEFAULT_SAFETY_CONFIG
86
+ * @returns {{ allowed: boolean, reason?: string, circuitState: string, rateInfo: object }}
87
+ */
88
+ export function checkSafety(repo, agent, config = {}) {
89
+ const cfg = { ...DEFAULT_SAFETY_CONFIG, ...config }
90
+
91
+ // Step 1: probe — may transition open → half-open if cooldown expired
92
+ probeCircuit(repo, agent)
93
+
94
+ // Step 2: read current circuit state
95
+ const circuit = getCircuitState(repo, agent)
96
+ const circuitState = circuit.status ?? 'closed'
97
+
98
+ if (circuitState === 'open') {
99
+ const remaining = circuit.cooldownUntil
100
+ ? formatCooldownRemaining(circuit.cooldownUntil)
101
+ : 'unknown'
102
+ return {
103
+ allowed: false,
104
+ reason: `Circuit breaker is open. Cooldown until: ${circuit.cooldownUntil} (${remaining})`,
105
+ circuitState,
106
+ rateInfo: {},
107
+ }
108
+ }
109
+
110
+ // Step 3: half-open → log and allow the probe through (no rate checks)
111
+ if (circuitState === 'half-open') {
112
+ console.warn(`[safety] Circuit is half-open for ${repo}/${agent} — allowing probe request through.`)
113
+ return {
114
+ allowed: true,
115
+ circuitState,
116
+ rateInfo: { probe: true },
117
+ }
118
+ }
119
+
120
+ // Step 4: per-agent hourly limit
121
+ const agentHourCount = countRecentEvents(repo, agent, 60)
122
+ if (agentHourCount >= cfg.maxPerHour) {
123
+ return {
124
+ allowed: false,
125
+ reason: `Hourly rate limit reached: ${agentHourCount}/${cfg.maxPerHour} issues created in the last 60 minutes.`,
126
+ circuitState,
127
+ rateInfo: { agentHourCount, maxPerHour: cfg.maxPerHour },
128
+ }
129
+ }
130
+
131
+ // Step 5: per-agent burst limit
132
+ const agentBurstCount = countRecentEvents(repo, agent, cfg.burstWindowMinutes)
133
+ if (agentBurstCount >= cfg.burstLimit) {
134
+ return {
135
+ allowed: false,
136
+ reason: `Burst rate limit reached: ${agentBurstCount}/${cfg.burstLimit} issues created in the last ${cfg.burstWindowMinutes} minutes.`,
137
+ circuitState,
138
+ rateInfo: { agentBurstCount, burstLimit: cfg.burstLimit, burstWindowMinutes: cfg.burstWindowMinutes },
139
+ }
140
+ }
141
+
142
+ // Step 6: global (all-agent) hourly limit
143
+ const globalHourCount = countAllRecentEvents(repo, 60)
144
+ if (globalHourCount >= cfg.globalMaxPerHour) {
145
+ return {
146
+ allowed: false,
147
+ reason: `Global hourly rate limit reached: ${globalHourCount}/${cfg.globalMaxPerHour} issues created across all agents in the last 60 minutes.`,
148
+ circuitState,
149
+ rateInfo: { globalHourCount, globalMaxPerHour: cfg.globalMaxPerHour },
150
+ }
151
+ }
152
+
153
+ return {
154
+ allowed: true,
155
+ circuitState,
156
+ rateInfo: {
157
+ agentHourCount,
158
+ agentBurstCount,
159
+ globalHourCount,
160
+ maxPerHour: cfg.maxPerHour,
161
+ burstLimit: cfg.burstLimit,
162
+ globalMaxPerHour: cfg.globalMaxPerHour,
163
+ },
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Record a successful issue creation.
169
+ *
170
+ * - Appends a 'create' rate event so future rate checks account for this issue.
171
+ * - If the circuit was half-open (probe succeeded), resets it to closed.
172
+ *
173
+ * @param {string} repo
174
+ * @param {string} agent
175
+ */
176
+ export function recordSuccess(repo, agent) {
177
+ recordRateEvent(repo, agent, 'create')
178
+
179
+ const circuit = getCircuitState(repo, agent)
180
+ if ((circuit.status ?? 'closed') === 'half-open') {
181
+ resetCircuit(repo, agent)
182
+ console.info(`[safety] Half-open probe succeeded — circuit reset to closed for ${repo}/${agent}.`)
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Record a safety failure (e.g., dedup block, validation error).
188
+ *
189
+ * Increments the failure count toward the trip threshold. Once
190
+ * tripThreshold is reached the circuit opens and enters cooldown.
191
+ *
192
+ * @param {string} repo
193
+ * @param {string} agent
194
+ * @param {object} [config] - overrides for DEFAULT_SAFETY_CONFIG
195
+ */
196
+ export function recordFailure(repo, agent, config = {}) {
197
+ const cfg = { ...DEFAULT_SAFETY_CONFIG, ...config }
198
+
199
+ const circuit = getCircuitState(repo, agent)
200
+ const newFailureCount = (circuit.failureCount ?? 0) + 1
201
+
202
+ if (newFailureCount >= cfg.tripThreshold) {
203
+ tripCircuit(repo, agent, cfg.cooldownMinutes)
204
+ console.warn(
205
+ `[safety] Circuit tripped for ${repo}/${agent} after ${newFailureCount} failures. ` +
206
+ `Cooldown: ${cfg.cooldownMinutes} minutes.`
207
+ )
208
+ } else {
209
+ // Update failure count without tripping — db.tripCircuit handles the trip;
210
+ // for intermediate increments we record a 'failure' rate event so the count
211
+ // is persisted across calls.
212
+ recordRateEvent(repo, agent, 'failure')
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Get a human-readable safety status summary for a repo/agent pair.
218
+ *
219
+ * @param {string} repo
220
+ * @param {string} agent
221
+ * @param {object} [config] - overrides for DEFAULT_SAFETY_CONFIG
222
+ * @returns {string}
223
+ */
224
+ export function getSafetyStatus(repo, agent, config = {}) {
225
+ const cfg = { ...DEFAULT_SAFETY_CONFIG, ...config }
226
+
227
+ probeCircuit(repo, agent)
228
+ const circuit = getCircuitState(repo, agent)
229
+ const circuitState = circuit.status ?? 'closed'
230
+
231
+ const agentHourCount = countRecentEvents(repo, agent, 60)
232
+ const agentBurstCount = countRecentEvents(repo, agent, cfg.burstWindowMinutes)
233
+ const globalHourCount = countAllRecentEvents(repo, 60)
234
+
235
+ const lines = [
236
+ `Safety status for ${repo} / agent: ${agent}`,
237
+ ` Circuit breaker : ${circuitState.toUpperCase()}` +
238
+ (circuitState === 'open' && circuit.cooldownUntil
239
+ ? ` (${formatCooldownRemaining(circuit.cooldownUntil)})`
240
+ : ''),
241
+ ` Failures : ${circuit.failureCount ?? 0} / ${cfg.tripThreshold} (trip threshold)`,
242
+ ` Hourly (agent) : ${agentHourCount} / ${cfg.maxPerHour}`,
243
+ ` Burst (agent) : ${agentBurstCount} / ${cfg.burstLimit} (last ${cfg.burstWindowMinutes} min)`,
244
+ ` Hourly (global) : ${globalHourCount} / ${cfg.globalMaxPerHour}`,
245
+ ]
246
+
247
+ return lines.join('\n')
248
+ }
249
+
250
+ /**
251
+ * Force-reset the circuit breaker to closed state (admin override).
252
+ *
253
+ * @param {string} repo
254
+ * @param {string} agent
255
+ */
256
+ export function forceReset(repo, agent) {
257
+ resetCircuit(repo, agent)
258
+ console.info(`[safety] Circuit breaker force-reset to closed for ${repo}/${agent}.`)
259
+ }