tissues 0.5.1 → 0.6.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 +93 -39
- package/package.json +3 -4
- package/src/cli.js +24 -22
- package/src/commands/ai.js +266 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +961 -12
- package/src/commands/create.js +516 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/list.js +7 -5
- package/src/commands/status.js +81 -19
- package/src/commands/templates.js +157 -0
- package/src/lib/ai/adapters/anthropic.js +52 -0
- package/src/lib/ai/adapters/base.js +45 -0
- package/src/lib/ai/adapters/command.js +58 -0
- package/src/lib/ai/adapters/gemini.js +56 -0
- package/src/lib/ai/adapters/ollama.js +60 -0
- package/src/lib/ai/adapters/openai-compat.js +51 -0
- package/src/lib/ai/adapters/openai.js +44 -0
- package/src/lib/ai/body-template.js +60 -0
- package/src/lib/ai/enhance.js +70 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +79 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +128 -0
- package/src/lib/ai/steps.js +472 -0
- package/src/lib/attribution.js +18 -179
- package/src/lib/clipboard.js +147 -0
- package/src/lib/color.js +9 -0
- package/src/lib/dedup.js +33 -4
- package/src/lib/defaults.js +38 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/gh.js +86 -11
- package/src/lib/repo-picker.js +2 -0
- package/src/lib/safety.js +1 -1
- package/src/lib/templates.js +8 -12
- package/src/lib/theme.js +9 -0
- package/src/commands/use.js +0 -19
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline runner for multi-step AI issue enhancement.
|
|
3
|
+
*
|
|
4
|
+
* Executes steps sequentially, accumulating state in a shared PipelineContext.
|
|
5
|
+
* Failed steps log a warning and continue (graceful degradation).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run a sequence of pipeline steps.
|
|
10
|
+
*
|
|
11
|
+
* @param {object[]} steps - array of step objects (from steps.js)
|
|
12
|
+
* @param {object} ctx - mutable PipelineContext, accumulates results
|
|
13
|
+
* @param {{ adapter: object, model: string }} route - resolved adapter + model
|
|
14
|
+
* @param {object} [budgets] - token budget config
|
|
15
|
+
* @param {object} [callbacks] - { onStepStart, onStepDone, onStepSkip, onStepFail }
|
|
16
|
+
* @param {{ checkBudgets: Function, recordUsage: Function }} [budgetHelpers]
|
|
17
|
+
* @returns {Promise<object>} the final ctx
|
|
18
|
+
*/
|
|
19
|
+
export async function runPipeline(steps, ctx, route, budgets, callbacks, budgetHelpers) {
|
|
20
|
+
const { adapter, model } = route
|
|
21
|
+
const cb = callbacks || {}
|
|
22
|
+
const { checkBudgets, recordUsage } = budgetHelpers || {}
|
|
23
|
+
|
|
24
|
+
let budgetExhausted = false
|
|
25
|
+
|
|
26
|
+
for (const step of steps) {
|
|
27
|
+
// If budget was exhausted, skip all remaining steps except format (which degrades gracefully)
|
|
28
|
+
if (budgetExhausted && step.name !== 'format') {
|
|
29
|
+
cb.onStepSkip?.(step, 'budget exhausted')
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check shouldRun
|
|
34
|
+
const stepConfig = ctx._stepConfigs?.[step.name] || 'auto'
|
|
35
|
+
if (stepConfig === 'never') {
|
|
36
|
+
cb.onStepSkip?.(step, 'disabled')
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (stepConfig === 'auto' && !step.shouldRun(ctx)) {
|
|
40
|
+
cb.onStepSkip?.(step, 'auto-skipped')
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
cb.onStepStart?.(step)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// Check budget before the call (skip for format when budget already exhausted — it degrades gracefully)
|
|
48
|
+
if (checkBudgets && budgets && !budgetExhausted) {
|
|
49
|
+
try {
|
|
50
|
+
checkBudgets(budgets, step.maxTokens)
|
|
51
|
+
} catch {
|
|
52
|
+
budgetExhausted = true
|
|
53
|
+
cb.onStepFail?.(step, new Error('Token budget exceeded'))
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build messages and call the adapter
|
|
59
|
+
const messages = step.buildMessages(ctx)
|
|
60
|
+
const raw = await adapter.complete(messages, { model, maxTokens: step.maxTokens })
|
|
61
|
+
|
|
62
|
+
// Record approximate usage
|
|
63
|
+
if (recordUsage) {
|
|
64
|
+
const approxTokens = Math.ceil(raw.length / 4)
|
|
65
|
+
recordUsage(approxTokens)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Parse response and update context
|
|
69
|
+
step.parseResponse(raw, ctx)
|
|
70
|
+
|
|
71
|
+
cb.onStepDone?.(step, ctx)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
cb.onStepFail?.(step, err)
|
|
74
|
+
// Non-fatal — continue to next step
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return ctx
|
|
79
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build messages array for AI enhancement of an issue body.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} title - issue title
|
|
5
|
+
* @param {string} description - actual issue content (what the issue is about)
|
|
6
|
+
* @param {string} templateBody - already-rendered template body
|
|
7
|
+
* @param {string} [instructions] - optional AI prompt guidance (not issue content)
|
|
8
|
+
* @returns {Array<{ role: 'system'|'user', content: string }>}
|
|
9
|
+
*/
|
|
10
|
+
export function buildMessages(title, description, templateBody, instructions) {
|
|
11
|
+
const systemContent = [
|
|
12
|
+
'You are an expert at writing clear, well-structured GitHub issues.',
|
|
13
|
+
'Given an issue title, description, and a rendered template, improve the issue body.',
|
|
14
|
+
'Fill in template sections with relevant detail inferred from the title and description.',
|
|
15
|
+
'Keep the markdown structure intact. Be concise and actionable.',
|
|
16
|
+
'Return ONLY the improved issue body markdown — no preamble, no explanation, no code fences.',
|
|
17
|
+
]
|
|
18
|
+
if (instructions) {
|
|
19
|
+
systemContent.push('', 'Additional instructions from the user:', instructions)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const userContent = [
|
|
23
|
+
`Title: ${title}`,
|
|
24
|
+
'',
|
|
25
|
+
description ? `Description: ${description}` : '',
|
|
26
|
+
'',
|
|
27
|
+
'Template body to improve:',
|
|
28
|
+
'```',
|
|
29
|
+
templateBody,
|
|
30
|
+
'```',
|
|
31
|
+
]
|
|
32
|
+
.filter((line) => line !== undefined)
|
|
33
|
+
.join('\n')
|
|
34
|
+
|
|
35
|
+
return [
|
|
36
|
+
{ role: 'system', content: systemContent.join('\n') },
|
|
37
|
+
{ role: 'user', content: userContent },
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { AnthropicAdapter } from './adapters/anthropic.js'
|
|
2
|
+
import { OpenAIAdapter } from './adapters/openai.js'
|
|
3
|
+
import { GeminiAdapter } from './adapters/gemini.js'
|
|
4
|
+
import { OllamaAdapter } from './adapters/ollama.js'
|
|
5
|
+
import { OpenAICompatAdapter } from './adapters/openai-compat.js'
|
|
6
|
+
import { CommandAdapter } from './adapters/command.js'
|
|
7
|
+
|
|
8
|
+
const ADAPTER_MAP = {
|
|
9
|
+
anthropic: AnthropicAdapter,
|
|
10
|
+
openai: OpenAIAdapter,
|
|
11
|
+
gemini: GeminiAdapter,
|
|
12
|
+
ollama: OllamaAdapter,
|
|
13
|
+
'openai-compat': OpenAICompatAdapter,
|
|
14
|
+
command: CommandAdapter,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve which adapter + model to use for a given issue context.
|
|
19
|
+
*
|
|
20
|
+
* Resolution order:
|
|
21
|
+
* 1. Explicit provider/model from context (CLI flags)
|
|
22
|
+
* 2. Routing rules from config (first match wins)
|
|
23
|
+
* 3. Default provider + model from config
|
|
24
|
+
*
|
|
25
|
+
* @param {object} config - merged config object
|
|
26
|
+
* @param {{ template?: string, labels?: string[], provider?: string, model?: string }} context
|
|
27
|
+
* @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string }}
|
|
28
|
+
*/
|
|
29
|
+
export function resolveRoute(config, context = {}) {
|
|
30
|
+
const ai = config.ai || {}
|
|
31
|
+
|
|
32
|
+
let provider = context.provider || null
|
|
33
|
+
let model = context.model || null
|
|
34
|
+
|
|
35
|
+
// 2. Check routing rules (only if no explicit override)
|
|
36
|
+
if (!provider && Array.isArray(ai.routes)) {
|
|
37
|
+
for (const rule of ai.routes) {
|
|
38
|
+
if (matchesRule(rule.match, context)) {
|
|
39
|
+
provider = rule.provider || provider
|
|
40
|
+
model = rule.model || model
|
|
41
|
+
break
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 3. Fall back to defaults
|
|
47
|
+
if (!provider) provider = ai.provider || 'anthropic'
|
|
48
|
+
if (!model) model = ai.model || ai.models?.[provider] || null
|
|
49
|
+
|
|
50
|
+
const AdapterClass = ADAPTER_MAP[provider]
|
|
51
|
+
if (!AdapterClass) throw new Error(`Unknown AI provider: ${provider}`)
|
|
52
|
+
|
|
53
|
+
// Build adapter config based on provider type
|
|
54
|
+
const adapterConfig = buildAdapterConfig(ai, provider)
|
|
55
|
+
const adapter = new AdapterClass(adapterConfig)
|
|
56
|
+
|
|
57
|
+
return { adapter, model }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Env var names for API key fallback (checked when config key is not set)
|
|
61
|
+
const KEY_ENV_VARS = {
|
|
62
|
+
anthropic: 'TISSUES_ANTHROPIC_KEY',
|
|
63
|
+
openai: 'TISSUES_OPENAI_KEY',
|
|
64
|
+
gemini: 'TISSUES_GEMINI_KEY',
|
|
65
|
+
'openai-compat': 'TISSUES_OPENAI_COMPAT_KEY',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve API key: config value first, then env var fallback.
|
|
70
|
+
*/
|
|
71
|
+
function resolveApiKey(ai, provider) {
|
|
72
|
+
const configKey = provider === 'openai-compat'
|
|
73
|
+
? ai.keys?.['openai-compat']
|
|
74
|
+
: ai.keys?.[provider]
|
|
75
|
+
if (configKey) return configKey
|
|
76
|
+
|
|
77
|
+
const envVar = KEY_ENV_VARS[provider]
|
|
78
|
+
return envVar ? process.env[envVar] || null : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the config object for a specific adapter.
|
|
83
|
+
*/
|
|
84
|
+
function buildAdapterConfig(ai, provider) {
|
|
85
|
+
switch (provider) {
|
|
86
|
+
case 'ollama':
|
|
87
|
+
return { baseUrl: ai.ollama?.url }
|
|
88
|
+
case 'openai-compat':
|
|
89
|
+
return { baseUrl: ai.custom?.url, apiKey: resolveApiKey(ai, 'openai-compat') }
|
|
90
|
+
case 'command':
|
|
91
|
+
return { command: ai.command || null }
|
|
92
|
+
default:
|
|
93
|
+
return { apiKey: resolveApiKey(ai, provider) }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if a rule's match criteria apply to the given context.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} match - e.g. { template: 'bug' } or { labels: ['P0-critical'] }
|
|
101
|
+
* @param {object} context
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function matchesRule(match, context) {
|
|
105
|
+
if (!match) return false
|
|
106
|
+
|
|
107
|
+
if (match.template && context.template) {
|
|
108
|
+
if (match.template !== context.template) return false
|
|
109
|
+
} else if (match.template) {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (match.labels && Array.isArray(match.labels)) {
|
|
114
|
+
const contextLabels = context.labels || []
|
|
115
|
+
const hasAny = match.labels.some((l) => contextLabels.includes(l))
|
|
116
|
+
if (!hasAny) return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* List available provider names.
|
|
124
|
+
* @returns {string[]}
|
|
125
|
+
*/
|
|
126
|
+
export function listProviders() {
|
|
127
|
+
return Object.keys(ADAPTER_MAP)
|
|
128
|
+
}
|