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,44 @@
|
|
|
1
|
+
import { BaseAdapter } from './base.js'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MODEL = 'gpt-4o-mini'
|
|
4
|
+
const DEFAULT_MAX_TOKENS = 4096
|
|
5
|
+
const API_URL = 'https://api.openai.com/v1/chat/completions'
|
|
6
|
+
|
|
7
|
+
export class OpenAIAdapter extends BaseAdapter {
|
|
8
|
+
get name() {
|
|
9
|
+
return 'openai'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async complete(messages, opts = {}) {
|
|
13
|
+
const apiKey = this.config.apiKey
|
|
14
|
+
if (!apiKey) throw new Error('OpenAI API key not configured (ai.keys.openai)')
|
|
15
|
+
|
|
16
|
+
const model = opts.model || DEFAULT_MODEL
|
|
17
|
+
const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS
|
|
18
|
+
|
|
19
|
+
const body = {
|
|
20
|
+
model,
|
|
21
|
+
max_tokens: maxTokens,
|
|
22
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const res = await fetch(API_URL, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
Authorization: `Bearer ${apiKey}`,
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const text = await res.text().catch(() => '')
|
|
36
|
+
throw new Error(`OpenAI API error ${res.status}: ${this.sanitizeErrorBody(text)}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const data = await res.json()
|
|
40
|
+
const content = data.choices?.[0]?.message?.content
|
|
41
|
+
if (!content) throw new Error('OpenAI returned no content')
|
|
42
|
+
return content
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich body section guidance for the pipeline `format` step.
|
|
3
|
+
*
|
|
4
|
+
* Provides the target structure that the LLM should produce when formatting
|
|
5
|
+
* an issue body. Sections are conditionally included based on what upstream
|
|
6
|
+
* pipeline steps produced.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const RICH_BODY_SECTIONS = [
|
|
10
|
+
{ key: 'summary', heading: 'Summary', always: true },
|
|
11
|
+
{ key: 'context', heading: 'Context', requires: 'structuredContext' },
|
|
12
|
+
{ key: 'problem', heading: 'Problem', always: true },
|
|
13
|
+
{ key: 'files', heading: 'Files Involved', requires: 'scopeAnalysis' },
|
|
14
|
+
{ key: 'solution', heading: 'Proposed Solution', always: true },
|
|
15
|
+
{ key: 'acceptance', heading: 'Acceptance Criteria', always: true },
|
|
16
|
+
{ key: 'complexity', heading: 'Scope & Complexity', requires: 'complexity' },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a guidance string for the `format` step's system prompt.
|
|
21
|
+
*
|
|
22
|
+
* Only includes section headings for which upstream data exists (or that are
|
|
23
|
+
* always-on). This prevents the LLM from hallucinating sections it has no
|
|
24
|
+
* data for.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} ctx - PipelineContext accumulated so far
|
|
27
|
+
* @returns {string} markdown-formatted guidance
|
|
28
|
+
*/
|
|
29
|
+
export function buildBodyGuidance(ctx) {
|
|
30
|
+
const lines = [
|
|
31
|
+
'Structure the issue body with these markdown sections:',
|
|
32
|
+
'',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
for (const section of RICH_BODY_SECTIONS) {
|
|
36
|
+
if (section.always || ctx[section.requires] != null) {
|
|
37
|
+
lines.push(`## ${section.heading}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Include sections for custom enhancement results not covered above
|
|
42
|
+
const knownKeys = new Set([
|
|
43
|
+
'structuredContext', 'scopeAnalysis', 'complexity', 'complexityRationale',
|
|
44
|
+
'risk', 'riskRationale', 'aiLabels', 'dedupScore', 'body',
|
|
45
|
+
'rawInput', 'title', 'description', 'instructions', 'templateBody',
|
|
46
|
+
'labels', 'existingIssues', 'repoLabels', '_stepConfigs', 'triage',
|
|
47
|
+
])
|
|
48
|
+
for (const [key, value] of Object.entries(ctx)) {
|
|
49
|
+
if (!knownKeys.has(key) && value != null && !key.startsWith('_')) {
|
|
50
|
+
// Custom enhancement produced a result — add a section hint
|
|
51
|
+
const heading = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')
|
|
52
|
+
lines.push(`## ${heading}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(
|
|
57
|
+
'',
|
|
58
|
+
'Rules:',
|
|
59
|
+
'- Use bullet points for lists, not paragraphs.',
|
|
60
|
+
'- Keep each section concise (2-5 bullet points).',
|
|
61
|
+
'- If a section has no relevant information, omit it entirely.',
|
|
62
|
+
'- Return ONLY the markdown body — no preamble, no explanation, no code fences.',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if (ctx.complexity != null && ctx.risk != null) {
|
|
66
|
+
lines.push(
|
|
67
|
+
'',
|
|
68
|
+
'In the "Scope & Complexity" section, include:',
|
|
69
|
+
`- Complexity: ${ctx.complexity}/10 — ${ctx.complexityRationale || ''}`,
|
|
70
|
+
`- Risk: ${ctx.risk}/10 — ${ctx.riskRationale || ''}`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return lines.join('\n')
|
|
75
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between the create command and the pipeline runner.
|
|
3
|
+
*
|
|
4
|
+
* Sets up the PipelineContext, resolves routing, filters steps by config,
|
|
5
|
+
* and calls runPipeline.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolveRoute } from './router.js'
|
|
9
|
+
import { runPipeline } from './pipeline.js'
|
|
10
|
+
import { ALL_STEPS } from './steps.js'
|
|
11
|
+
import { checkBudgets, recordUsage } from './index.js'
|
|
12
|
+
import { loadAllEnhancements } from '../enhancements.js'
|
|
13
|
+
import { enhancementToStep } from './enhancement-adapter.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run the full enhancement pipeline.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} config - merged config
|
|
19
|
+
* @param {object} input - { title, description, instructions, templateBody, labels, existingIssues, repoLabels, rawInput }
|
|
20
|
+
* @param {object} [callbacks] - pipeline callbacks for progress display
|
|
21
|
+
* @param {{ provider?: string, model?: string, template?: string, enhancements?: string[] }} [routeContext] - routing context
|
|
22
|
+
* @returns {Promise<object>} PipelineContext with all accumulated results
|
|
23
|
+
*/
|
|
24
|
+
export async function runEnhancePipeline(config, input, callbacks, routeContext = {}) {
|
|
25
|
+
const ai = config.ai || {}
|
|
26
|
+
const budgets = ai.budgets || {}
|
|
27
|
+
const pipelineConfig = ai.pipeline || {}
|
|
28
|
+
|
|
29
|
+
// Resolve adapter + model
|
|
30
|
+
const route = resolveRoute(config, routeContext)
|
|
31
|
+
|
|
32
|
+
// Load all enhancements (three-tier: repo > user > built-in)
|
|
33
|
+
let enhancements
|
|
34
|
+
try {
|
|
35
|
+
enhancements = loadAllEnhancements(input._repoRoot)
|
|
36
|
+
} catch {
|
|
37
|
+
enhancements = null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let activeSteps
|
|
41
|
+
let stepConfigs = {}
|
|
42
|
+
|
|
43
|
+
if (enhancements && enhancements.length > 0) {
|
|
44
|
+
// Enhancement-based resolution
|
|
45
|
+
const enhancementNames = routeContext.enhancements || route.enhancements || null
|
|
46
|
+
|
|
47
|
+
// Build step configs from config (supports both old `steps` and new `enhancements` key)
|
|
48
|
+
const cfgEnhancements = pipelineConfig.enhancements || pipelineConfig.steps || {}
|
|
49
|
+
|
|
50
|
+
// Filter enhancements if specific names requested
|
|
51
|
+
let filtered = enhancements
|
|
52
|
+
if (enhancementNames && enhancementNames.length > 0) {
|
|
53
|
+
const requested = new Set(enhancementNames)
|
|
54
|
+
// Always include structural enhancements (triage, format)
|
|
55
|
+
filtered = enhancements.filter(
|
|
56
|
+
(enh) => enh.isStructural || requested.has(enh.key),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build step configs and convert to step objects
|
|
61
|
+
for (const enh of filtered) {
|
|
62
|
+
stepConfigs[enh.key] = cfgEnhancements[enh.key] || enh.mode || 'auto'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
activeSteps = filtered
|
|
66
|
+
.filter((enh) => stepConfigs[enh.key] !== 'never')
|
|
67
|
+
.map((enh) => enhancementToStep(enh))
|
|
68
|
+
} else {
|
|
69
|
+
// Fallback: use hardcoded ALL_STEPS (backward compat)
|
|
70
|
+
const cfgSteps = pipelineConfig.steps || {}
|
|
71
|
+
for (const step of ALL_STEPS) {
|
|
72
|
+
stepConfigs[step.name] = cfgSteps[step.name] || 'auto'
|
|
73
|
+
}
|
|
74
|
+
activeSteps = ALL_STEPS.filter((s) => stepConfigs[s.name] !== 'never')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build the initial PipelineContext
|
|
78
|
+
const ctx = {
|
|
79
|
+
// Input data
|
|
80
|
+
rawInput: input.rawInput || '',
|
|
81
|
+
title: input.title,
|
|
82
|
+
description: input.description || '',
|
|
83
|
+
instructions: input.instructions || '',
|
|
84
|
+
templateBody: input.templateBody || '',
|
|
85
|
+
labels: input.labels || [],
|
|
86
|
+
existingIssues: input.existingIssues || [],
|
|
87
|
+
repoLabels: input.repoLabels || [],
|
|
88
|
+
|
|
89
|
+
// Step config (internal)
|
|
90
|
+
_stepConfigs: stepConfigs,
|
|
91
|
+
|
|
92
|
+
// Accumulated results — steps populate these
|
|
93
|
+
dedupScore: null,
|
|
94
|
+
structuredContext: null,
|
|
95
|
+
scopeAnalysis: null,
|
|
96
|
+
complexity: null,
|
|
97
|
+
complexityRationale: null,
|
|
98
|
+
risk: null,
|
|
99
|
+
riskRationale: null,
|
|
100
|
+
aiLabels: null,
|
|
101
|
+
body: input.templateBody || '', // fallback to template
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await runPipeline(activeSteps, ctx, route, config, budgets, callbacks, { checkBudgets, recordUsage })
|
|
105
|
+
|
|
106
|
+
return ctx
|
|
107
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between enhancement descriptors and pipeline step objects.
|
|
3
|
+
*
|
|
4
|
+
* Converts enhancement descriptors (from enhancements.js) into step objects
|
|
5
|
+
* that pipeline.js can execute. Built-in steps retain their specialized
|
|
6
|
+
* shouldRun/parseResponse logic; custom steps get generic implementations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { BUILT_IN_STEPS_MAP, tryParseJSON, contextSummary, priorStepsSummary } from './steps.js'
|
|
10
|
+
import { buildBodyGuidance } from './body-template.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert an enhancement descriptor into a pipeline step object.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} enhancement - descriptor from enhancements.js
|
|
16
|
+
* @returns {object} step object compatible with pipeline.js
|
|
17
|
+
*/
|
|
18
|
+
export function enhancementToStep(enhancement) {
|
|
19
|
+
const builtInStep = BUILT_IN_STEPS_MAP[enhancement.key]
|
|
20
|
+
|
|
21
|
+
// If this is a built-in enhancement, use the built-in step's specialized
|
|
22
|
+
// shouldRun and parseResponse, but allow the prompt to be overridden
|
|
23
|
+
if (builtInStep) {
|
|
24
|
+
const promptOverridden = enhancement.source && enhancement.source !== 'built-in'
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
name: enhancement.key,
|
|
28
|
+
displayName: enhancement.name,
|
|
29
|
+
maxTokens: enhancement.maxTokens,
|
|
30
|
+
provider: enhancement.provider || null,
|
|
31
|
+
|
|
32
|
+
shouldRun: builtInStep.shouldRun,
|
|
33
|
+
parseResponse: builtInStep.parseResponse,
|
|
34
|
+
|
|
35
|
+
buildMessages(ctx) {
|
|
36
|
+
if (promptOverridden && enhancement.prompt) {
|
|
37
|
+
// Use the user/repo-overridden prompt with the standard user message
|
|
38
|
+
return buildCustomMessages(enhancement, ctx)
|
|
39
|
+
}
|
|
40
|
+
return builtInStep.buildMessages(ctx)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Custom enhancement — generic implementation
|
|
46
|
+
return {
|
|
47
|
+
name: enhancement.key,
|
|
48
|
+
displayName: enhancement.name,
|
|
49
|
+
maxTokens: enhancement.maxTokens,
|
|
50
|
+
provider: enhancement.provider || null,
|
|
51
|
+
|
|
52
|
+
shouldRun(ctx) {
|
|
53
|
+
// Check requires — all required context keys must be present
|
|
54
|
+
if (enhancement.requires?.length > 0) {
|
|
55
|
+
return enhancement.requires.every((key) => ctx[key] != null)
|
|
56
|
+
}
|
|
57
|
+
return true
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
buildMessages(ctx) {
|
|
61
|
+
return buildCustomMessages(enhancement, ctx)
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
parseResponse(raw, ctx) {
|
|
65
|
+
if (enhancement.format === 'json') {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = tryParseJSON(raw)
|
|
68
|
+
ctx[enhancement.contextKey] = parsed
|
|
69
|
+
} catch {
|
|
70
|
+
ctx[enhancement.contextKey] = null
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// markdown — store raw text
|
|
74
|
+
let cleaned = raw.trim()
|
|
75
|
+
const fenceMatch = cleaned.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
|
|
76
|
+
if (fenceMatch) cleaned = fenceMatch[1].trim()
|
|
77
|
+
if (cleaned.length > 0) {
|
|
78
|
+
ctx[enhancement.contextKey] = cleaned
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build messages for a custom or prompt-overridden enhancement.
|
|
87
|
+
*/
|
|
88
|
+
function buildCustomMessages(enhancement, ctx) {
|
|
89
|
+
let systemContent = enhancement.prompt
|
|
90
|
+
|
|
91
|
+
// For the format step, append body guidance
|
|
92
|
+
if (enhancement.key === 'format') {
|
|
93
|
+
const guidance = buildBodyGuidance(ctx)
|
|
94
|
+
systemContent = systemContent + '\n\n' + guidance
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// For the labels step, inject available labels
|
|
98
|
+
if (enhancement.key === 'labels' && ctx.repoLabels?.length) {
|
|
99
|
+
systemContent = systemContent.replace(
|
|
100
|
+
/Return ONLY valid JSON\.$/m,
|
|
101
|
+
`Available labels in this repo: ${ctx.repoLabels.join(', ')}\nReturn ONLY valid JSON.`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return [
|
|
106
|
+
{ role: 'system', content: systemContent },
|
|
107
|
+
{ role: 'user', content: contextSummary(ctx) + priorStepsSummary(ctx) },
|
|
108
|
+
]
|
|
109
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { resolveRoute, resolveProviderAdapter, listProviders, listAllProviders } from './router.js'
|
|
2
|
+
import { buildMessages } from './prompt.js'
|
|
3
|
+
|
|
4
|
+
export { listProviders, listAllProviders, resolveProviderAdapter }
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Token usage tracking (in-memory, resets on process exit)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const tokenLog = []
|
|
11
|
+
|
|
12
|
+
export function recordUsage(tokens) {
|
|
13
|
+
tokenLog.push({ tokens, timestamp: Date.now() })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getUsage(windowMs) {
|
|
17
|
+
const cutoff = Date.now() - windowMs
|
|
18
|
+
return tokenLog
|
|
19
|
+
.filter((e) => e.timestamp >= cutoff)
|
|
20
|
+
.reduce((sum, e) => sum + e.tokens, 0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check token budgets before making a request.
|
|
25
|
+
* Throws if a budget would be exceeded.
|
|
26
|
+
*/
|
|
27
|
+
export function checkBudgets(budgets, requestTokens) {
|
|
28
|
+
if (!budgets) return
|
|
29
|
+
|
|
30
|
+
if (budgets.maxTokensPerRequest && requestTokens > budgets.maxTokensPerRequest) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Token budget: request (${requestTokens}) exceeds per-request limit (${budgets.maxTokensPerRequest})`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (budgets.maxTokensPerHour) {
|
|
37
|
+
const hourlyUsed = getUsage(60 * 60 * 1000)
|
|
38
|
+
if (hourlyUsed + requestTokens > budgets.maxTokensPerHour) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Token budget: hourly usage (${hourlyUsed} + ${requestTokens}) would exceed limit (${budgets.maxTokensPerHour})`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (budgets.maxTokensPerDay) {
|
|
46
|
+
const dailyUsed = getUsage(24 * 60 * 60 * 1000)
|
|
47
|
+
if (dailyUsed + requestTokens > budgets.maxTokensPerDay) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Token budget: daily usage (${dailyUsed} + ${requestTokens}) would exceed limit (${budgets.maxTokensPerDay})`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Public API
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if AI enhancement is available (enabled + key configured).
|
|
61
|
+
*
|
|
62
|
+
* @param {object} config - merged config
|
|
63
|
+
* @param {{ provider?: string }} [context]
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
export function checkAvailable(config, context = {}) {
|
|
67
|
+
const ai = config.ai || {}
|
|
68
|
+
if (!ai.enabled) return false
|
|
69
|
+
try {
|
|
70
|
+
const { adapter } = resolveRoute(config, context)
|
|
71
|
+
return adapter.isConfigured()
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Enhance an issue body using AI.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} config - merged config
|
|
81
|
+
* @param {string} title - issue title
|
|
82
|
+
* @param {string} description - actual issue content
|
|
83
|
+
* @param {string} templateBody - already-rendered template
|
|
84
|
+
* @param {string} [instructions] - optional AI guidance
|
|
85
|
+
* @param {{ template?: string, labels?: string[], provider?: string, model?: string }} [context] - routing context
|
|
86
|
+
* @returns {Promise<string>} enhanced body
|
|
87
|
+
*/
|
|
88
|
+
export async function enhance(config, title, description, templateBody, instructions, context = {}) {
|
|
89
|
+
const ai = config.ai || {}
|
|
90
|
+
const budgets = ai.budgets || {}
|
|
91
|
+
const { adapter, model } = resolveRoute(config, context)
|
|
92
|
+
const messages = buildMessages(title, description, templateBody, instructions)
|
|
93
|
+
|
|
94
|
+
// Enforce per-request cap: clamp maxTokens to budget
|
|
95
|
+
const requestMax = budgets.maxTokensPerRequest || 4096
|
|
96
|
+
|
|
97
|
+
// Check rolling budgets before the call
|
|
98
|
+
checkBudgets(budgets, requestMax)
|
|
99
|
+
|
|
100
|
+
let result = await adapter.complete(messages, { model, maxTokens: requestMax })
|
|
101
|
+
|
|
102
|
+
// Strip wrapping code fences (```markdown ... ```) that LLMs often add
|
|
103
|
+
const fenceMatch = result.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
|
|
104
|
+
if (fenceMatch) result = fenceMatch[1].trim()
|
|
105
|
+
|
|
106
|
+
// Record approximate usage (response length in chars / 4 is a rough token estimate)
|
|
107
|
+
const approxTokens = Math.ceil(result.length / 4)
|
|
108
|
+
recordUsage(approxTokens)
|
|
109
|
+
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get current token usage stats.
|
|
115
|
+
* @returns {{ hourly: number, daily: number }}
|
|
116
|
+
*/
|
|
117
|
+
export function getTokenUsage() {
|
|
118
|
+
return {
|
|
119
|
+
hourly: getUsage(60 * 60 * 1000),
|
|
120
|
+
daily: getUsage(24 * 60 * 60 * 1000),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
import { resolveProviderAdapter } from './router.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run a sequence of pipeline steps.
|
|
12
|
+
*
|
|
13
|
+
* @param {object[]} steps - array of step objects (from steps.js)
|
|
14
|
+
* @param {object} ctx - mutable PipelineContext, accumulates results
|
|
15
|
+
* @param {{ adapter: object, model: string }} route - resolved adapter + model
|
|
16
|
+
* @param {object} [config] - merged config object (enables per-step provider overrides)
|
|
17
|
+
* @param {object} [budgets] - token budget config
|
|
18
|
+
* @param {object} [callbacks] - { onStepStart, onStepDone, onStepSkip, onStepFail }
|
|
19
|
+
* @param {{ checkBudgets: Function, recordUsage: Function }} [budgetHelpers]
|
|
20
|
+
* @returns {Promise<object>} the final ctx
|
|
21
|
+
*/
|
|
22
|
+
export async function runPipeline(steps, ctx, route, config, budgets, callbacks, budgetHelpers) {
|
|
23
|
+
const { adapter: defaultAdapter, model: defaultModel } = route
|
|
24
|
+
const cb = callbacks || {}
|
|
25
|
+
const { checkBudgets, recordUsage } = budgetHelpers || {}
|
|
26
|
+
|
|
27
|
+
let budgetExhausted = false
|
|
28
|
+
|
|
29
|
+
for (const step of steps) {
|
|
30
|
+
// If budget was exhausted, skip all remaining steps except format (which degrades gracefully)
|
|
31
|
+
if (budgetExhausted && step.name !== 'format') {
|
|
32
|
+
cb.onStepSkip?.(step, 'budget exhausted')
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check shouldRun
|
|
37
|
+
const stepConfig = ctx._stepConfigs?.[step.name] || 'auto'
|
|
38
|
+
if (stepConfig === 'never') {
|
|
39
|
+
cb.onStepSkip?.(step, 'disabled')
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (stepConfig === 'auto' && !step.shouldRun(ctx)) {
|
|
43
|
+
cb.onStepSkip?.(step, 'auto-skipped')
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
cb.onStepStart?.(step)
|
|
48
|
+
|
|
49
|
+
// Resolve per-step adapter override
|
|
50
|
+
let adapter = defaultAdapter
|
|
51
|
+
let model = defaultModel
|
|
52
|
+
if (step.provider && config) {
|
|
53
|
+
try {
|
|
54
|
+
const override = resolveProviderAdapter(config, step.provider)
|
|
55
|
+
adapter = override.adapter
|
|
56
|
+
model = override.model
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Unknown/unconfigured provider — warn and fall back to default
|
|
59
|
+
cb.onStepFail?.(step, new Error(`Provider override "${step.provider}" failed: ${err.message}, using default`))
|
|
60
|
+
// Continue with default adapter (don't skip — aligns with "never lose data" principle)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Check budget before the call (skip for format when budget already exhausted — it degrades gracefully)
|
|
66
|
+
if (checkBudgets && budgets && !budgetExhausted) {
|
|
67
|
+
try {
|
|
68
|
+
checkBudgets(budgets, step.maxTokens)
|
|
69
|
+
} catch {
|
|
70
|
+
budgetExhausted = true
|
|
71
|
+
cb.onStepFail?.(step, new Error('Token budget exceeded'))
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build messages and call the adapter
|
|
77
|
+
const messages = step.buildMessages(ctx)
|
|
78
|
+
const raw = await adapter.complete(messages, { model, maxTokens: step.maxTokens })
|
|
79
|
+
|
|
80
|
+
// Record approximate usage
|
|
81
|
+
if (recordUsage) {
|
|
82
|
+
const approxTokens = Math.ceil(raw.length / 4)
|
|
83
|
+
recordUsage(approxTokens)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Parse response and update context
|
|
87
|
+
step.parseResponse(raw, ctx)
|
|
88
|
+
|
|
89
|
+
cb.onStepDone?.(step, ctx)
|
|
90
|
+
} catch (err) {
|
|
91
|
+
cb.onStepFail?.(step, err)
|
|
92
|
+
// Non-fatal — continue to next step
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return ctx
|
|
97
|
+
}
|
|
@@ -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
|
+
}
|