tissues 0.5.2 → 0.6.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 +94 -40
- package/package.json +3 -4
- package/src/cli.js +26 -22
- package/src/commands/ai.js +268 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +1035 -12
- package/src/commands/create.js +523 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/enhancements.js +282 -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 +68 -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 +75 -0
- package/src/lib/ai/enhance.js +107 -0
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +97 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +216 -0
- package/src/lib/ai/steps.js +492 -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 +67 -32
- package/src/lib/defaults.js +54 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/gh.js +102 -21
- 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,216 @@
|
|
|
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
|
+
* Merge custom providers from both old (`ai.commands`) and new (`ai.providers`) keys,
|
|
19
|
+
* and migrate the legacy singleton `ai.command` into the registry.
|
|
20
|
+
*
|
|
21
|
+
* `ai.providers` wins on conflict with `ai.commands`.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} ai - the `config.ai` object
|
|
24
|
+
* @returns {object} merged custom providers map
|
|
25
|
+
*/
|
|
26
|
+
export function migrateProviderConfig(ai) {
|
|
27
|
+
const merged = {}
|
|
28
|
+
|
|
29
|
+
// Legacy: ai.commands (old key)
|
|
30
|
+
if (ai.commands && typeof ai.commands === 'object') {
|
|
31
|
+
Object.assign(merged, ai.commands)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Canonical: ai.providers (new key, wins on conflict)
|
|
35
|
+
if (ai.providers && typeof ai.providers === 'object') {
|
|
36
|
+
Object.assign(merged, ai.providers)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Note: ai.command (legacy singleton) is NOT merged here — the built-in
|
|
40
|
+
// "command" provider already reads it via buildAdapterConfig.
|
|
41
|
+
|
|
42
|
+
return merged
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve which adapter + model to use for a given issue context.
|
|
47
|
+
*
|
|
48
|
+
* Resolution order:
|
|
49
|
+
* 1. Explicit provider/model from context (CLI flags)
|
|
50
|
+
* 2. Routing rules from config (first match wins)
|
|
51
|
+
* 3. Default provider + model from config
|
|
52
|
+
*
|
|
53
|
+
* @param {object} config - merged config object
|
|
54
|
+
* @param {{ template?: string, labels?: string[], provider?: string, model?: string, enhancements?: string[] }} context
|
|
55
|
+
* @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string, enhancements?: string[] }}
|
|
56
|
+
*/
|
|
57
|
+
export function resolveRoute(config, context = {}) {
|
|
58
|
+
const ai = config.ai || {}
|
|
59
|
+
|
|
60
|
+
let provider = context.provider || null
|
|
61
|
+
let model = context.model || null
|
|
62
|
+
let enhancements = null
|
|
63
|
+
|
|
64
|
+
// 2. Check routing rules (only if no explicit override)
|
|
65
|
+
if (!provider && Array.isArray(ai.routes)) {
|
|
66
|
+
for (const rule of ai.routes) {
|
|
67
|
+
if (matchesRule(rule.match, context)) {
|
|
68
|
+
provider = rule.provider || provider
|
|
69
|
+
model = rule.model || model
|
|
70
|
+
if (rule.enhancements) enhancements = rule.enhancements
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 3. Fall back to defaults
|
|
77
|
+
if (!provider) provider = ai.provider || 'anthropic'
|
|
78
|
+
if (!model) model = ai.model || ai.models?.[provider] || null
|
|
79
|
+
|
|
80
|
+
let AdapterClass = ADAPTER_MAP[provider]
|
|
81
|
+
let adapterConfig
|
|
82
|
+
|
|
83
|
+
if (!AdapterClass) {
|
|
84
|
+
// Check custom providers registry (merged from ai.commands + ai.providers)
|
|
85
|
+
const customProviders = migrateProviderConfig(ai)
|
|
86
|
+
const cmdEntry = customProviders[provider]
|
|
87
|
+
if (!cmdEntry) throw new Error(`Unknown AI provider: ${provider}`)
|
|
88
|
+
AdapterClass = CommandAdapter
|
|
89
|
+
adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
|
|
90
|
+
} else {
|
|
91
|
+
// Build adapter config based on provider type
|
|
92
|
+
adapterConfig = buildAdapterConfig(ai, provider)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const adapter = new AdapterClass(adapterConfig)
|
|
96
|
+
|
|
97
|
+
const result = { adapter, model }
|
|
98
|
+
if (enhancements) result.enhancements = enhancements
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve an adapter + model for a specific provider name.
|
|
104
|
+
*
|
|
105
|
+
* Checks ADAPTER_MAP first, then the merged custom providers registry.
|
|
106
|
+
* Used by the pipeline for per-step provider overrides.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} config - merged config object
|
|
109
|
+
* @param {string} providerName - provider to resolve
|
|
110
|
+
* @param {string} [modelOverride] - optional model override
|
|
111
|
+
* @returns {{ adapter: import('./adapters/base.js').BaseAdapter, model: string }}
|
|
112
|
+
*/
|
|
113
|
+
export function resolveProviderAdapter(config, providerName, modelOverride) {
|
|
114
|
+
const ai = config.ai || {}
|
|
115
|
+
|
|
116
|
+
let AdapterClass = ADAPTER_MAP[providerName]
|
|
117
|
+
let adapterConfig
|
|
118
|
+
|
|
119
|
+
if (!AdapterClass) {
|
|
120
|
+
const customProviders = migrateProviderConfig(ai)
|
|
121
|
+
const cmdEntry = customProviders[providerName]
|
|
122
|
+
if (!cmdEntry) throw new Error(`Unknown AI provider: ${providerName}`)
|
|
123
|
+
AdapterClass = CommandAdapter
|
|
124
|
+
adapterConfig = { command: cmdEntry.command || null, timeout: cmdEntry.timeout || undefined }
|
|
125
|
+
} else {
|
|
126
|
+
adapterConfig = buildAdapterConfig(ai, providerName)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const adapter = new AdapterClass(adapterConfig)
|
|
130
|
+
const model = modelOverride || ai.model || ai.models?.[providerName] || null
|
|
131
|
+
|
|
132
|
+
return { adapter, model }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Env var names for API key fallback (checked when config key is not set)
|
|
136
|
+
const KEY_ENV_VARS = {
|
|
137
|
+
anthropic: 'TISSUES_ANTHROPIC_KEY',
|
|
138
|
+
openai: 'TISSUES_OPENAI_KEY',
|
|
139
|
+
gemini: 'TISSUES_GEMINI_KEY',
|
|
140
|
+
'openai-compat': 'TISSUES_OPENAI_COMPAT_KEY',
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve API key: config value first, then env var fallback.
|
|
145
|
+
*/
|
|
146
|
+
function resolveApiKey(ai, provider) {
|
|
147
|
+
const configKey = provider === 'openai-compat'
|
|
148
|
+
? ai.keys?.['openai-compat']
|
|
149
|
+
: ai.keys?.[provider]
|
|
150
|
+
if (configKey) return configKey
|
|
151
|
+
|
|
152
|
+
const envVar = KEY_ENV_VARS[provider]
|
|
153
|
+
return envVar ? process.env[envVar] || null : null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the config object for a specific adapter.
|
|
158
|
+
*/
|
|
159
|
+
function buildAdapterConfig(ai, provider) {
|
|
160
|
+
switch (provider) {
|
|
161
|
+
case 'ollama':
|
|
162
|
+
return { baseUrl: ai.ollama?.url }
|
|
163
|
+
case 'openai-compat':
|
|
164
|
+
return { baseUrl: ai.custom?.url, apiKey: resolveApiKey(ai, 'openai-compat') }
|
|
165
|
+
case 'command':
|
|
166
|
+
return { command: ai.command || null }
|
|
167
|
+
default:
|
|
168
|
+
return { apiKey: resolveApiKey(ai, provider) }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if a rule's match criteria apply to the given context.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} match - e.g. { template: 'bug' } or { labels: ['P0-critical'] }
|
|
176
|
+
* @param {object} context
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
function matchesRule(match, context) {
|
|
180
|
+
if (!match) return false
|
|
181
|
+
|
|
182
|
+
if (match.template && context.template) {
|
|
183
|
+
if (match.template !== context.template) return false
|
|
184
|
+
} else if (match.template) {
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (match.labels && Array.isArray(match.labels)) {
|
|
189
|
+
const contextLabels = context.labels || []
|
|
190
|
+
const hasAny = match.labels.some((l) => contextLabels.includes(l))
|
|
191
|
+
if (!hasAny) return false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return true
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* List built-in provider names.
|
|
199
|
+
* @returns {string[]}
|
|
200
|
+
*/
|
|
201
|
+
export function listProviders() {
|
|
202
|
+
return Object.keys(ADAPTER_MAP)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* List all available provider names: built-in + custom providers from config.
|
|
207
|
+
* @param {object} config - merged config object
|
|
208
|
+
* @returns {string[]}
|
|
209
|
+
*/
|
|
210
|
+
export function listAllProviders(config) {
|
|
211
|
+
const builtIn = Object.keys(ADAPTER_MAP)
|
|
212
|
+
const ai = config?.ai || {}
|
|
213
|
+
const customProviders = migrateProviderConfig(ai)
|
|
214
|
+
const custom = Object.keys(customProviders)
|
|
215
|
+
return [...builtIn, ...custom.filter((k) => !builtIn.includes(k))]
|
|
216
|
+
}
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline step definitions for multi-step AI issue enhancement.
|
|
3
|
+
*
|
|
4
|
+
* Each step is a plain object with:
|
|
5
|
+
* - name: unique identifier
|
|
6
|
+
* - displayName: human-readable label for progress display
|
|
7
|
+
* - maxTokens: token budget for this step's LLM call
|
|
8
|
+
* - shouldRun: (ctx, stepConfig) => boolean — whether to run in 'auto' mode
|
|
9
|
+
* - buildMessages: (ctx) => [{ role, content }] — prompt for the LLM
|
|
10
|
+
* - parseResponse: (raw, ctx) => void — parse LLM output and mutate ctx
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { buildBodyGuidance } from './body-template.js'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function tryParseJSON(raw) {
|
|
20
|
+
let cleaned = raw.trim()
|
|
21
|
+
// Strip markdown fences if present
|
|
22
|
+
if (cleaned.startsWith('```')) {
|
|
23
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '')
|
|
24
|
+
}
|
|
25
|
+
return JSON.parse(cleaned)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function contextSummary(ctx) {
|
|
29
|
+
const parts = [`Title: ${ctx.title}`]
|
|
30
|
+
if (ctx.description) parts.push(`Description: ${ctx.description}`)
|
|
31
|
+
if (ctx.instructions) parts.push(`User instructions: ${ctx.instructions}`)
|
|
32
|
+
if (ctx.templateBody) parts.push(`Template:\n${ctx.templateBody}`)
|
|
33
|
+
if (ctx.labels?.length) parts.push(`Labels: ${ctx.labels.join(', ')}`)
|
|
34
|
+
return parts.join('\n\n')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function priorStepsSummary(ctx) {
|
|
38
|
+
const parts = []
|
|
39
|
+
if (ctx.dedupScore != null) {
|
|
40
|
+
parts.push(`Dedup confidence: ${ctx.dedupScore.confidence}/100 (${ctx.dedupScore.level})`)
|
|
41
|
+
}
|
|
42
|
+
if (ctx.structuredContext) {
|
|
43
|
+
parts.push(`Context: ${JSON.stringify(ctx.structuredContext)}`)
|
|
44
|
+
}
|
|
45
|
+
if (ctx.scopeAnalysis) {
|
|
46
|
+
parts.push(`Scope: ${JSON.stringify(ctx.scopeAnalysis)}`)
|
|
47
|
+
}
|
|
48
|
+
if (ctx.complexity != null) {
|
|
49
|
+
parts.push(`Complexity: ${ctx.complexity}/10 — ${ctx.complexityRationale || ''}`)
|
|
50
|
+
}
|
|
51
|
+
if (ctx.risk != null) {
|
|
52
|
+
parts.push(`Risk: ${ctx.risk}/10 — ${ctx.riskRationale || ''}`)
|
|
53
|
+
}
|
|
54
|
+
if (ctx.aiLabels?.length) {
|
|
55
|
+
parts.push(`AI labels: ${ctx.aiLabels.join(', ')}`)
|
|
56
|
+
}
|
|
57
|
+
return parts.length > 0 ? '\n\nPrior analysis:\n' + parts.join('\n') : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Step: triage — extract title + description from freeform input
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const triageStep = {
|
|
65
|
+
name: 'triage',
|
|
66
|
+
displayName: 'Input analyzed',
|
|
67
|
+
maxTokens: 1024,
|
|
68
|
+
|
|
69
|
+
shouldRun(ctx) {
|
|
70
|
+
return !!(ctx.rawInput && ctx.rawInput.length > 0)
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
buildMessages(ctx) {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
role: 'system',
|
|
77
|
+
content: [
|
|
78
|
+
'You extract a structured GitHub issue title and description from freeform user input.',
|
|
79
|
+
'Return a JSON object with:',
|
|
80
|
+
' title: string — concise issue title, ≤80 characters, imperative mood (e.g. "Fix login timeout on Safari")',
|
|
81
|
+
' description: string — structured description expanding on the input, in markdown',
|
|
82
|
+
'The title must capture the core intent. The description should organize and expand the raw input into clear context.',
|
|
83
|
+
'Return ONLY valid JSON.',
|
|
84
|
+
].join('\n'),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
role: 'user',
|
|
88
|
+
content: ctx.rawInput,
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
parseResponse(raw, ctx) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = tryParseJSON(raw)
|
|
96
|
+
if (parsed.title && typeof parsed.title === 'string') {
|
|
97
|
+
ctx.title = parsed.title.slice(0, 80)
|
|
98
|
+
}
|
|
99
|
+
if (parsed.description && typeof parsed.description === 'string') {
|
|
100
|
+
ctx.description = parsed.description
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Parse failure — keep splitInput() values as fallback
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Step: dedup
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
const dedupStep = {
|
|
113
|
+
name: 'dedup',
|
|
114
|
+
displayName: 'Duplicate check',
|
|
115
|
+
maxTokens: 1024,
|
|
116
|
+
|
|
117
|
+
shouldRun() {
|
|
118
|
+
// Disabled: deterministic dedup (Jaccard) runs before the pipeline.
|
|
119
|
+
// The AI step hallucinated connections between unrelated issues (#56).
|
|
120
|
+
// Keep the code for future opt-in via config.
|
|
121
|
+
return false
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
buildMessages(ctx) {
|
|
125
|
+
const existing = (ctx.existingIssues || [])
|
|
126
|
+
.slice(0, 20)
|
|
127
|
+
.map((i) => `#${i.number}: ${i.title}`)
|
|
128
|
+
.join('\n')
|
|
129
|
+
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
role: 'system',
|
|
133
|
+
content: [
|
|
134
|
+
'You are a duplicate detection system for GitHub issues.',
|
|
135
|
+
'Compare the new issue against existing open issues.',
|
|
136
|
+
'Return a JSON object with:',
|
|
137
|
+
' confidence: number 0-100 (how likely this is a duplicate)',
|
|
138
|
+
' level: "high" | "medium" | "low" | "none"',
|
|
139
|
+
' matches: array of { number, reason } for similar issues',
|
|
140
|
+
'Return ONLY valid JSON.',
|
|
141
|
+
].join('\n'),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
role: 'user',
|
|
145
|
+
content: `New issue:\nTitle: ${ctx.title}\nDescription: ${ctx.description || 'none'}\n\nExisting open issues:\n${existing}`,
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
parseResponse(raw, ctx) {
|
|
151
|
+
try {
|
|
152
|
+
const parsed = tryParseJSON(raw)
|
|
153
|
+
ctx.dedupScore = {
|
|
154
|
+
confidence: Math.min(100, Math.max(0, Number(parsed.confidence) || 0)),
|
|
155
|
+
level: ['high', 'medium', 'low', 'none'].includes(parsed.level) ? parsed.level : 'none',
|
|
156
|
+
matches: Array.isArray(parsed.matches) ? parsed.matches : [],
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
ctx.dedupScore = null
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Step: context
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
const contextStep = {
|
|
169
|
+
name: 'context',
|
|
170
|
+
displayName: 'Context gathered',
|
|
171
|
+
maxTokens: 1024,
|
|
172
|
+
|
|
173
|
+
shouldRun() {
|
|
174
|
+
return true
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
buildMessages(ctx) {
|
|
178
|
+
return [
|
|
179
|
+
{
|
|
180
|
+
role: 'system',
|
|
181
|
+
content: [
|
|
182
|
+
'You are an expert at understanding software issues.',
|
|
183
|
+
'Extract structured context from the issue description.',
|
|
184
|
+
'Return a JSON object with:',
|
|
185
|
+
' problem: string — the core problem in one sentence',
|
|
186
|
+
' files: string[] — file paths mentioned or implied',
|
|
187
|
+
' errors: string[] — error messages mentioned',
|
|
188
|
+
' sessionContext: string — any relevant session/environment context',
|
|
189
|
+
'Return ONLY valid JSON.',
|
|
190
|
+
].join('\n'),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
role: 'user',
|
|
194
|
+
content: contextSummary(ctx),
|
|
195
|
+
},
|
|
196
|
+
]
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
parseResponse(raw, ctx) {
|
|
200
|
+
try {
|
|
201
|
+
const parsed = tryParseJSON(raw)
|
|
202
|
+
ctx.structuredContext = {
|
|
203
|
+
problem: parsed.problem || '',
|
|
204
|
+
files: Array.isArray(parsed.files) ? parsed.files : [],
|
|
205
|
+
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
|
206
|
+
sessionContext: parsed.sessionContext || '',
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
ctx.structuredContext = null
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Step: scope
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
const scopeStep = {
|
|
219
|
+
name: 'scope',
|
|
220
|
+
displayName: 'Scope analyzed',
|
|
221
|
+
maxTokens: 1024,
|
|
222
|
+
|
|
223
|
+
shouldRun(ctx) {
|
|
224
|
+
// Only run if description mentions code/files or context step found files
|
|
225
|
+
const desc = (ctx.description || '').toLowerCase()
|
|
226
|
+
const hasCodeMentions = /\.(js|ts|py|go|rs|rb|java|css|html|json|yml|yaml|md)\b/.test(desc) ||
|
|
227
|
+
/\b(file|module|component|function|class|import|require)\b/.test(desc)
|
|
228
|
+
const hasContextFiles = ctx.structuredContext?.files?.length > 0
|
|
229
|
+
return hasCodeMentions || hasContextFiles
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
buildMessages(ctx) {
|
|
233
|
+
return [
|
|
234
|
+
{
|
|
235
|
+
role: 'system',
|
|
236
|
+
content: [
|
|
237
|
+
'You are a software scope analyzer.',
|
|
238
|
+
'Given an issue description and context, identify the files and areas affected.',
|
|
239
|
+
'Return a JSON object with:',
|
|
240
|
+
' files: array of { path: string, purpose: string, deps: string[] }',
|
|
241
|
+
' affectedAreas: string[] — high-level areas (e.g. "auth", "UI", "database")',
|
|
242
|
+
'Return ONLY valid JSON.',
|
|
243
|
+
].join('\n'),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
role: 'user',
|
|
247
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
248
|
+
},
|
|
249
|
+
]
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
parseResponse(raw, ctx) {
|
|
253
|
+
try {
|
|
254
|
+
const parsed = tryParseJSON(raw)
|
|
255
|
+
ctx.scopeAnalysis = {
|
|
256
|
+
files: Array.isArray(parsed.files) ? parsed.files : [],
|
|
257
|
+
affectedAreas: Array.isArray(parsed.affectedAreas) ? parsed.affectedAreas : [],
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
ctx.scopeAnalysis = null
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Step: complexity
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
const complexityStep = {
|
|
270
|
+
name: 'complexity',
|
|
271
|
+
displayName: 'Complexity scored',
|
|
272
|
+
maxTokens: 512,
|
|
273
|
+
|
|
274
|
+
shouldRun(ctx) {
|
|
275
|
+
// Run if we have scope analysis or a non-trivial description
|
|
276
|
+
return ctx.scopeAnalysis != null || (ctx.description || '').length > 50
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
buildMessages(ctx) {
|
|
280
|
+
return [
|
|
281
|
+
{
|
|
282
|
+
role: 'system',
|
|
283
|
+
content: [
|
|
284
|
+
'You assess implementation complexity for GitHub issues.',
|
|
285
|
+
'Return a JSON object with:',
|
|
286
|
+
' score: number 1-10 (1=trivial, 10=massive refactor)',
|
|
287
|
+
' rationale: string — one sentence explaining the score',
|
|
288
|
+
'Return ONLY valid JSON.',
|
|
289
|
+
].join('\n'),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
role: 'user',
|
|
293
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
294
|
+
},
|
|
295
|
+
]
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
parseResponse(raw, ctx) {
|
|
299
|
+
try {
|
|
300
|
+
const parsed = tryParseJSON(raw)
|
|
301
|
+
ctx.complexity = Math.min(10, Math.max(1, Number(parsed.score) || 5))
|
|
302
|
+
ctx.complexityRationale = parsed.rationale || ''
|
|
303
|
+
} catch {
|
|
304
|
+
ctx.complexity = null
|
|
305
|
+
ctx.complexityRationale = null
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Step: risk
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
const riskStep = {
|
|
315
|
+
name: 'risk',
|
|
316
|
+
displayName: 'Risk assessed',
|
|
317
|
+
maxTokens: 512,
|
|
318
|
+
|
|
319
|
+
shouldRun(ctx) {
|
|
320
|
+
return ctx.scopeAnalysis != null || (ctx.description || '').length > 50
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
buildMessages(ctx) {
|
|
324
|
+
return [
|
|
325
|
+
{
|
|
326
|
+
role: 'system',
|
|
327
|
+
content: [
|
|
328
|
+
'You assess implementation risk for GitHub issues.',
|
|
329
|
+
'Consider: breaking changes, data loss potential, security implications, blast radius.',
|
|
330
|
+
'Return a JSON object with:',
|
|
331
|
+
' score: number 1-10 (1=no risk, 10=extremely risky)',
|
|
332
|
+
' rationale: string — one sentence explaining the score',
|
|
333
|
+
'Return ONLY valid JSON.',
|
|
334
|
+
].join('\n'),
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
role: 'user',
|
|
338
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
339
|
+
},
|
|
340
|
+
]
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
parseResponse(raw, ctx) {
|
|
344
|
+
try {
|
|
345
|
+
const parsed = tryParseJSON(raw)
|
|
346
|
+
ctx.risk = Math.min(10, Math.max(1, Number(parsed.score) || 3))
|
|
347
|
+
ctx.riskRationale = parsed.rationale || ''
|
|
348
|
+
} catch {
|
|
349
|
+
ctx.risk = null
|
|
350
|
+
ctx.riskRationale = null
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Step: labels
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
const labelsStep = {
|
|
360
|
+
name: 'labels',
|
|
361
|
+
displayName: 'Labels suggested',
|
|
362
|
+
maxTokens: 512,
|
|
363
|
+
|
|
364
|
+
shouldRun(ctx) {
|
|
365
|
+
// Run if there are repo labels to choose from
|
|
366
|
+
return ctx.repoLabels?.length > 0
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
buildMessages(ctx) {
|
|
370
|
+
const available = (ctx.repoLabels || []).join(', ')
|
|
371
|
+
return [
|
|
372
|
+
{
|
|
373
|
+
role: 'system',
|
|
374
|
+
content: [
|
|
375
|
+
'You suggest GitHub labels for issues.',
|
|
376
|
+
`Available labels in this repo: ${available}`,
|
|
377
|
+
'Return a JSON object with:',
|
|
378
|
+
' labels: string[] — labels to apply (must be from the available list)',
|
|
379
|
+
' reasoning: string — brief explanation',
|
|
380
|
+
'Only suggest labels that genuinely fit. Return ONLY valid JSON.',
|
|
381
|
+
].join('\n'),
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
role: 'user',
|
|
385
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
386
|
+
},
|
|
387
|
+
]
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
parseResponse(raw, ctx) {
|
|
391
|
+
try {
|
|
392
|
+
const parsed = tryParseJSON(raw)
|
|
393
|
+
const suggested = Array.isArray(parsed.labels) ? parsed.labels : []
|
|
394
|
+
// Filter to only labels that actually exist in the repo
|
|
395
|
+
const valid = ctx.repoLabels || []
|
|
396
|
+
ctx.aiLabels = suggested.filter((l) => valid.includes(l))
|
|
397
|
+
} catch {
|
|
398
|
+
ctx.aiLabels = null
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Step: format
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
const formatStep = {
|
|
408
|
+
name: 'format',
|
|
409
|
+
displayName: 'Body formatted',
|
|
410
|
+
maxTokens: 4096,
|
|
411
|
+
|
|
412
|
+
shouldRun() {
|
|
413
|
+
return true
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
buildMessages(ctx) {
|
|
417
|
+
const guidance = buildBodyGuidance(ctx)
|
|
418
|
+
return [
|
|
419
|
+
{
|
|
420
|
+
role: 'system',
|
|
421
|
+
content: [
|
|
422
|
+
'You are an expert at writing clear, well-structured GitHub issues.',
|
|
423
|
+
'Given an issue title, description, and analysis from prior steps,',
|
|
424
|
+
'write a complete issue body in markdown.',
|
|
425
|
+
'',
|
|
426
|
+
guidance,
|
|
427
|
+
].join('\n'),
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
role: 'user',
|
|
431
|
+
content: contextSummary(ctx) + priorStepsSummary(ctx),
|
|
432
|
+
},
|
|
433
|
+
]
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
parseResponse(raw, ctx) {
|
|
437
|
+
// The format step returns raw markdown, not JSON
|
|
438
|
+
// Strip wrapping code fences (```markdown ... ```) that LLMs often add
|
|
439
|
+
let cleaned = raw.trim()
|
|
440
|
+
const fenceMatch = cleaned.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
|
|
441
|
+
if (fenceMatch) cleaned = fenceMatch[1].trim()
|
|
442
|
+
if (cleaned.length > 0) {
|
|
443
|
+
ctx.body = cleaned
|
|
444
|
+
}
|
|
445
|
+
// If empty, ctx.body stays as whatever it was (template fallback)
|
|
446
|
+
},
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// Exports
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* All pipeline steps in execution order.
|
|
455
|
+
*/
|
|
456
|
+
export const ALL_STEPS = [
|
|
457
|
+
triageStep,
|
|
458
|
+
dedupStep,
|
|
459
|
+
contextStep,
|
|
460
|
+
scopeStep,
|
|
461
|
+
complexityStep,
|
|
462
|
+
riskStep,
|
|
463
|
+
labelsStep,
|
|
464
|
+
formatStep,
|
|
465
|
+
]
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Map of built-in step name → step object.
|
|
469
|
+
* Used by the enhancement adapter to bridge built-in logic with custom prompts.
|
|
470
|
+
*/
|
|
471
|
+
export const BUILT_IN_STEPS_MAP = {
|
|
472
|
+
triage: triageStep,
|
|
473
|
+
dedup: dedupStep,
|
|
474
|
+
context: contextStep,
|
|
475
|
+
scope: scopeStep,
|
|
476
|
+
complexity: complexityStep,
|
|
477
|
+
risk: riskStep,
|
|
478
|
+
labels: labelsStep,
|
|
479
|
+
format: formatStep,
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get a step by name.
|
|
484
|
+
* @param {string} name
|
|
485
|
+
* @returns {object|undefined}
|
|
486
|
+
*/
|
|
487
|
+
export function getStep(name) {
|
|
488
|
+
return ALL_STEPS.find((s) => s.name === name)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Re-export helpers for use by the enhancement adapter
|
|
492
|
+
export { tryParseJSON, contextSummary, priorStepsSummary }
|