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.
@@ -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
+ }