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
JavaScript
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}`);
}
}