UNPKG

termcode

Version:

Superior terminal AI coding agent with enterprise-grade security, intelligent error recovery, performance monitoring, and plugin system - Advanced Claude Code alternative

373 lines (366 loc) 13.4 kB
import { promises as fs } from "node:fs"; import path from "node:path"; import os from "node:os"; import { log } from "../util/logging.js"; const configDir = path.join(os.homedir(), ".termcode"); const issueConfigPath = path.join(configDir, "issue-config.json"); // Load issue tracker configuration export async function loadIssueConfig() { try { const content = await fs.readFile(issueConfigPath, "utf8"); return JSON.parse(content); } catch (error) { return {}; } } // Save issue tracker configuration export async function saveIssueConfig(config) { try { await fs.mkdir(configDir, { recursive: true }); await fs.writeFile(issueConfigPath, JSON.stringify(config, null, 2), "utf8"); } catch (error) { log.error("Failed to save issue config:", error); throw error; } } // Fetch Jira issue details export async function fetchJiraIssue(issueKey) { const config = await loadIssueConfig(); if (!config.jira) { throw new Error("Jira not configured. Use /ticket config to set up integration."); } const { baseUrl, email, apiToken } = config.jira; const auth = Buffer.from(`${email}:${apiToken}`).toString('base64'); try { const response = await fetch(`${baseUrl}/rest/api/3/issue/${issueKey}`, { headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json', } }); if (!response.ok) { throw new Error(`Jira API error: ${response.statusText}`); } const issue = await response.json(); return { id: issue.key, title: issue.fields.summary, description: issue.fields.description?.content?.[0]?.content?.[0]?.text || issue.fields.description || "", status: issue.fields.status.name, assignee: issue.fields.assignee?.displayName, priority: issue.fields.priority?.name, labels: issue.fields.labels || [], type: "jira", url: `${baseUrl}/browse/${issue.key}`, project: issue.fields.project.name, createdAt: issue.fields.created, updatedAt: issue.fields.updated }; } catch (error) { log.error(`Failed to fetch Jira issue ${issueKey}:`, error); return null; } } // Fetch Linear issue details export async function fetchLinearIssue(issueId) { const config = await loadIssueConfig(); if (!config.linear) { throw new Error("Linear not configured. Use /ticket config to set up integration."); } const { apiKey } = config.linear; try { const query = ` query($issueId: String!) { issue(id: $issueId) { id identifier title description state { name } assignee { name } priority labels { nodes { name } } team { name } url createdAt updatedAt } } `; const response = await fetch('https://api.linear.app/graphql', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables: { issueId } }) }); if (!response.ok) { throw new Error(`Linear API error: ${response.statusText}`); } const data = await response.json(); if (data.errors) { throw new Error(`Linear GraphQL errors: ${data.errors.map((e) => e.message).join(', ')}`); } const issue = data.data.issue; if (!issue) { return null; } return { id: issue.identifier, title: issue.title, description: issue.description || "", status: issue.state.name, assignee: issue.assignee?.name, priority: String(issue.priority || 0), labels: issue.labels.nodes.map((label) => label.name), type: "linear", url: issue.url, team: issue.team.name, createdAt: issue.createdAt, updatedAt: issue.updatedAt }; } catch (error) { log.error(`Failed to fetch Linear issue ${issueId}:`, error); return null; } } // Auto-detect issue type from ID format export function detectIssueType(issueId) { // Jira format: PROJECT-123 if (/^[A-Z]+-\d+$/.test(issueId)) { return "jira"; } // Linear format: usually alphanumeric if (/^[A-Z0-9]+-[A-Z0-9]+$/.test(issueId.toUpperCase())) { return "linear"; } return "unknown"; } // Fetch issue from appropriate tracker export async function fetchIssue(issueId) { const type = detectIssueType(issueId); switch (type) { case "jira": return fetchJiraIssue(issueId); case "linear": return fetchLinearIssue(issueId); default: throw new Error(`Cannot determine issue tracker type for: ${issueId}. Supported formats: PROJ-123 (Jira), ABC-123 (Linear)`); } } // Generate branch name from issue export function generateBranchName(issue, config) { const sanitize = (str) => str .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .substring(0, 50); switch (config.branchNaming) { case "ticket-id": return `feature/${issue.id}`; case "ticket-title": return `feature/${issue.id}-${sanitize(issue.title)}`; case "custom": return `${issue.type}/${issue.id}`; default: return `feature/${issue.id}`; } } // Generate commit message from issue export function generateCommitMessage(issue, config, changes) { const template = config.commitMessageTemplate || "{type}({id}): {title}\n\n{description}"; return template .replace("{type}", issue.type === "jira" ? "feat" : "feature") .replace("{id}", issue.id) .replace("{title}", issue.title) .replace("{description}", issue.description.substring(0, 200)) .replace("{changes}", changes || "Implement requested changes") .replace("{url}", issue.url); } // Execute full ticket workflow export async function executeTicketWorkflow(issueId, repoPath, task, options = {}) { const config = { autoBranch: true, branchNaming: "ticket-id", autoCommit: true, commitMessageTemplate: "{type}({id}): {title}", autoPush: false, createPR: false, ...options }; const result = { success: false, issue: null, branchCreated: undefined, changesApplied: false, committed: false, pushed: false, prUrl: undefined }; try { // 1. Fetch issue details log.step("Fetching ticket", `retrieving details for ${issueId}...`); const issue = await fetchIssue(issueId); if (!issue) { throw new Error(`Issue not found: ${issueId}`); } result.issue = issue; log.success(`Fetched ${issue.type} ticket: ${issue.title}`); log.raw(` Status: ${issue.status}`); log.raw(` Assignee: ${issue.assignee || "Unassigned"}`); log.raw(` URL: ${issue.url}`); // 2. Create branch if requested if (config.autoBranch) { const { runShell } = await import("../tools/shell.js"); const branchName = generateBranchName(issue, config); log.step("Creating branch", branchName); const branchResult = await runShell(["git", "checkout", "-b", branchName], repoPath); if (branchResult.ok) { result.branchCreated = branchName; log.success(`Created and switched to branch: ${branchName}`); } else { log.warn(`Failed to create branch, continuing on current branch`); } } // 3. Apply changes using AI log.step("Applying changes", "generating code changes based on ticket description..."); const enhancedTask = `${task} Context from ${issue.type} ticket ${issue.id}: Title: ${issue.title} Description: ${issue.description} Priority: ${issue.priority || "Normal"} Status: ${issue.status} Please implement the changes described in the ticket.`; const { runTask } = await import("../agent/planner.js"); await runTask(repoPath, enhancedTask, false); result.changesApplied = true; // 4. Commit changes if requested if (config.autoCommit) { const { runShell } = await import("../tools/shell.js"); log.step("Committing changes", "creating commit with ticket context..."); // Stage all changes await runShell(["git", "add", "."], repoPath); // Create commit with ticket context const commitMessage = generateCommitMessage(issue, config); const commitResult = await runShell(["git", "commit", "-m", commitMessage], repoPath); if (commitResult.ok) { result.committed = true; log.success("Changes committed with ticket context"); } else { log.warn("Failed to commit changes"); } } // 5. Push if requested if (config.autoPush && result.branchCreated) { const { runShell } = await import("../tools/shell.js"); log.step("Pushing branch", "pushing changes to remote..."); const pushResult = await runShell(["git", "push", "-u", "origin", result.branchCreated], repoPath); if (pushResult.ok) { result.pushed = true; log.success("Branch pushed to remote"); } else { log.warn("Failed to push branch"); } } // 6. Create PR if requested if (config.createPR && result.pushed) { try { const { runShell } = await import("../tools/shell.js"); const prTitle = `${issue.id}: ${issue.title}`; const prBody = `Resolves: ${issue.url} ${issue.description} **Changes:** - Implemented solution for ${issue.id} - Applied changes as described in ticket **Testing:** - [ ] Manual testing completed - [ ] All tests passing --- *Auto-generated from TermCode ticket integration*`; log.step("Creating PR", "creating pull request..."); const prResult = await runShell([ "gh", "pr", "create", "--title", prTitle, "--body", prBody ], repoPath); if (prResult.ok) { result.prUrl = prResult.data.stdout.trim(); log.success(`PR created: ${result.prUrl}`); } else { log.warn("Failed to create PR (ensure gh CLI is installed and authenticated)"); } } catch (error) { log.warn("PR creation failed:", error); } } result.success = true; return result; } catch (error) { log.error("Ticket workflow failed:", error); result.success = false; return result; } } // Get workflow recommendations based on issue export async function getWorkflowRecommendations(issueId) { try { const issue = await fetchIssue(issueId); if (!issue) { throw new Error("Issue not found"); } const recommended = { autoBranch: true, branchNaming: "ticket-title", autoCommit: true, commitMessageTemplate: "{type}({id}): {title}\n\nCloses: {url}", autoPush: false, createPR: false }; let reasoning = `Based on ${issue.type} issue analysis:\n`; reasoning += `- Priority: ${issue.priority || "Normal"}\n`; reasoning += `- Status: ${issue.status}\n`; reasoning += `- Labels: ${issue.labels.join(", ") || "None"}`; // Adjust recommendations based on issue characteristics if (issue.priority === "High" || issue.priority === "Critical") { recommended.autoPush = true; recommended.createPR = true; reasoning += "\n- High priority: enabled auto-push and PR creation"; } if (issue.labels.includes("hotfix") || issue.title.toLowerCase().includes("urgent")) { recommended.branchNaming = "custom"; reasoning += "\n- Hotfix detected: using custom branch naming"; } const requirements = [ "Git repository initialized", "Working directory clean (or changes will be committed)", ]; if (recommended.autoPush || recommended.createPR) { requirements.push("Git remote configured"); } if (recommended.createPR) { requirements.push("GitHub CLI (gh) installed and authenticated"); } return { recommended, reasoning, requirements }; } catch (error) { throw new Error(`Failed to analyze issue: ${error}`); } }