tissues 0.4.1 โ 0.5.1
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/README.md +34 -24
- package/package.json +1 -1
- package/src/cli.js +5 -3
- package/src/commands/config.js +104 -0
- package/src/commands/create.js +375 -221
- package/src/commands/status.js +1 -1
- package/src/commands/{open.js โ use.js} +4 -2
- package/src/lib/attribution.js +8 -8
- package/src/lib/db.js +19 -0
- package/src/lib/defaults.js +4 -69
- package/src/lib/gh.js +53 -4
- package/src/lib/safety.js +3 -4
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# tissues
|
|
1
|
+
# tissues ๐งป
|
|
2
2
|
|
|
3
3
|
AI-enhanced GitHub issue creation with built-in safety guardrails.
|
|
4
4
|
|
|
@@ -32,11 +32,11 @@ Requires Node.js >= 18.
|
|
|
32
32
|
# Authenticate (auto-detects gh CLI token)
|
|
33
33
|
tissues auth
|
|
34
34
|
|
|
35
|
-
# Set your active repo
|
|
36
|
-
tissues
|
|
35
|
+
# Set your active repo (optional โ saves typing --repo every time)
|
|
36
|
+
tissues use calebogden/tissues
|
|
37
37
|
|
|
38
|
-
# Create an issue interactively
|
|
39
|
-
tissues create
|
|
38
|
+
# Create an issue interactively (title as positional argument โ no quotes needed)
|
|
39
|
+
tissues create fix the login bug
|
|
40
40
|
|
|
41
41
|
# Check safety status before running in CI
|
|
42
42
|
tissues status
|
|
@@ -54,13 +54,14 @@ Authenticate with GitHub. Auto-detects your `gh` CLI token if you're already log
|
|
|
54
54
|
tissues auth
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
### `tissues
|
|
57
|
+
### `tissues use`
|
|
58
58
|
|
|
59
|
-
Set the active repository context.
|
|
59
|
+
Set the active repository context. Optional โ all commands also accept `--repo` directly. Once set, subsequent commands use this repo automatically unless overridden.
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
tissues
|
|
63
|
-
# prompts: pick a repo from your GitHub account
|
|
62
|
+
tissues use calebogden/tissues # set by name
|
|
63
|
+
tissues use # prompts: pick a repo from your GitHub account
|
|
64
|
+
tissues use --repo calebogden/tissues # same, via flag
|
|
64
65
|
```
|
|
65
66
|
|
|
66
67
|
### `tissues create`
|
|
@@ -68,7 +69,7 @@ tissues open
|
|
|
68
69
|
Create a new GitHub issue. Runs dedup checks and safety gates before creating anything.
|
|
69
70
|
|
|
70
71
|
```bash
|
|
71
|
-
tissues create [options]
|
|
72
|
+
tissues create [title...] [options]
|
|
72
73
|
```
|
|
73
74
|
|
|
74
75
|
**Options:**
|
|
@@ -78,7 +79,10 @@ tissues create [options]
|
|
|
78
79
|
| `--repo <owner/name>` | Override active repo |
|
|
79
80
|
| `--template <name>` | Template to use: `bug`, `feature`, `security`, `performance`, `refactor` |
|
|
80
81
|
| `--title <title>` | Issue title (skips interactive prompt) |
|
|
81
|
-
| `--body <text>` | Issue body / description (skips interactive prompt) |
|
|
82
|
+
| `--body <text>` | Issue body / description โ the actual content describing the issue (skips interactive prompt) |
|
|
83
|
+
| `--instructions <text>` | AI enhancement instructions: guides how the AI writes the issue body (e.g. "keep it under 200 words", "use formal tone"). Does not appear in the created issue. Optional โ skips interactive prompt when provided. |
|
|
84
|
+
| `--no-enhance` | Skip AI enhancement, use rendered template as-is |
|
|
85
|
+
| `--batch <file>` | Create multiple issues from a JSON file. Each item supports: `title`, `body`, `template`, `labels`, `agent`, `session` |
|
|
82
86
|
| `--labels <labels>` | Comma-separated labels to apply |
|
|
83
87
|
| `--agent <name>` | Agent identifier for attribution (default: `human`) |
|
|
84
88
|
| `--session <id>` | Session ID for attribution and idempotency |
|
|
@@ -88,8 +92,8 @@ tissues create [options]
|
|
|
88
92
|
**Examples:**
|
|
89
93
|
|
|
90
94
|
```bash
|
|
91
|
-
# Interactive
|
|
92
|
-
tissues create
|
|
95
|
+
# Interactive (title pre-filled from positional argument)
|
|
96
|
+
tissues create fix the login bug
|
|
93
97
|
|
|
94
98
|
# Fully scripted (no prompts)
|
|
95
99
|
tissues create \
|
|
@@ -97,6 +101,7 @@ tissues create \
|
|
|
97
101
|
--template bug \
|
|
98
102
|
--title "Login fails on Safari 17" \
|
|
99
103
|
--body "Reproducible on fresh profile. Console shows CORS error." \
|
|
104
|
+
--instructions "Keep it under 150 words and include a clear reproduction checklist." \
|
|
100
105
|
--labels "bug,P1"
|
|
101
106
|
|
|
102
107
|
# From an AI agent
|
|
@@ -192,12 +197,11 @@ Every issue created by tissues includes a machine-readable `<!-- tissues-meta --
|
|
|
192
197
|
|
|
193
198
|
## Configuration
|
|
194
199
|
|
|
195
|
-
Configuration is loaded and merged from
|
|
200
|
+
Configuration is loaded and merged from three sources in ascending priority order:
|
|
196
201
|
|
|
197
202
|
1. Built-in defaults
|
|
198
203
|
2. User-level config: `~/.config/tissues/config.json`
|
|
199
204
|
3. Repo-level config: `.tissues/config.json`
|
|
200
|
-
4. Environment variables: `TISSUES_*`
|
|
201
205
|
|
|
202
206
|
### Example `.tissues/config.json`
|
|
203
207
|
|
|
@@ -230,15 +234,20 @@ Configuration is loaded and merged from four sources in ascending priority order
|
|
|
230
234
|
}
|
|
231
235
|
```
|
|
232
236
|
|
|
233
|
-
###
|
|
237
|
+
### `tissues config`
|
|
234
238
|
|
|
235
|
-
|
|
239
|
+
Get or set persistent user-level config values using dot notation. Values are stored in `~/.config/tissues/config.json`.
|
|
236
240
|
|
|
237
241
|
```bash
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
+
# Set a value
|
|
243
|
+
tissues config set safety.maxPerHour 5
|
|
244
|
+
tissues config set attribution.defaultAgent my-agent
|
|
245
|
+
|
|
246
|
+
# Read a value
|
|
247
|
+
tissues config get safety.maxPerHour
|
|
248
|
+
|
|
249
|
+
# Show all resolved config
|
|
250
|
+
tissues config list
|
|
242
251
|
```
|
|
243
252
|
|
|
244
253
|
### Hooks
|
|
@@ -275,7 +284,7 @@ Template files support `{{variable}}` substitution:
|
|
|
275
284
|
| Variable | Value |
|
|
276
285
|
|---|---|
|
|
277
286
|
| `{{title}}` | Issue title |
|
|
278
|
-
| `{{description}}` |
|
|
287
|
+
| `{{description}}` | Issue description โ the actual content describing the issue |
|
|
279
288
|
| `{{agent}}` | Agent identifier |
|
|
280
289
|
| `{{session}}` | Session ID |
|
|
281
290
|
| `{{date}}` | ISO date (YYYY-MM-DD) |
|
|
@@ -316,11 +325,10 @@ Every issue created by tissues includes a machine-readable HTML comment at the b
|
|
|
316
325
|
<!-- tissues-meta
|
|
317
326
|
agent: claude-opus-4-6
|
|
318
327
|
session: abc123
|
|
319
|
-
pid: 84921
|
|
320
328
|
trigger: cli-create
|
|
321
329
|
fingerprint: sha256:3f2a1b...
|
|
322
330
|
created_at: 2026-02-19T15:30:00.000Z
|
|
323
|
-
created_via: tissues-cli/0.
|
|
331
|
+
created_via: tissues-cli/0.5
|
|
324
332
|
-->
|
|
325
333
|
```
|
|
326
334
|
|
|
@@ -332,6 +340,8 @@ The block is used by tissues to:
|
|
|
332
340
|
|
|
333
341
|
You can pass additional fields via `--agent`, `--session`, and the programmatic API.
|
|
334
342
|
|
|
343
|
+
`pid` is available but opt-in โ pass it explicitly via the programmatic API (`pid: process.pid`). It is omitted by default to avoid leaking process info into public issues.
|
|
344
|
+
|
|
335
345
|
---
|
|
336
346
|
|
|
337
347
|
## State Database
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -2,7 +2,8 @@ import { Command } from 'commander'
|
|
|
2
2
|
import { createRequire } from 'module'
|
|
3
3
|
const { version } = createRequire(import.meta.url)('../package.json')
|
|
4
4
|
import { authCommand } from './commands/auth.js'
|
|
5
|
-
import {
|
|
5
|
+
import { configCommand } from './commands/config.js'
|
|
6
|
+
import { useCommand } from './commands/use.js'
|
|
6
7
|
import { createCommand } from './commands/create.js'
|
|
7
8
|
import { listCommand } from './commands/list.js'
|
|
8
9
|
import { statusCommand } from './commands/status.js'
|
|
@@ -39,7 +40,7 @@ program
|
|
|
39
40
|
requireGh()
|
|
40
41
|
|
|
41
42
|
// Show active repo context on every command (except auth/open)
|
|
42
|
-
if (name !== 'auth' && name !== '
|
|
43
|
+
if (name !== 'auth' && name !== 'use' && name !== 'login' && name !== 'status' && name !== 'switch' && name !== 'logout') {
|
|
43
44
|
const activeRepo = store.get('activeRepo')
|
|
44
45
|
if (activeRepo) {
|
|
45
46
|
console.log(chalk.dim(`Working in: ${activeRepo}\n`))
|
|
@@ -61,7 +62,8 @@ program.hook('preAction', () => {
|
|
|
61
62
|
authCommand.description(authDescription())
|
|
62
63
|
})
|
|
63
64
|
program.addCommand(authCommand)
|
|
64
|
-
program.addCommand(
|
|
65
|
+
program.addCommand(configCommand)
|
|
66
|
+
program.addCommand(useCommand)
|
|
65
67
|
program.addCommand(createCommand)
|
|
66
68
|
program.addCommand(listCommand)
|
|
67
69
|
program.addCommand(statusCommand)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import chalk from 'chalk'
|
|
5
|
+
import { loadConfig, userConfigPath, BUILT_IN_DEFAULTS } from '../lib/defaults.js'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function readUserConfig() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(userConfigPath(), 'utf8'))
|
|
14
|
+
} catch {
|
|
15
|
+
return {}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeUserConfig(obj) {
|
|
20
|
+
const filePath = userConfigPath()
|
|
21
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
22
|
+
fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + '\n', 'utf8')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getNestedValue(obj, dotKey) {
|
|
26
|
+
return dotKey.split('.').reduce((o, k) => (o != null ? o[k] : undefined), obj)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setNestedValue(obj, dotKey, value) {
|
|
30
|
+
const parts = dotKey.split('.')
|
|
31
|
+
const result = { ...obj }
|
|
32
|
+
let cursor = result
|
|
33
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
34
|
+
const k = parts[i]
|
|
35
|
+
cursor[k] = cursor[k] && typeof cursor[k] === 'object' ? { ...cursor[k] } : {}
|
|
36
|
+
cursor = cursor[k]
|
|
37
|
+
}
|
|
38
|
+
cursor[parts[parts.length - 1]] = value
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function coerceValue(dotKey, raw) {
|
|
43
|
+
const parts = dotKey.split('.')
|
|
44
|
+
if (parts.length !== 2) return raw
|
|
45
|
+
const [section, field] = parts
|
|
46
|
+
const defaultVal = BUILT_IN_DEFAULTS[section]?.[field]
|
|
47
|
+
if (typeof defaultVal === 'number') {
|
|
48
|
+
const n = Number(raw)
|
|
49
|
+
if (isNaN(n)) throw new Error(`Expected a number for ${dotKey}, got: ${raw}`)
|
|
50
|
+
return n
|
|
51
|
+
}
|
|
52
|
+
if (typeof defaultVal === 'boolean') {
|
|
53
|
+
if (raw === 'true' || raw === '1') return true
|
|
54
|
+
if (raw === 'false' || raw === '0') return false
|
|
55
|
+
throw new Error(`Expected true/false for ${dotKey}, got: ${raw}`)
|
|
56
|
+
}
|
|
57
|
+
if (raw === 'null') return null
|
|
58
|
+
return raw
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Command
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export const configCommand = new Command('config')
|
|
66
|
+
.description('Get or set persistent configuration values')
|
|
67
|
+
|
|
68
|
+
configCommand
|
|
69
|
+
.command('get <key>')
|
|
70
|
+
.description('Get a config value (dot notation, e.g. safety.maxPerHour)')
|
|
71
|
+
.action((key) => {
|
|
72
|
+
const cfg = loadConfig()
|
|
73
|
+
const value = getNestedValue(cfg, key)
|
|
74
|
+
if (value === undefined) {
|
|
75
|
+
console.error(chalk.red(`Unknown config key: ${key}`))
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
console.log(value)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
configCommand
|
|
82
|
+
.command('set <key> <value>')
|
|
83
|
+
.description('Set a config value (dot notation, e.g. safety.maxPerHour 5)')
|
|
84
|
+
.action((key, rawValue) => {
|
|
85
|
+
let value
|
|
86
|
+
try {
|
|
87
|
+
value = coerceValue(key, rawValue)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(chalk.red(err.message))
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
const current = readUserConfig()
|
|
93
|
+
const updated = setNestedValue(current, key, value)
|
|
94
|
+
writeUserConfig(updated)
|
|
95
|
+
console.log(chalk.green(`โ Set ${key} = ${value}`))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
configCommand
|
|
99
|
+
.command('list')
|
|
100
|
+
.description('Show all resolved config values')
|
|
101
|
+
.action(() => {
|
|
102
|
+
const cfg = loadConfig()
|
|
103
|
+
console.log(JSON.stringify(cfg, null, 2))
|
|
104
|
+
})
|
package/src/commands/create.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
2
3
|
import { Command } from 'commander'
|
|
3
4
|
import { input, select, confirm } from '@inquirer/prompts'
|
|
4
5
|
import { store } from '../lib/config.js'
|
|
@@ -9,7 +10,13 @@ import { listTemplates, loadTemplate, renderTemplate } from '../lib/templates.js
|
|
|
9
10
|
import { renderAttribution } from '../lib/attribution.js'
|
|
10
11
|
import { computeFingerprint } from '../lib/dedup.js'
|
|
11
12
|
import { pickRepo } from '../lib/repo-picker.js'
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
requireAuth,
|
|
15
|
+
createIssue,
|
|
16
|
+
listLabels,
|
|
17
|
+
createLabel,
|
|
18
|
+
addLabelsToIssue,
|
|
19
|
+
} from '../lib/gh.js'
|
|
13
20
|
import chalk from 'chalk'
|
|
14
21
|
import ora from 'ora'
|
|
15
22
|
|
|
@@ -81,280 +88,427 @@ function runPostCreateHook(hookCmd, ctx) {
|
|
|
81
88
|
* Enhance issue body with AI. Currently a structured placeholder.
|
|
82
89
|
* Wire up real AI (OpenAI / Anthropic) here.
|
|
83
90
|
*
|
|
84
|
-
* @param {string} title
|
|
85
|
-
* @param {string} description
|
|
91
|
+
* @param {string} title - issue title
|
|
92
|
+
* @param {string} description - the actual issue content describing what the issue is about;
|
|
93
|
+
* this is rendered into the template body via {{description}} and forms the core of the issue
|
|
86
94
|
* @param {string} templateBody - already-rendered template body to use as context
|
|
95
|
+
* @param {string} [instructions] - optional AI prompt instructions that guide *how* the AI
|
|
96
|
+
* writes or post-processes the issue body (e.g. "keep it under 200 words",
|
|
97
|
+
* "after creating, send to this webhook: https://..."). Does NOT appear in the issue itself.
|
|
87
98
|
* @returns {Promise<string>}
|
|
88
99
|
*/
|
|
89
|
-
async function enhanceWithAI(title, description, templateBody) {
|
|
100
|
+
async function enhanceWithAI(title, description, templateBody, instructions) {
|
|
90
101
|
// TODO: wire up real AI (OpenAI / Anthropic)
|
|
91
|
-
//
|
|
102
|
+
// - description goes INTO the issue body (rendered via {{description}} in the template)
|
|
103
|
+
// - instructions guide the AI behaviour but are NOT included in the output
|
|
92
104
|
return templateBody
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
// ---------------------------------------------------------------------------
|
|
96
|
-
//
|
|
108
|
+
// AbortError โ thrown for user-initiated cancellations (exit 0)
|
|
97
109
|
// ---------------------------------------------------------------------------
|
|
98
110
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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()
|
|
111
|
+
class AbortError extends Error {
|
|
112
|
+
constructor(msg) {
|
|
113
|
+
super(msg)
|
|
114
|
+
this.isAbort = true
|
|
115
|
+
}
|
|
116
|
+
}
|
|
115
117
|
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Core create logic (extracted so batch mode can reuse it)
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
128
121
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Run the full issue-creation flow.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} opts - command options (same shape as Commander opts)
|
|
126
|
+
* @returns {Promise<{ url: string, number: number } | null>} null for dry-run
|
|
127
|
+
* @throws {AbortError} when the user cancels interactively
|
|
128
|
+
* @throws {Error} on hard failures
|
|
129
|
+
*/
|
|
130
|
+
async function runCreate(opts) {
|
|
131
|
+
// -----------------------------------------------------------------------
|
|
132
|
+
// 0. Ensure authenticated
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
requireAuth()
|
|
135
|
+
|
|
136
|
+
// -----------------------------------------------------------------------
|
|
137
|
+
// 1. Resolve repo
|
|
138
|
+
// -----------------------------------------------------------------------
|
|
139
|
+
let repo = opts.repo
|
|
140
|
+
if (!repo) {
|
|
141
|
+
repo = store.get('activeRepo')
|
|
142
|
+
}
|
|
143
|
+
if (!repo) {
|
|
144
|
+
console.log(chalk.yellow('No active repo. Pick one:\n'))
|
|
145
|
+
repo = await pickRepo()
|
|
146
|
+
console.log()
|
|
147
|
+
}
|
|
134
148
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
// -----------------------------------------------------------------------
|
|
150
|
+
// 2. Load config
|
|
151
|
+
// -----------------------------------------------------------------------
|
|
152
|
+
const repoRoot = findRepoRoot()
|
|
153
|
+
const config = loadConfig(repoRoot)
|
|
154
|
+
|
|
155
|
+
// -----------------------------------------------------------------------
|
|
156
|
+
// 3. Check safety
|
|
157
|
+
// -----------------------------------------------------------------------
|
|
158
|
+
const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
|
|
159
|
+
const safetyResult = checkSafety(repo, agent, config.safety)
|
|
140
160
|
|
|
141
|
-
|
|
161
|
+
warnIfCircuitNotClosed(safetyResult.circuitState)
|
|
162
|
+
|
|
163
|
+
if (!safetyResult.allowed) {
|
|
164
|
+
recordFailure(repo, agent, config.safety)
|
|
165
|
+
console.error(chalk.red('Safety check failed:'), safetyResult.reason)
|
|
166
|
+
throw new Error('Safety check failed')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// -----------------------------------------------------------------------
|
|
170
|
+
// 4. Get inputs
|
|
171
|
+
// -----------------------------------------------------------------------
|
|
172
|
+
const title =
|
|
173
|
+
opts.title ?? (await input({ message: 'Issue title', default: opts._titleDefault }))
|
|
174
|
+
if (!title || !title.trim()) {
|
|
175
|
+
console.error(chalk.red('Title is required.'))
|
|
176
|
+
throw new Error('Title is required')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const description =
|
|
180
|
+
opts.body ??
|
|
181
|
+
(await input({
|
|
182
|
+
message: 'Issue description (optional)',
|
|
183
|
+
}))
|
|
184
|
+
|
|
185
|
+
const instructions =
|
|
186
|
+
opts.instructions ??
|
|
187
|
+
(await input({
|
|
188
|
+
message: 'Issue instruction (optional, guides AI enhancement)',
|
|
189
|
+
}))
|
|
190
|
+
|
|
191
|
+
// -----------------------------------------------------------------------
|
|
192
|
+
// 5. Pick template
|
|
193
|
+
// -----------------------------------------------------------------------
|
|
194
|
+
let templateName = opts.template
|
|
195
|
+
|
|
196
|
+
// Fall back to config default
|
|
197
|
+
if (!templateName) {
|
|
198
|
+
templateName = config.templates?.default ?? null
|
|
199
|
+
}
|
|
142
200
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
201
|
+
// If still unresolved and interactive, let user pick
|
|
202
|
+
if (!templateName) {
|
|
203
|
+
const available = listTemplates(repoRoot)
|
|
204
|
+
// Deduplicate by key (higher-priority sources shadow lower ones)
|
|
205
|
+
const seen = new Set()
|
|
206
|
+
const choices = []
|
|
207
|
+
for (const tpl of available) {
|
|
208
|
+
if (!seen.has(tpl.key)) {
|
|
209
|
+
seen.add(tpl.key)
|
|
210
|
+
choices.push({
|
|
211
|
+
name: `${tpl.name} ${chalk.dim(`(${tpl.source})`)}`,
|
|
212
|
+
value: tpl.key,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
147
215
|
}
|
|
148
216
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
217
|
+
if (choices.length > 0) {
|
|
218
|
+
templateName = await select({
|
|
219
|
+
message: 'Choose a template',
|
|
220
|
+
choices,
|
|
221
|
+
})
|
|
222
|
+
} else {
|
|
223
|
+
templateName = 'default'
|
|
156
224
|
}
|
|
225
|
+
}
|
|
157
226
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
227
|
+
let template
|
|
228
|
+
try {
|
|
229
|
+
template = loadTemplate(templateName, repoRoot)
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error(chalk.red(`Template error: ${err.message}`))
|
|
232
|
+
throw new Error(`Template error: ${err.message}`)
|
|
233
|
+
}
|
|
163
234
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
235
|
+
// -----------------------------------------------------------------------
|
|
236
|
+
// 6. Check dedup
|
|
237
|
+
// -----------------------------------------------------------------------
|
|
238
|
+
const session = opts.session ?? null
|
|
239
|
+
const dedupSpinner = ora('Checking for duplicates...').start()
|
|
240
|
+
let dedupResult
|
|
241
|
+
try {
|
|
242
|
+
dedupResult = await checkDuplicate(repo, {
|
|
243
|
+
title,
|
|
244
|
+
body: description,
|
|
245
|
+
agent,
|
|
246
|
+
// No idempotency key by default โ callers who need deterministic keys
|
|
247
|
+
// can pass --session and combine with agent + title in future work
|
|
248
|
+
})
|
|
249
|
+
dedupSpinner.stop()
|
|
250
|
+
} catch (err) {
|
|
251
|
+
dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
|
|
252
|
+
dedupResult = { action: 'allow', results: [] }
|
|
253
|
+
}
|
|
168
254
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
255
|
+
if (dedupResult.action === 'block') {
|
|
256
|
+
recordFailure(repo, agent, config.safety)
|
|
257
|
+
console.error(chalk.red('Duplicate detected โ issue not created.'))
|
|
258
|
+
for (const r of dedupResult.results.filter((r) => r.action === 'block')) {
|
|
259
|
+
console.error(formatDedupResult(r))
|
|
172
260
|
}
|
|
261
|
+
throw new Error('Duplicate detected')
|
|
262
|
+
}
|
|
173
263
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
}
|
|
264
|
+
if (dedupResult.action === 'warn' && !opts.force) {
|
|
265
|
+
console.warn(chalk.yellow('\nSimilar issue(s) found:'))
|
|
266
|
+
for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
|
|
267
|
+
console.warn(formatDedupResult(r))
|
|
268
|
+
}
|
|
269
|
+
console.warn()
|
|
189
270
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
271
|
+
const proceed = await confirm({
|
|
272
|
+
message: 'Create anyway?',
|
|
273
|
+
default: false,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
if (!proceed) {
|
|
277
|
+
console.log(chalk.dim('Aborted.'))
|
|
278
|
+
throw new AbortError('Aborted by user')
|
|
198
279
|
}
|
|
280
|
+
}
|
|
199
281
|
|
|
200
|
-
|
|
282
|
+
// -----------------------------------------------------------------------
|
|
283
|
+
// 7. Render template
|
|
284
|
+
// -----------------------------------------------------------------------
|
|
285
|
+
const renderedTemplate = renderTemplate(template.body, {
|
|
286
|
+
title,
|
|
287
|
+
description: description || '',
|
|
288
|
+
agent,
|
|
289
|
+
session: session || '',
|
|
290
|
+
date: new Date().toISOString().slice(0, 10),
|
|
291
|
+
repo,
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// -----------------------------------------------------------------------
|
|
295
|
+
// 8. AI enhancement (skip for --no-enhance or --dry-run)
|
|
296
|
+
// -----------------------------------------------------------------------
|
|
297
|
+
let body = renderedTemplate
|
|
298
|
+
if (opts.enhance !== false && !opts.dryRun) {
|
|
299
|
+
const aiSpinner = ora('Enhancing with AI...').start()
|
|
201
300
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
301
|
+
body = await enhanceWithAI(title, description, renderedTemplate, instructions)
|
|
302
|
+
aiSpinner.succeed('Enhanced')
|
|
303
|
+
} catch {
|
|
304
|
+
aiSpinner.warn('AI enhancement unavailable โ using template as-is')
|
|
206
305
|
}
|
|
306
|
+
}
|
|
207
307
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
308
|
+
// -----------------------------------------------------------------------
|
|
309
|
+
// 9. Add attribution
|
|
310
|
+
// -----------------------------------------------------------------------
|
|
311
|
+
const fingerprint = computeFingerprint(title, body)
|
|
312
|
+
const attributionBlock = renderAttribution({
|
|
313
|
+
agent,
|
|
314
|
+
session: session ?? undefined,
|
|
315
|
+
trigger: 'cli-create',
|
|
316
|
+
fingerprint: `sha256:${fingerprint}`,
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
body = body.trimEnd() + '\n\n' + attributionBlock
|
|
320
|
+
|
|
321
|
+
// -----------------------------------------------------------------------
|
|
322
|
+
// 10. Parse labels
|
|
323
|
+
// -----------------------------------------------------------------------
|
|
324
|
+
const labels = opts.labels
|
|
325
|
+
? opts.labels
|
|
326
|
+
.split(',')
|
|
327
|
+
.map((l) => l.trim())
|
|
328
|
+
.filter(Boolean)
|
|
329
|
+
: []
|
|
330
|
+
|
|
331
|
+
// -----------------------------------------------------------------------
|
|
332
|
+
// Dry run โ stop here
|
|
333
|
+
// -----------------------------------------------------------------------
|
|
334
|
+
if (opts.dryRun) {
|
|
335
|
+
const border = 'โ'.repeat(44)
|
|
336
|
+
console.log(chalk.cyan(`\nโโโ DRY RUN ${border.slice(11)}`))
|
|
337
|
+
console.log(` ${chalk.bold('Repo: ')} ${repo}`)
|
|
338
|
+
console.log(` ${chalk.bold('Title: ')} ${title}`)
|
|
339
|
+
console.log(` ${chalk.bold('Agent: ')} ${agent}`)
|
|
340
|
+
console.log(` ${chalk.bold('Template:')} ${template.name} (${template.source})`)
|
|
341
|
+
if (labels.length > 0) console.log(` ${chalk.bold('Labels: ')} ${labels.join(', ')}`)
|
|
342
|
+
console.log(`\n ${chalk.bold('Body:')}`)
|
|
343
|
+
console.log(renderedTemplate)
|
|
344
|
+
console.log(`\n ${chalk.dim('โโโ attribution (invisible on GitHub) โโโ')}`)
|
|
345
|
+
console.log(chalk.dim(attributionBlock))
|
|
346
|
+
console.log(chalk.cyan(`${border}\n No issue created.\n`))
|
|
347
|
+
return null
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// -----------------------------------------------------------------------
|
|
351
|
+
// 11. Create issue (with graceful label handling)
|
|
352
|
+
// -----------------------------------------------------------------------
|
|
353
|
+
let existingLabels = labels
|
|
354
|
+
let missingLabels = []
|
|
355
|
+
|
|
356
|
+
if (labels.length > 0) {
|
|
214
357
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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: [] }
|
|
358
|
+
const repoLabelNames = listLabels(repo)
|
|
359
|
+
existingLabels = labels.filter((l) => repoLabelNames.includes(l))
|
|
360
|
+
missingLabels = labels.filter((l) => !repoLabelNames.includes(l))
|
|
361
|
+
} catch {
|
|
362
|
+
// If listLabels fails, proceed with all labels (original behavior)
|
|
226
363
|
}
|
|
364
|
+
}
|
|
227
365
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
366
|
+
const createSpinner = ora('Creating issue...').start()
|
|
367
|
+
let issue
|
|
368
|
+
try {
|
|
369
|
+
issue = await createIssue(repo, { title, body, labels: existingLabels })
|
|
370
|
+
createSpinner.succeed(`Issue created: ${chalk.cyan(issue.url)}`)
|
|
371
|
+
} catch (err) {
|
|
372
|
+
// GitHub API failures are NOT safety failures โ do not record to circuit
|
|
373
|
+
createSpinner.fail(`Failed to create issue: ${err.message}`)
|
|
374
|
+
throw new Error(`Failed to create issue: ${err.message}`)
|
|
375
|
+
}
|
|
236
376
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
377
|
+
if (missingLabels.length > 0) {
|
|
378
|
+
console.log(
|
|
379
|
+
chalk.yellow(`Labels not found: ${missingLabels.join(', ')} โ issue created without them`),
|
|
380
|
+
)
|
|
381
|
+
const createMissing = await confirm({
|
|
382
|
+
message: 'Create missing labels and apply them?',
|
|
383
|
+
default: true,
|
|
384
|
+
})
|
|
385
|
+
if (createMissing) {
|
|
386
|
+
for (const labelName of missingLabels) {
|
|
387
|
+
const labelSpinner = ora(`Creating label "${labelName}"...`).start()
|
|
388
|
+
try {
|
|
389
|
+
createLabel(repo, labelName)
|
|
390
|
+
labelSpinner.succeed(`Label created: ${labelName}`)
|
|
391
|
+
} catch (err) {
|
|
392
|
+
labelSpinner.warn(`Failed to create label "${labelName}": ${err.message}`)
|
|
393
|
+
}
|
|
241
394
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
if (!proceed) {
|
|
250
|
-
console.log(chalk.dim('Aborted.'))
|
|
251
|
-
process.exit(0)
|
|
395
|
+
try {
|
|
396
|
+
addLabelsToIssue(repo, issue.number, missingLabels)
|
|
397
|
+
console.log(chalk.green('โ Labels created and applied'))
|
|
398
|
+
} catch (err) {
|
|
399
|
+
console.warn(chalk.yellow(`Failed to apply labels: ${err.message}`))
|
|
252
400
|
}
|
|
253
401
|
}
|
|
402
|
+
}
|
|
254
403
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
404
|
+
// -----------------------------------------------------------------------
|
|
405
|
+
// 12. Record success
|
|
406
|
+
// -----------------------------------------------------------------------
|
|
407
|
+
try {
|
|
408
|
+
await recordCreation(repo, {
|
|
259
409
|
title,
|
|
260
|
-
|
|
410
|
+
body,
|
|
411
|
+
issueNumber: issue.number,
|
|
261
412
|
agent,
|
|
262
|
-
session: session || '',
|
|
263
|
-
date: new Date().toISOString().slice(0, 10),
|
|
264
|
-
repo,
|
|
265
413
|
})
|
|
414
|
+
} catch (err) {
|
|
415
|
+
// Non-fatal โ dedup DB write failure shouldn't abort the command
|
|
416
|
+
console.warn(chalk.dim(`[dedup] Failed to record fingerprint: ${err.message}`))
|
|
417
|
+
}
|
|
266
418
|
|
|
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
|
-
}
|
|
419
|
+
recordSuccess(repo, agent)
|
|
278
420
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
421
|
+
// -----------------------------------------------------------------------
|
|
422
|
+
// Run postCreate hook if configured
|
|
423
|
+
// -----------------------------------------------------------------------
|
|
424
|
+
const hookCmd = config.hooks?.postCreate
|
|
425
|
+
if (hookCmd) {
|
|
426
|
+
runPostCreateHook(hookCmd, {
|
|
427
|
+
repo,
|
|
428
|
+
issueNumber: issue.number,
|
|
429
|
+
issueUrl: issue.url,
|
|
288
430
|
})
|
|
431
|
+
}
|
|
289
432
|
|
|
290
|
-
|
|
433
|
+
return { url: issue.url, number: issue.number }
|
|
434
|
+
}
|
|
291
435
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const labels = opts.labels
|
|
296
|
-
? opts.labels
|
|
297
|
-
.split(',')
|
|
298
|
-
.map((l) => l.trim())
|
|
299
|
-
.filter(Boolean)
|
|
300
|
-
: []
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Command
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
301
439
|
|
|
440
|
+
export const createCommand = new Command('create')
|
|
441
|
+
.description('Create a new GitHub issue')
|
|
442
|
+
.argument('[title...]', 'Issue title (positional shorthand for --title)')
|
|
443
|
+
.option('--repo <repo>', 'Repository override (owner/name)')
|
|
444
|
+
.option('--template <name>', 'Template to use (bug, feature, security, performance, refactor)')
|
|
445
|
+
.option('--agent <name>', 'Agent identifier for attribution (default: human)')
|
|
446
|
+
.option('--session <id>', 'Session ID for attribution')
|
|
447
|
+
.option('--title <title>', 'Issue title (skips interactive prompt)')
|
|
448
|
+
.option('--body <body>', 'Issue body / description (skips interactive prompt)')
|
|
449
|
+
.option('--instructions <text>', 'AI enhancement instruction โ a prompt that guides how the AI writes the issue body (skips interactive prompt)')
|
|
450
|
+
.option('--labels <labels>', 'Comma-separated labels to apply')
|
|
451
|
+
.option('--force', 'Skip dedup warnings (still blocks on exact matches)')
|
|
452
|
+
.option('--dry-run', 'Check dedup and safety without creating the issue')
|
|
453
|
+
.option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
|
|
454
|
+
.option(
|
|
455
|
+
'--batch <file>',
|
|
456
|
+
'Create multiple issues from a JSON file (title, body, template, labels, agent, session per item)',
|
|
457
|
+
)
|
|
458
|
+
.action(async (titleArg, opts) => {
|
|
459
|
+
// Positional words โ prefill the prompt (user can still edit)
|
|
460
|
+
// --title flag โ skip the prompt entirely (scripted/batch use)
|
|
461
|
+
if (titleArg?.length && !opts.title) opts._titleDefault = titleArg.join(' ')
|
|
302
462
|
// -----------------------------------------------------------------------
|
|
303
|
-
//
|
|
463
|
+
// Batch mode
|
|
304
464
|
// -----------------------------------------------------------------------
|
|
305
|
-
if (opts.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
console.log(chalk.dim(body))
|
|
314
|
-
console.log(chalk.cyan('--- END DRY RUN (no issue created) ---\n'))
|
|
315
|
-
process.exit(0)
|
|
316
|
-
}
|
|
465
|
+
if (opts.batch) {
|
|
466
|
+
let items
|
|
467
|
+
try {
|
|
468
|
+
items = JSON.parse(fs.readFileSync(opts.batch, 'utf8'))
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.error(chalk.red(`Failed to read batch file: ${err.message}`))
|
|
471
|
+
process.exit(1)
|
|
472
|
+
}
|
|
317
473
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
474
|
+
if (!Array.isArray(items)) {
|
|
475
|
+
console.error(chalk.red('Batch file must contain a JSON array'))
|
|
476
|
+
process.exit(1)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const results = []
|
|
480
|
+
for (const [i, item] of items.entries()) {
|
|
481
|
+
console.log(chalk.dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
|
|
482
|
+
// item fields override CLI opts; labels array โ comma-string for runCreate
|
|
483
|
+
const itemOpts = {
|
|
484
|
+
...opts,
|
|
485
|
+
...item,
|
|
486
|
+
labels: item.labels?.join(',') ?? opts.labels,
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
const result = await runCreate(itemOpts)
|
|
490
|
+
results.push({ title: item.title, url: result?.url, ok: true })
|
|
491
|
+
} catch (err) {
|
|
492
|
+
results.push({ title: item.title, error: err.message, ok: false })
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
console.log(chalk.bold('\nBatch summary:'))
|
|
497
|
+
for (const r of results) {
|
|
498
|
+
if (r.ok) console.log(chalk.green(` โ ${r.title}`) + chalk.dim(` โ ${r.url}`))
|
|
499
|
+
else console.log(chalk.red(` โ ${r.title}`) + chalk.dim(` โ ${r.error}`))
|
|
500
|
+
}
|
|
501
|
+
process.exit(results.every((r) => r.ok) ? 0 : 1)
|
|
502
|
+
return
|
|
330
503
|
}
|
|
331
504
|
|
|
332
505
|
// -----------------------------------------------------------------------
|
|
333
|
-
//
|
|
506
|
+
// Single mode
|
|
334
507
|
// -----------------------------------------------------------------------
|
|
335
508
|
try {
|
|
336
|
-
await
|
|
337
|
-
|
|
338
|
-
body,
|
|
339
|
-
issueNumber: issue.number,
|
|
340
|
-
agent,
|
|
341
|
-
})
|
|
509
|
+
const result = await runCreate(opts)
|
|
510
|
+
if (result === null) process.exit(0) // dry-run
|
|
342
511
|
} catch (err) {
|
|
343
|
-
|
|
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
|
-
})
|
|
512
|
+
process.exit(err.isAbort ? 0 : 1)
|
|
359
513
|
}
|
|
360
514
|
})
|
package/src/commands/status.js
CHANGED
|
@@ -83,7 +83,7 @@ export const statusCommand = new Command('status')
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
if (!repo) {
|
|
86
|
-
console.error(chalk.red('No active repo. Set one with: tissues
|
|
86
|
+
console.error(chalk.red('No active repo. Set one with: tissues use <owner/repo>'))
|
|
87
87
|
process.exit(1)
|
|
88
88
|
}
|
|
89
89
|
|
|
@@ -3,10 +3,12 @@ import { pickRepo } from '../lib/repo-picker.js'
|
|
|
3
3
|
import { setConfig } from '../lib/config.js'
|
|
4
4
|
import chalk from 'chalk'
|
|
5
5
|
|
|
6
|
-
export const
|
|
6
|
+
export const useCommand = new Command('use')
|
|
7
7
|
.description('Set the active repository context')
|
|
8
8
|
.argument('[repo]', 'Repository in owner/name format (e.g. owner/repo)')
|
|
9
|
-
.
|
|
9
|
+
.option('--repo <repo>', 'Repository in owner/name format (alias for positional argument)')
|
|
10
|
+
.action(async (repoArg, opts) => {
|
|
11
|
+
const repo = repoArg || opts.repo
|
|
10
12
|
if (repo) {
|
|
11
13
|
setConfig({ activeRepo: repo })
|
|
12
14
|
console.log(chalk.green(`โ Active repo set to ${repo}`))
|
package/src/lib/attribution.js
CHANGED
|
@@ -56,7 +56,7 @@ function nowISO() {
|
|
|
56
56
|
* @typedef {object} AttributionOpts
|
|
57
57
|
* @property {string} [agent] - agent identifier (e.g. 'claude-opus-4-6')
|
|
58
58
|
* @property {string} [session] - session or conversation ID
|
|
59
|
-
* @property {number} [pid] - process ID
|
|
59
|
+
* @property {number} [pid] - process ID (opt-in; omitted by default)
|
|
60
60
|
* @property {string} [model] - AI model used (if any)
|
|
61
61
|
* @property {string} [trigger] - how the issue was created (e.g. 'cli-create')
|
|
62
62
|
* @property {string} [fingerprint] - content fingerprint (sha256:...)
|
|
@@ -72,7 +72,8 @@ function nowISO() {
|
|
|
72
72
|
* Build a normalized attribution metadata object from raw options.
|
|
73
73
|
*
|
|
74
74
|
* The returned object includes all provided fields plus automatic defaults
|
|
75
|
-
* (`created_at`, `created_via
|
|
75
|
+
* (`created_at`, `created_via`). Undefined/null fields are omitted.
|
|
76
|
+
* `pid` is opt-in โ it is only included if explicitly passed.
|
|
76
77
|
*
|
|
77
78
|
* @param {AttributionOpts} opts
|
|
78
79
|
* @returns {object} metadata record
|
|
@@ -100,8 +101,8 @@ export function buildAttribution(opts = {}) {
|
|
|
100
101
|
if (session != null) meta.session = String(session)
|
|
101
102
|
if (model != null) meta.model = String(model)
|
|
102
103
|
|
|
103
|
-
// Process info
|
|
104
|
-
|
|
104
|
+
// Process info (pid is opt-in โ pass it explicitly to include)
|
|
105
|
+
if (pid != null) meta.pid = Number(pid)
|
|
105
106
|
meta.trigger = trigger != null ? String(trigger) : 'cli-create'
|
|
106
107
|
|
|
107
108
|
// Deduplication handles
|
|
@@ -120,7 +121,7 @@ export function buildAttribution(opts = {}) {
|
|
|
120
121
|
|
|
121
122
|
// Timestamps / versioning
|
|
122
123
|
meta.created_at = createdAt ?? nowISO()
|
|
123
|
-
meta.created_via = `tissues-cli/${PKG_VERSION}`
|
|
124
|
+
meta.created_via = `tissues-cli/${PKG_VERSION.split('.').slice(0, 2).join('.')}`
|
|
124
125
|
|
|
125
126
|
return meta
|
|
126
127
|
}
|
|
@@ -137,11 +138,10 @@ export function buildAttribution(opts = {}) {
|
|
|
137
138
|
* <!-- tissues-meta
|
|
138
139
|
* agent: claude-opus-4-6
|
|
139
140
|
* session: abc123
|
|
140
|
-
* pid: 12345
|
|
141
141
|
* trigger: cli-create
|
|
142
142
|
* fingerprint: sha256:deadbeef
|
|
143
143
|
* created_at: 2026-02-19T15:30:00Z
|
|
144
|
-
* created_via: tissues-cli/0.1
|
|
144
|
+
* created_via: tissues-cli/0.1
|
|
145
145
|
* -->
|
|
146
146
|
*
|
|
147
147
|
* @param {AttributionOpts} opts
|
|
@@ -170,7 +170,7 @@ export function renderAttribution(opts = {}) {
|
|
|
170
170
|
* Returns the parsed key/value pairs as a plain object, or `null` if no
|
|
171
171
|
* attribution block is present in `issueBody`.
|
|
172
172
|
*
|
|
173
|
-
* Numeric fields (`
|
|
173
|
+
* Numeric fields (`risk`, `complexity`, `confidence`) are coerced to
|
|
174
174
|
* numbers. The `context_tags` field is split back into an array.
|
|
175
175
|
*
|
|
176
176
|
* @param {string} issueBody - raw GitHub issue body markdown
|
package/src/lib/db.js
CHANGED
|
@@ -277,6 +277,25 @@ export function getCircuitState(repo, agent = 'human') {
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Increment the failure count for a circuit without opening it.
|
|
282
|
+
* Used to persist intermediate failures before the trip threshold is reached.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} repo
|
|
285
|
+
* @param {string} [agent='human']
|
|
286
|
+
*/
|
|
287
|
+
export function incrementFailureCount(repo, agent = 'human') {
|
|
288
|
+
const db = getDb()
|
|
289
|
+
const id = `${repo}:${agent}`
|
|
290
|
+
db.prepare(
|
|
291
|
+
`INSERT INTO circuit_breaker (id, repo, agent, status, failure_count, updated_at)
|
|
292
|
+
VALUES (?, ?, ?, 'closed', 1, datetime('now'))
|
|
293
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
294
|
+
failure_count = failure_count + 1,
|
|
295
|
+
updated_at = datetime('now')`,
|
|
296
|
+
).run(id, repo, agent)
|
|
297
|
+
}
|
|
298
|
+
|
|
280
299
|
/**
|
|
281
300
|
* Trip (open) the circuit breaker and set a cooldown window.
|
|
282
301
|
*
|
package/src/lib/defaults.js
CHANGED
|
@@ -6,7 +6,7 @@ import os from 'node:os'
|
|
|
6
6
|
// Built-in defaults
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
const BUILT_IN_DEFAULTS = {
|
|
9
|
+
export const BUILT_IN_DEFAULTS = {
|
|
10
10
|
// Safety
|
|
11
11
|
safety: {
|
|
12
12
|
maxPerHour: 10,
|
|
@@ -50,12 +50,6 @@ const BUILT_IN_DEFAULTS = {
|
|
|
50
50
|
},
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Env var prefix
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
|
|
57
|
-
const ENV_PREFIX = 'TISSUES_'
|
|
58
|
-
|
|
59
53
|
// ---------------------------------------------------------------------------
|
|
60
54
|
// Helpers
|
|
61
55
|
// ---------------------------------------------------------------------------
|
|
@@ -113,64 +107,10 @@ function readJsonFile(filePath) {
|
|
|
113
107
|
*
|
|
114
108
|
* @returns {string}
|
|
115
109
|
*/
|
|
116
|
-
function userConfigPath() {
|
|
110
|
+
export function userConfigPath() {
|
|
117
111
|
return path.join(os.homedir(), '.config', 'tissues', 'config.json')
|
|
118
112
|
}
|
|
119
113
|
|
|
120
|
-
/**
|
|
121
|
-
* Convert a flat `TISSUES_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
|
-
* TISSUES_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
114
|
// ---------------------------------------------------------------------------
|
|
175
115
|
// Public API
|
|
176
116
|
// ---------------------------------------------------------------------------
|
|
@@ -200,8 +140,7 @@ export function findRepoRoot(startDir) {
|
|
|
200
140
|
* 1. Built-in defaults (lowest)
|
|
201
141
|
* 2. User-level config (~/.config/tissues/config.json)
|
|
202
142
|
* 3. Repo-level config (<repoRoot>/.tissues/config.json)
|
|
203
|
-
* 4.
|
|
204
|
-
* 5. CLI flags (passed as `cliOverrides`, highest)
|
|
143
|
+
* 4. CLI flags (passed as `cliOverrides`, highest)
|
|
205
144
|
*
|
|
206
145
|
* @param {string} [repoRoot] - path to the repo root; auto-detected if omitted
|
|
207
146
|
* @param {object} [cliOverrides] - values from parsed CLI flags (already nested)
|
|
@@ -223,11 +162,7 @@ export function loadConfig(repoRoot, cliOverrides) {
|
|
|
223
162
|
if (repoCfg) merged = deepMerge(merged, repoCfg)
|
|
224
163
|
}
|
|
225
164
|
|
|
226
|
-
// 4.
|
|
227
|
-
const envCfg = configFromEnv()
|
|
228
|
-
if (Object.keys(envCfg).length > 0) merged = deepMerge(merged, envCfg)
|
|
229
|
-
|
|
230
|
-
// 5. CLI overrides
|
|
165
|
+
// 4. CLI overrides
|
|
231
166
|
if (cliOverrides && typeof cliOverrides === 'object') {
|
|
232
167
|
merged = deepMerge(merged, cliOverrides)
|
|
233
168
|
}
|
package/src/lib/gh.js
CHANGED
|
@@ -44,10 +44,6 @@ export function requireGh() {
|
|
|
44
44
|
* @returns {string | null}
|
|
45
45
|
*/
|
|
46
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
47
|
try {
|
|
52
48
|
return execFileSync('gh', ['auth', 'token'], {
|
|
53
49
|
encoding: 'utf8',
|
|
@@ -221,6 +217,59 @@ export function createIssue(repo, { title, body, labels }) {
|
|
|
221
217
|
return { number, url }
|
|
222
218
|
}
|
|
223
219
|
|
|
220
|
+
/**
|
|
221
|
+
* List all label names for a repo.
|
|
222
|
+
* @param {string} repo - owner/name
|
|
223
|
+
* @returns {string[]}
|
|
224
|
+
*/
|
|
225
|
+
export function listLabels(repo) {
|
|
226
|
+
const raw = execFileSync('gh', [
|
|
227
|
+
'label', 'list',
|
|
228
|
+
'--repo', repo,
|
|
229
|
+
'--json', 'name',
|
|
230
|
+
'--jq', '.[].name',
|
|
231
|
+
], {
|
|
232
|
+
encoding: 'utf8',
|
|
233
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
234
|
+
}).trim()
|
|
235
|
+
|
|
236
|
+
if (!raw) return []
|
|
237
|
+
return raw.split('\n').filter(Boolean)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Create a label in a repo with a default color.
|
|
242
|
+
* @param {string} repo - owner/name
|
|
243
|
+
* @param {string} name - label name
|
|
244
|
+
*/
|
|
245
|
+
export function createLabel(repo, name) {
|
|
246
|
+
execFileSync('gh', [
|
|
247
|
+
'label', 'create', name,
|
|
248
|
+
'--repo', repo,
|
|
249
|
+
'--color', '#0075ca',
|
|
250
|
+
], {
|
|
251
|
+
encoding: 'utf8',
|
|
252
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Apply labels to an existing issue.
|
|
258
|
+
* @param {string} repo - owner/name
|
|
259
|
+
* @param {number} issueNumber
|
|
260
|
+
* @param {string[]} labels
|
|
261
|
+
*/
|
|
262
|
+
export function addLabelsToIssue(repo, issueNumber, labels) {
|
|
263
|
+
execFileSync('gh', [
|
|
264
|
+
'issue', 'edit', String(issueNumber),
|
|
265
|
+
'--repo', repo,
|
|
266
|
+
'--add-label', labels.join(','),
|
|
267
|
+
], {
|
|
268
|
+
encoding: 'utf8',
|
|
269
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
224
273
|
/**
|
|
225
274
|
* List open issues for a repo.
|
|
226
275
|
* @param {string} repo - owner/name
|
package/src/lib/safety.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
probeCircuit,
|
|
17
17
|
recordRateEvent,
|
|
18
18
|
countRecentEvents,
|
|
19
|
+
incrementFailureCount,
|
|
19
20
|
getDb,
|
|
20
21
|
} from './db.js'
|
|
21
22
|
|
|
@@ -206,10 +207,8 @@ export function recordFailure(repo, agent, config = {}) {
|
|
|
206
207
|
`Cooldown: ${cfg.cooldownMinutes} minutes.`
|
|
207
208
|
)
|
|
208
209
|
} else {
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
// is persisted across calls.
|
|
212
|
-
recordRateEvent(repo, agent, 'failure')
|
|
210
|
+
// Persist intermediate failure count without opening the circuit yet
|
|
211
|
+
incrementFailureCount(repo, agent)
|
|
213
212
|
}
|
|
214
213
|
}
|
|
215
214
|
|