tissues 0.4.1 โ 0.5.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/README.md +13 -9
- package/package.json +1 -1
- package/src/cli.js +2 -0
- package/src/commands/config.js +104 -0
- package/src/commands/create.js +352 -215
- package/src/commands/open.js +3 -1
- package/src/lib/attribution.js +1 -1
- package/src/lib/defaults.js +4 -69
- package/src/lib/gh.js +53 -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
|
|
|
@@ -192,12 +192,11 @@ Every issue created by tissues includes a machine-readable `<!-- tissues-meta --
|
|
|
192
192
|
|
|
193
193
|
## Configuration
|
|
194
194
|
|
|
195
|
-
Configuration is loaded and merged from
|
|
195
|
+
Configuration is loaded and merged from three sources in ascending priority order:
|
|
196
196
|
|
|
197
197
|
1. Built-in defaults
|
|
198
198
|
2. User-level config: `~/.config/tissues/config.json`
|
|
199
199
|
3. Repo-level config: `.tissues/config.json`
|
|
200
|
-
4. Environment variables: `TISSUES_*`
|
|
201
200
|
|
|
202
201
|
### Example `.tissues/config.json`
|
|
203
202
|
|
|
@@ -230,15 +229,20 @@ Configuration is loaded and merged from four sources in ascending priority order
|
|
|
230
229
|
}
|
|
231
230
|
```
|
|
232
231
|
|
|
233
|
-
###
|
|
232
|
+
### `tissues config`
|
|
234
233
|
|
|
235
|
-
|
|
234
|
+
Get or set persistent user-level config values using dot notation. Values are stored in `~/.config/tissues/config.json`.
|
|
236
235
|
|
|
237
236
|
```bash
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
# Set a value
|
|
238
|
+
tissues config set safety.maxPerHour 5
|
|
239
|
+
tissues config set attribution.defaultAgent my-agent
|
|
240
|
+
|
|
241
|
+
# Read a value
|
|
242
|
+
tissues config get safety.maxPerHour
|
|
243
|
+
|
|
244
|
+
# Show all resolved config
|
|
245
|
+
tissues config list
|
|
242
246
|
```
|
|
243
247
|
|
|
244
248
|
### Hooks
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -2,6 +2,7 @@ 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 { configCommand } from './commands/config.js'
|
|
5
6
|
import { openCommand } from './commands/open.js'
|
|
6
7
|
import { createCommand } from './commands/create.js'
|
|
7
8
|
import { listCommand } from './commands/list.js'
|
|
@@ -61,6 +62,7 @@ program.hook('preAction', () => {
|
|
|
61
62
|
authCommand.description(authDescription())
|
|
62
63
|
})
|
|
63
64
|
program.addCommand(authCommand)
|
|
65
|
+
program.addCommand(configCommand)
|
|
64
66
|
program.addCommand(openCommand)
|
|
65
67
|
program.addCommand(createCommand)
|
|
66
68
|
program.addCommand(listCommand)
|
|
@@ -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
|
|
|
@@ -93,181 +100,190 @@ async function enhanceWithAI(title, description, templateBody) {
|
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
// ---------------------------------------------------------------------------
|
|
96
|
-
//
|
|
103
|
+
// AbortError โ thrown for user-initiated cancellations (exit 0)
|
|
97
104
|
// ---------------------------------------------------------------------------
|
|
98
105
|
|
|
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()
|
|
106
|
+
class AbortError extends Error {
|
|
107
|
+
constructor(msg) {
|
|
108
|
+
super(msg)
|
|
109
|
+
this.isAbort = true
|
|
110
|
+
}
|
|
111
|
+
}
|
|
115
112
|
|
|
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
|
-
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Core create logic (extracted so batch mode can reuse it)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
128
116
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Run the full issue-creation flow.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} opts - command options (same shape as Commander opts)
|
|
121
|
+
* @returns {Promise<{ url: string, number: number } | null>} null for dry-run
|
|
122
|
+
* @throws {AbortError} when the user cancels interactively
|
|
123
|
+
* @throws {Error} on hard failures
|
|
124
|
+
*/
|
|
125
|
+
async function runCreate(opts) {
|
|
126
|
+
// -----------------------------------------------------------------------
|
|
127
|
+
// 0. Ensure authenticated
|
|
128
|
+
// -----------------------------------------------------------------------
|
|
129
|
+
requireAuth()
|
|
130
|
+
|
|
131
|
+
// -----------------------------------------------------------------------
|
|
132
|
+
// 1. Resolve repo
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
let repo = opts.repo
|
|
135
|
+
if (!repo) {
|
|
136
|
+
repo = store.get('activeRepo')
|
|
137
|
+
}
|
|
138
|
+
if (!repo) {
|
|
139
|
+
console.log(chalk.yellow('No active repo. Pick one:\n'))
|
|
140
|
+
repo = await pickRepo()
|
|
141
|
+
console.log()
|
|
142
|
+
}
|
|
134
143
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
144
|
+
// -----------------------------------------------------------------------
|
|
145
|
+
// 2. Load config
|
|
146
|
+
// -----------------------------------------------------------------------
|
|
147
|
+
const repoRoot = findRepoRoot()
|
|
148
|
+
const config = loadConfig(repoRoot)
|
|
140
149
|
|
|
141
|
-
|
|
150
|
+
// -----------------------------------------------------------------------
|
|
151
|
+
// 3. Check safety
|
|
152
|
+
// -----------------------------------------------------------------------
|
|
153
|
+
const agent = opts.agent ?? config.attribution?.defaultAgent ?? 'human'
|
|
154
|
+
const safetyResult = checkSafety(repo, agent, config.safety)
|
|
142
155
|
|
|
143
|
-
|
|
144
|
-
recordFailure(repo, agent, config.safety)
|
|
145
|
-
console.error(chalk.red('Safety check failed:'), safetyResult.reason)
|
|
146
|
-
process.exit(1)
|
|
147
|
-
}
|
|
156
|
+
warnIfCircuitNotClosed(safetyResult.circuitState)
|
|
148
157
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
console.error(chalk.red('Title is required.'))
|
|
155
|
-
process.exit(1)
|
|
156
|
-
}
|
|
158
|
+
if (!safetyResult.allowed) {
|
|
159
|
+
recordFailure(repo, agent, config.safety)
|
|
160
|
+
console.error(chalk.red('Safety check failed:'), safetyResult.reason)
|
|
161
|
+
throw new Error('Safety check failed')
|
|
162
|
+
}
|
|
157
163
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
// -----------------------------------------------------------------------
|
|
165
|
+
// 4. Get inputs
|
|
166
|
+
// -----------------------------------------------------------------------
|
|
167
|
+
const title = opts.title ?? (await input({ message: 'Issue title' }))
|
|
168
|
+
if (!title || !title.trim()) {
|
|
169
|
+
console.error(chalk.red('Title is required.'))
|
|
170
|
+
throw new Error('Title is required')
|
|
171
|
+
}
|
|
163
172
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
173
|
+
const description =
|
|
174
|
+
opts.body ??
|
|
175
|
+
(await input({
|
|
176
|
+
message: 'Brief description (optional, used for AI enhancement)',
|
|
177
|
+
}))
|
|
168
178
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
// -----------------------------------------------------------------------
|
|
180
|
+
// 5. Pick template
|
|
181
|
+
// -----------------------------------------------------------------------
|
|
182
|
+
let templateName = opts.template
|
|
173
183
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
}
|
|
184
|
+
// Fall back to config default
|
|
185
|
+
if (!templateName) {
|
|
186
|
+
templateName = config.templates?.default ?? null
|
|
187
|
+
}
|
|
189
188
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
189
|
+
// If still unresolved and interactive, let user pick
|
|
190
|
+
if (!templateName) {
|
|
191
|
+
const available = listTemplates(repoRoot)
|
|
192
|
+
// Deduplicate by key (higher-priority sources shadow lower ones)
|
|
193
|
+
const seen = new Set()
|
|
194
|
+
const choices = []
|
|
195
|
+
for (const tpl of available) {
|
|
196
|
+
if (!seen.has(tpl.key)) {
|
|
197
|
+
seen.add(tpl.key)
|
|
198
|
+
choices.push({
|
|
199
|
+
name: `${tpl.name} ${chalk.dim(`(${tpl.source})`)}`,
|
|
200
|
+
value: tpl.key,
|
|
194
201
|
})
|
|
195
|
-
} else {
|
|
196
|
-
templateName = 'default'
|
|
197
202
|
}
|
|
198
203
|
}
|
|
199
204
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
205
|
+
if (choices.length > 0) {
|
|
206
|
+
templateName = await select({
|
|
207
|
+
message: 'Choose a template',
|
|
208
|
+
choices,
|
|
221
209
|
})
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
|
|
225
|
-
dedupResult = { action: 'allow', results: [] }
|
|
210
|
+
} else {
|
|
211
|
+
templateName = 'default'
|
|
226
212
|
}
|
|
213
|
+
}
|
|
227
214
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
215
|
+
let template
|
|
216
|
+
try {
|
|
217
|
+
template = loadTemplate(templateName, repoRoot)
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(chalk.red(`Template error: ${err.message}`))
|
|
220
|
+
throw new Error(`Template error: ${err.message}`)
|
|
221
|
+
}
|
|
236
222
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
223
|
+
// -----------------------------------------------------------------------
|
|
224
|
+
// 6. Check dedup
|
|
225
|
+
// -----------------------------------------------------------------------
|
|
226
|
+
const session = opts.session ?? null
|
|
227
|
+
const dedupSpinner = ora('Checking for duplicates...').start()
|
|
228
|
+
let dedupResult
|
|
229
|
+
try {
|
|
230
|
+
dedupResult = await checkDuplicate(repo, {
|
|
231
|
+
title,
|
|
232
|
+
body: description,
|
|
233
|
+
agent,
|
|
234
|
+
// No idempotency key by default โ callers who need deterministic keys
|
|
235
|
+
// can pass --session and combine with agent + title in future work
|
|
236
|
+
})
|
|
237
|
+
dedupSpinner.stop()
|
|
238
|
+
} catch (err) {
|
|
239
|
+
dedupSpinner.warn(`Dedup check failed (skipping): ${err.message}`)
|
|
240
|
+
dedupResult = { action: 'allow', results: [] }
|
|
241
|
+
}
|
|
243
242
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
243
|
+
if (dedupResult.action === 'block') {
|
|
244
|
+
recordFailure(repo, agent, config.safety)
|
|
245
|
+
console.error(chalk.red('Duplicate detected โ issue not created.'))
|
|
246
|
+
for (const r of dedupResult.results.filter((r) => r.action === 'block')) {
|
|
247
|
+
console.error(formatDedupResult(r))
|
|
248
|
+
}
|
|
249
|
+
throw new Error('Duplicate detected')
|
|
250
|
+
}
|
|
248
251
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
if (dedupResult.action === 'warn' && !opts.force) {
|
|
253
|
+
console.warn(chalk.yellow('\nSimilar issue(s) found:'))
|
|
254
|
+
for (const r of dedupResult.results.filter((r) => r.action === 'warn')) {
|
|
255
|
+
console.warn(formatDedupResult(r))
|
|
253
256
|
}
|
|
257
|
+
console.warn()
|
|
254
258
|
|
|
255
|
-
|
|
256
|
-
|
|
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,
|
|
259
|
+
const proceed = await confirm({
|
|
260
|
+
message: 'Create anyway?',
|
|
261
|
+
default: false,
|
|
265
262
|
})
|
|
266
263
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
264
|
+
if (!proceed) {
|
|
265
|
+
console.log(chalk.dim('Aborted.'))
|
|
266
|
+
throw new AbortError('Aborted by user')
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// -----------------------------------------------------------------------
|
|
271
|
+
// 7. Render template
|
|
272
|
+
// -----------------------------------------------------------------------
|
|
273
|
+
const renderedTemplate = renderTemplate(template.body, {
|
|
274
|
+
title,
|
|
275
|
+
description: description || '',
|
|
276
|
+
agent,
|
|
277
|
+
session: session || '',
|
|
278
|
+
date: new Date().toISOString().slice(0, 10),
|
|
279
|
+
repo,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// -----------------------------------------------------------------------
|
|
283
|
+
// 8. AI enhancement (skip for --no-enhance or --dry-run)
|
|
284
|
+
// -----------------------------------------------------------------------
|
|
285
|
+
let body = renderedTemplate
|
|
286
|
+
if (opts.enhance !== false && !opts.dryRun) {
|
|
271
287
|
const aiSpinner = ora('Enhancing with AI...').start()
|
|
272
288
|
try {
|
|
273
289
|
body = await enhanceWithAI(title, description, renderedTemplate)
|
|
@@ -275,86 +291,207 @@ export const createCommand = new Command('create')
|
|
|
275
291
|
} catch {
|
|
276
292
|
aiSpinner.warn('AI enhancement unavailable โ using template as-is')
|
|
277
293
|
}
|
|
294
|
+
}
|
|
278
295
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
296
|
+
// -----------------------------------------------------------------------
|
|
297
|
+
// 9. Add attribution
|
|
298
|
+
// -----------------------------------------------------------------------
|
|
299
|
+
const fingerprint = computeFingerprint(title, body)
|
|
300
|
+
const attributionBlock = renderAttribution({
|
|
301
|
+
agent,
|
|
302
|
+
session: session ?? undefined,
|
|
303
|
+
trigger: 'cli-create',
|
|
304
|
+
fingerprint: `sha256:${fingerprint}`,
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
body = body.trimEnd() + '\n\n' + attributionBlock
|
|
308
|
+
|
|
309
|
+
// -----------------------------------------------------------------------
|
|
310
|
+
// 10. Parse labels
|
|
311
|
+
// -----------------------------------------------------------------------
|
|
312
|
+
const labels = opts.labels
|
|
313
|
+
? opts.labels
|
|
314
|
+
.split(',')
|
|
315
|
+
.map((l) => l.trim())
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
: []
|
|
318
|
+
|
|
319
|
+
// -----------------------------------------------------------------------
|
|
320
|
+
// Dry run โ stop here
|
|
321
|
+
// -----------------------------------------------------------------------
|
|
322
|
+
if (opts.dryRun) {
|
|
323
|
+
const border = 'โ'.repeat(44)
|
|
324
|
+
console.log(chalk.cyan(`\nโโโ DRY RUN ${border.slice(11)}`))
|
|
325
|
+
console.log(` ${chalk.bold('Repo: ')} ${repo}`)
|
|
326
|
+
console.log(` ${chalk.bold('Title: ')} ${title}`)
|
|
327
|
+
console.log(` ${chalk.bold('Agent: ')} ${agent}`)
|
|
328
|
+
console.log(` ${chalk.bold('Template:')} ${template.name} (${template.source})`)
|
|
329
|
+
if (labels.length > 0) console.log(` ${chalk.bold('Labels: ')} ${labels.join(', ')}`)
|
|
330
|
+
console.log(`\n ${chalk.bold('Body:')}`)
|
|
331
|
+
console.log(renderedTemplate)
|
|
332
|
+
console.log(`\n ${chalk.dim('โโโ attribution (invisible on GitHub) โโโ')}`)
|
|
333
|
+
console.log(chalk.dim(attributionBlock))
|
|
334
|
+
console.log(chalk.cyan(`${border}\n No issue created.\n`))
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// -----------------------------------------------------------------------
|
|
339
|
+
// 11. Create issue (with graceful label handling)
|
|
340
|
+
// -----------------------------------------------------------------------
|
|
341
|
+
let existingLabels = labels
|
|
342
|
+
let missingLabels = []
|
|
343
|
+
|
|
344
|
+
if (labels.length > 0) {
|
|
345
|
+
try {
|
|
346
|
+
const repoLabelNames = listLabels(repo)
|
|
347
|
+
existingLabels = labels.filter((l) => repoLabelNames.includes(l))
|
|
348
|
+
missingLabels = labels.filter((l) => !repoLabelNames.includes(l))
|
|
349
|
+
} catch {
|
|
350
|
+
// If listLabels fails, proceed with all labels (original behavior)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const createSpinner = ora('Creating issue...').start()
|
|
355
|
+
let issue
|
|
356
|
+
try {
|
|
357
|
+
issue = await createIssue(repo, { title, body, labels: existingLabels })
|
|
358
|
+
createSpinner.succeed(`Issue created: ${chalk.cyan(issue.url)}`)
|
|
359
|
+
} catch (err) {
|
|
360
|
+
// GitHub API failures are NOT safety failures โ do not record to circuit
|
|
361
|
+
createSpinner.fail(`Failed to create issue: ${err.message}`)
|
|
362
|
+
throw new Error(`Failed to create issue: ${err.message}`)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (missingLabels.length > 0) {
|
|
366
|
+
console.log(
|
|
367
|
+
chalk.yellow(`Labels not found: ${missingLabels.join(', ')} โ issue created without them`),
|
|
368
|
+
)
|
|
369
|
+
const createMissing = await confirm({
|
|
370
|
+
message: 'Create missing labels and apply them?',
|
|
371
|
+
default: true,
|
|
372
|
+
})
|
|
373
|
+
if (createMissing) {
|
|
374
|
+
for (const labelName of missingLabels) {
|
|
375
|
+
const labelSpinner = ora(`Creating label "${labelName}"...`).start()
|
|
376
|
+
try {
|
|
377
|
+
createLabel(repo, labelName)
|
|
378
|
+
labelSpinner.succeed(`Label created: ${labelName}`)
|
|
379
|
+
} catch (err) {
|
|
380
|
+
labelSpinner.warn(`Failed to create label "${labelName}": ${err.message}`)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
addLabelsToIssue(repo, issue.number, missingLabels)
|
|
385
|
+
console.log(chalk.green('โ Labels created and applied'))
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.warn(chalk.yellow(`Failed to apply labels: ${err.message}`))
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// -----------------------------------------------------------------------
|
|
393
|
+
// 12. Record success
|
|
394
|
+
// -----------------------------------------------------------------------
|
|
395
|
+
try {
|
|
396
|
+
await recordCreation(repo, {
|
|
397
|
+
title,
|
|
398
|
+
body,
|
|
399
|
+
issueNumber: issue.number,
|
|
284
400
|
agent,
|
|
285
|
-
session: session ?? undefined,
|
|
286
|
-
trigger: 'cli-create',
|
|
287
|
-
fingerprint: `sha256:${fingerprint}`,
|
|
288
401
|
})
|
|
402
|
+
} catch (err) {
|
|
403
|
+
// Non-fatal โ dedup DB write failure shouldn't abort the command
|
|
404
|
+
console.warn(chalk.dim(`[dedup] Failed to record fingerprint: ${err.message}`))
|
|
405
|
+
}
|
|
289
406
|
|
|
290
|
-
|
|
407
|
+
recordSuccess(repo, agent)
|
|
291
408
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
:
|
|
409
|
+
// -----------------------------------------------------------------------
|
|
410
|
+
// Run postCreate hook if configured
|
|
411
|
+
// -----------------------------------------------------------------------
|
|
412
|
+
const hookCmd = config.hooks?.postCreate
|
|
413
|
+
if (hookCmd) {
|
|
414
|
+
runPostCreateHook(hookCmd, {
|
|
415
|
+
repo,
|
|
416
|
+
issueNumber: issue.number,
|
|
417
|
+
issueUrl: issue.url,
|
|
418
|
+
})
|
|
419
|
+
}
|
|
301
420
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
}
|
|
421
|
+
return { url: issue.url, number: issue.number }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Command
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
317
427
|
|
|
428
|
+
export const createCommand = new Command('create')
|
|
429
|
+
.description('Create a new GitHub issue')
|
|
430
|
+
.option('--repo <repo>', 'Repository override (owner/name)')
|
|
431
|
+
.option('--template <name>', 'Template to use (bug, feature, security, performance, refactor)')
|
|
432
|
+
.option('--agent <name>', 'Agent identifier for attribution (default: human)')
|
|
433
|
+
.option('--session <id>', 'Session ID for attribution')
|
|
434
|
+
.option('--title <title>', 'Issue title (skips interactive prompt)')
|
|
435
|
+
.option('--body <body>', 'Issue body / description (skips interactive prompt)')
|
|
436
|
+
.option('--labels <labels>', 'Comma-separated labels to apply')
|
|
437
|
+
.option('--force', 'Skip dedup warnings (still blocks on exact matches)')
|
|
438
|
+
.option('--dry-run', 'Check dedup and safety without creating the issue')
|
|
439
|
+
.option('--no-enhance', 'Skip AI enhancement, use rendered template as-is')
|
|
440
|
+
.option(
|
|
441
|
+
'--batch <file>',
|
|
442
|
+
'Create multiple issues from a JSON file (title, body, template, labels, agent, session per item)',
|
|
443
|
+
)
|
|
444
|
+
.action(async (opts) => {
|
|
318
445
|
// -----------------------------------------------------------------------
|
|
319
|
-
//
|
|
446
|
+
// Batch mode
|
|
320
447
|
// -----------------------------------------------------------------------
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
448
|
+
if (opts.batch) {
|
|
449
|
+
let items
|
|
450
|
+
try {
|
|
451
|
+
items = JSON.parse(fs.readFileSync(opts.batch, 'utf8'))
|
|
452
|
+
} catch (err) {
|
|
453
|
+
console.error(chalk.red(`Failed to read batch file: ${err.message}`))
|
|
454
|
+
process.exit(1)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!Array.isArray(items)) {
|
|
458
|
+
console.error(chalk.red('Batch file must contain a JSON array'))
|
|
459
|
+
process.exit(1)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const results = []
|
|
463
|
+
for (const [i, item] of items.entries()) {
|
|
464
|
+
console.log(chalk.dim(`\n[${i + 1}/${items.length}] "${item.title}"`))
|
|
465
|
+
// item fields override CLI opts; labels array โ comma-string for runCreate
|
|
466
|
+
const itemOpts = {
|
|
467
|
+
...opts,
|
|
468
|
+
...item,
|
|
469
|
+
labels: item.labels?.join(',') ?? opts.labels,
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const result = await runCreate(itemOpts)
|
|
473
|
+
results.push({ title: item.title, url: result?.url, ok: true })
|
|
474
|
+
} catch (err) {
|
|
475
|
+
results.push({ title: item.title, error: err.message, ok: false })
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
console.log(chalk.bold('\nBatch summary:'))
|
|
480
|
+
for (const r of results) {
|
|
481
|
+
if (r.ok) console.log(chalk.green(` โ ${r.title}`) + chalk.dim(` โ ${r.url}`))
|
|
482
|
+
else console.log(chalk.red(` โ ${r.title}`) + chalk.dim(` โ ${r.error}`))
|
|
483
|
+
}
|
|
484
|
+
process.exit(results.every((r) => r.ok) ? 0 : 1)
|
|
485
|
+
return
|
|
330
486
|
}
|
|
331
487
|
|
|
332
488
|
// -----------------------------------------------------------------------
|
|
333
|
-
//
|
|
489
|
+
// Single mode
|
|
334
490
|
// -----------------------------------------------------------------------
|
|
335
491
|
try {
|
|
336
|
-
await
|
|
337
|
-
|
|
338
|
-
body,
|
|
339
|
-
issueNumber: issue.number,
|
|
340
|
-
agent,
|
|
341
|
-
})
|
|
492
|
+
const result = await runCreate(opts)
|
|
493
|
+
if (result === null) process.exit(0) // dry-run
|
|
342
494
|
} 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
|
-
})
|
|
495
|
+
process.exit(err.isAbort ? 0 : 1)
|
|
359
496
|
}
|
|
360
497
|
})
|
package/src/commands/open.js
CHANGED
|
@@ -6,7 +6,9 @@ import chalk from 'chalk'
|
|
|
6
6
|
export const openCommand = new Command('open')
|
|
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
|
@@ -101,7 +101,7 @@ export function buildAttribution(opts = {}) {
|
|
|
101
101
|
if (model != null) meta.model = String(model)
|
|
102
102
|
|
|
103
103
|
// Process info
|
|
104
|
-
|
|
104
|
+
if (pid != null) meta.pid = Number(pid)
|
|
105
105
|
meta.trigger = trigger != null ? String(trigger) : 'cli-create'
|
|
106
106
|
|
|
107
107
|
// Deduplication handles
|
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
|