UNPKG

@fromsvenwithlove/devops-issues-cli

Version:

AI-powered CLI tool and library for Azure DevOps work item management with Claude agents

796 lines (690 loc) 23.1 kB
import chalk from "chalk"; import { keypressManager } from "../utils/keypress-manager.js"; export class TreeNavigator { constructor(workItems, azureClient = null) { this.tree = this.buildTree(workItems); this.flatList = this.buildFlatList(this.tree); this.selectedIndex = 0; this.running = false; this.handlerId = null; this.azureClient = azureClient; this.mode = "TREE_NAV"; this.actionMenuIndex = 0; this.stateMenuIndex = 0; this.validStates = []; this.messageText = ""; this.messageType = ""; // 'success' or 'error' this.confirmationItem = null; this.commentText = ""; this.currentComments = []; } buildTree(workItems) { // Create a map for quick lookup const itemMap = new Map(); workItems.forEach((item) => { // Handle both formats: direct properties or fields object const id = item.id; const title = item.title || item.fields?.["System.Title"] || "No Title"; const type = item.type || item.fields?.["System.WorkItemType"] || "Unknown"; const state = item.state || item.fields?.["System.State"] || "Unknown"; const assignedTo = item.assignedTo || item.fields?.["System.AssignedTo"]?.displayName || "Unassigned"; itemMap.set(id, { id: id, title: title, type: type, state: state, assignedTo: assignedTo, children: [], expanded: false, level: 0, }); }); // Build parent-child relationships const rootItems = []; workItems.forEach((item) => { const parentId = item.parentId || item.fields?.["System.Parent"]; const currentItem = itemMap.get(item.id); if (parentId && itemMap.has(parentId)) { const parent = itemMap.get(parentId); parent.children.push(currentItem); currentItem.parent = parent; } else { rootItems.push(currentItem); } }); // Set levels after all relationships are built function setLevels(items, level = 0) { items.forEach((item) => { item.level = level; if (item.children.length > 0) { setLevels(item.children, level + 1); } }); } setLevels(rootItems); // Expand root nodes by default rootItems.forEach((item) => { item.expanded = true; }); return rootItems; } buildFlatList(tree) { const list = []; function addToList(items, level = 0) { items.forEach((item) => { item.level = level; list.push(item); if (item.expanded && item.children.length > 0) { addToList(item.children, level + 1); } }); } addToList(tree); return list; } rebuildFlatList() { this.flatList = this.buildFlatList(this.tree); // Ensure selected index is still valid if (this.selectedIndex >= this.flatList.length) { this.selectedIndex = Math.max(0, this.flatList.length - 1); } } getTypeIcon(type) { const icons = { Epic: "📋", Feature: "🔹", "User Story": "📝", Bug: "🐛", Task: "⚙️", }; return icons[type] || "📄"; } getStateColor(state) { const colors = { Active: chalk.green, New: chalk.blue, Resolved: chalk.gray, Closed: chalk.gray, Done: chalk.gray, }; return colors[state] || chalk.white; } countChildren(item) { return item.children.reduce( (count, child) => count + 1 + this.countChildren(child), 0 ); } countItemsWithChildren(item) { let count = item.children.length > 0 ? 1 : 0; return ( count + item.children.reduce( (subCount, child) => subCount + this.countItemsWithChildren(child), 0 ) ); } renderTree() { console.clear(); console.log(chalk.bold.blue("Azure DevOps Issues Explorer\n")); if (this.flatList.length === 0) { console.log(chalk.yellow("No work items found.")); console.log("\nPress q to quit"); return; } // Debug info const totalItems = this.tree.reduce( (count, item) => count + 1 + this.countChildren(item), 0 ); const itemsWithChildren = this.tree.reduce( (count, item) => count + this.countItemsWithChildren(item), 0 ); console.log( chalk.dim( `Total items: ${totalItems}, Items with children: ${itemsWithChildren}, Visible: ${this.flatList.length}\n` ) ); this.flatList.forEach((item, index) => { const isSelected = index === this.selectedIndex; const prefix = isSelected ? chalk.cyan("> ") : " "; const indent = " ".repeat(item.level); const stateColor = this.getStateColor(item.state); const expandIcon = item.children.length > 0 ? (item.expanded ? "▼ " : "▶ ") : ""; const title = `${expandIcon}${item.title} (#${item.id})`; const state = stateColor(`[${item.state}]`); const line = `${indent}${title} ${state}`; if (isSelected) { console.log(chalk.inverse(line)); } else { console.log(line); } }); console.log( chalk.dim( "\nNavigation: ↑↓ select, → expand, ← collapse, Enter details, q quit" ) ); } async showDetailsWithActions() { const item = this.flatList[this.selectedIndex]; if (!item) return; this.mode = "ACTION_MENU"; this.actionMenuIndex = 0; // Fetch comments if Azure client is available this.currentComments = []; if (this.azureClient) { try { this.currentComments = await this.azureClient.getWorkItemComments( item.id ); } catch (error) { console.error("Failed to fetch comments:", error.message); } } this.renderDetailsWithActions(); } renderDetailsWithActions() { const item = this.flatList[this.selectedIndex]; console.clear(); console.log(chalk.bold.blue("Work Item Details")); console.log("─".repeat(30)); console.log(`${chalk.bold("ID:")} #${item.id}`); console.log(`${chalk.bold("Title:")} ${item.title}`); console.log(`${chalk.bold("Type:")} ${item.type}`); console.log( `${chalk.bold("State:")} ${this.getStateColor(item.state)(item.state)}` ); console.log(`${chalk.bold("Assigned:")} ${item.assignedTo}`); if (item.children.length > 0) { console.log( `${chalk.bold("Children:")} ${item.children.length} work items` ); } // Display comments if (this.currentComments && this.currentComments.length > 0) { console.log( "\n\n" + chalk.bold(`Comments (${this.currentComments.length}):`) ); console.log("\n"); this.currentComments.forEach((comment, index) => { const date = new Date(comment.createdDate); const dateStr = date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); console.log( chalk.cyan(`▪ ${comment.createdBy}`) + chalk.dim(` - ${dateStr}`) ); // Indent comment text const lines = comment.text.split("\n"); lines.forEach((line) => { console.log(" " + line); }); if (index < this.currentComments.length - 1) { console.log(""); // Add spacing between comments } }); } else { console.log(chalk.dim("\nNo comments yet")); } console.log("\n\n" + chalk.bold.blue("Actions")); console.log("─".repeat(30)); const actions = [ "Update State", "Add Comment", "Delete Issue", "Return to Tree", ]; actions.forEach((action, index) => { const isSelected = index === this.actionMenuIndex; const prefix = isSelected ? chalk("> ") : " "; const line = `${prefix}${action}`; if (isSelected) { console.log(chalk.inverse(line)); } else { console.log(line); } }); console.log( chalk.dim( "\nNavigation: ↑↓ select action, Enter confirm, Esc back to tree" ) ); } async showStateSelection() { const item = this.flatList[this.selectedIndex]; if (!item || !this.azureClient) return; this.mode = "STATE_SELECT"; this.stateMenuIndex = 0; try { this.validStates = await this.azureClient.getValidStates(item.type); // Remove current state from options this.validStates = this.validStates.filter( (state) => state !== item.state ); if (this.validStates.length === 0) { console.clear(); console.log( chalk.yellow("No other states available for this work item type.") ); console.log(chalk.dim("Press any key to return to actions...")); await this.waitForKeypress(); this.mode = "ACTION_MENU"; this.renderDetailsWithActions(); return; } this.renderStateSelection(); } catch (error) { console.clear(); console.log(chalk.red(`Error loading states: ${error.message}`)); console.log(chalk.dim("Press any key to return to actions...")); await this.waitForKeypress(); this.mode = "ACTION_MENU"; this.renderDetailsWithActions(); } } renderStateSelection() { const item = this.flatList[this.selectedIndex]; console.clear(); console.log(chalk.bold.blue("Update Work Item State")); console.log("─".repeat(30)); console.log(`${chalk.bold("Work Item:")} #${item.id} - ${item.title}`); console.log( `${chalk.bold("Current State:")} ${this.getStateColor(item.state)(item.state)}` ); console.log("\n" + chalk.bold("Select New State:")); this.validStates.forEach((state, index) => { const isSelected = index === this.stateMenuIndex; const prefix = isSelected ? chalk.cyan("> ") : " "; const stateDisplay = this.getStateColor(state)(state); const line = `${stateDisplay}`; if (isSelected) { console.log(chalk.inverse(line)); } else { console.log(line); } }); console.log( chalk.dim("\nNavigation: ↑↓ select state, Enter confirm, Esc cancel") ); } async updateItemState() { const item = this.flatList[this.selectedIndex]; const newState = this.validStates[this.stateMenuIndex]; if (!item || !newState || !this.azureClient) return; console.clear(); console.log(chalk.blue("Updating work item state...")); try { const result = await this.azureClient.updateWorkItemState( item.id, newState ); // Update the item in our local data item.state = result.state; // Optimistic approach: return directly to tree view this.mode = "TREE_NAV"; this.renderTree(); } catch (error) { // Show error message for failed updates this.mode = "SUCCESS_MESSAGE"; this.messageText = `❌ Failed to update work item: ${error.message}`; this.messageType = "error"; this.renderMessage(); } } navigate() { return new Promise((resolve) => { this.running = true; // Generate unique handler ID this.handlerId = `tree-navigator-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // Try to register with keypress manager if ( !keypressManager.addHandler( this.handlerId, this.createKeypressHandler(resolve) ) ) { console.log( chalk.red( "Explorer requires an interactive terminal. Please run in a proper terminal/console." ) ); resolve(); return; } this.renderTree(); }); } createKeypressHandler(resolve) { return async (ch, key) => { if (!this.running) return; if (key && key.name === "q") { this.cleanup(); resolve(); return; } if (key && key.name === "up") { if (this.mode === "TREE_NAV") { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.renderTree(); } else if (this.mode === "ACTION_MENU") { this.actionMenuIndex = Math.max(0, this.actionMenuIndex - 1); this.renderDetailsWithActions(); } else if (this.mode === "STATE_SELECT") { this.stateMenuIndex = Math.max(0, this.stateMenuIndex - 1); this.renderStateSelection(); } else if (this.mode === "COMMENT_INPUT") { // No up navigation in comment input mode } } if (key && key.name === "down") { if (this.mode === "TREE_NAV") { this.selectedIndex = Math.min( this.flatList.length - 1, this.selectedIndex + 1 ); this.renderTree(); } else if (this.mode === "ACTION_MENU") { this.actionMenuIndex = Math.min(3, this.actionMenuIndex + 1); // 4 actions total this.renderDetailsWithActions(); } else if (this.mode === "STATE_SELECT") { this.stateMenuIndex = Math.min( this.validStates.length - 1, this.stateMenuIndex + 1 ); this.renderStateSelection(); } else if (this.mode === "COMMENT_INPUT") { // No down navigation in comment input mode } } if (key && key.name === "right") { if (this.mode === "TREE_NAV") { const item = this.flatList[this.selectedIndex]; if (item && item.children.length > 0 && !item.expanded) { item.expanded = true; this.rebuildFlatList(); this.renderTree(); } } } if (key && key.name === "left") { if (this.mode === "TREE_NAV") { const item = this.flatList[this.selectedIndex]; if (item && item.expanded) { item.expanded = false; this.rebuildFlatList(); this.renderTree(); } } } if (key && key.name === "escape") { if (this.mode === "ACTION_MENU") { this.mode = "TREE_NAV"; this.renderTree(); } else if (this.mode === "STATE_SELECT") { this.mode = "ACTION_MENU"; this.renderDetailsWithActions(); } else if (this.mode === "DELETE_CONFIRM") { this.mode = "ACTION_MENU"; this.renderDetailsWithActions(); } else if (this.mode === "COMMENT_INPUT") { this.mode = "ACTION_MENU"; this.commentText = ""; this.renderDetailsWithActions(); } } // Handle SUCCESS_MESSAGE mode - any key returns to appropriate view if (this.mode === "SUCCESS_MESSAGE") { if (this.messageType === "success") { // Success messages return to tree view this.mode = "TREE_NAV"; this.renderTree(); } else { // Error messages return to action menu this.mode = "ACTION_MENU"; this.renderDetailsWithActions(); } } // Handle DELETE_CONFIRM mode if (this.mode === "DELETE_CONFIRM") { if (key && key.name === "y") { await this.executeDelete(); } else if (key && key.name === "n") { this.mode = "ACTION_MENU"; this.renderDetailsWithActions(); } } if (key && key.name === "return") { if (this.mode === "TREE_NAV") { await this.showDetailsWithActions(); } else if (this.mode === "ACTION_MENU") { if (this.actionMenuIndex === 0) { // Update State if (this.azureClient) { await this.showStateSelection(); } else { this.mode = "SUCCESS_MESSAGE"; this.messageText = "State update not available - Azure client not connected"; this.messageType = "error"; this.renderMessage(); } } else if (this.actionMenuIndex === 1) { // Add Comment if (this.azureClient) { this.showCommentInput(); } else { this.mode = "SUCCESS_MESSAGE"; this.messageText = "Comment not available - Azure client not connected"; this.messageType = "error"; this.renderMessage(); } } else if (this.actionMenuIndex === 2) { // Delete Issue if (this.azureClient) { this.showDeleteConfirmation(); } else { this.mode = "SUCCESS_MESSAGE"; this.messageText = "Delete not available - Azure client not connected"; this.messageType = "error"; this.renderMessage(); } } else if (this.actionMenuIndex === 3) { // Return to Tree this.mode = "TREE_NAV"; this.renderTree(); } } else if (this.mode === "STATE_SELECT") { await this.updateItemState(); } } // Handle COMMENT_INPUT mode if (this.mode === "COMMENT_INPUT") { if ((key && key.name === "return") || (key && key.name === "enter")) { // Submit comment on Enter await this.addComment(); } else if (key && key.name === "backspace") { if (this.commentText.length > 0) { this.commentText = this.commentText.slice(0, -1); this.renderCommentInput(); } } else if (ch && (!key || (!key.ctrl && !key.meta))) { // Add regular characters this.commentText += ch; this.renderCommentInput(); } } if (key && key.ctrl && key.name === "c") { this.cleanup(); process.exit(); } }; } renderMessage() { console.clear(); if (this.messageType === "success") { console.log(chalk.green(this.messageText)); } else if (this.messageType === "error") { console.log(chalk.red(this.messageText)); } console.log(chalk.dim("\\nPress any key to continue...")); } showDeleteConfirmation() { const item = this.flatList[this.selectedIndex]; if (!item) return; this.mode = "DELETE_CONFIRM"; this.confirmationItem = item; this.renderDeleteConfirmation(); } renderDeleteConfirmation() { const item = this.confirmationItem; console.clear(); console.log(chalk.bold.red("⚠️ DELETE WORK ITEM")); console.log("─".repeat(30)); console.log(`${chalk.bold("ID:")} #${item.id}`); console.log(`${chalk.bold("Title:")} ${item.title}`); console.log(`${chalk.bold("Type:")} ${item.type}`); console.log( `${chalk.bold("State:")} ${this.getStateColor(item.state)(item.state)}` ); if (item.children.length > 0) { console.log( `${chalk.bold.yellow("⚠️ Warning:")} This item has ${item.children.length} child work items` ); console.log( chalk.yellow(" Deleting this item may affect child relationships") ); } console.log("\\n" + chalk.bold.red("This action cannot be undone!")); console.log(chalk.red("The work item will be moved to the recycle bin.")); console.log( "\\n" + chalk.bold("Are you sure you want to delete this work item?") ); console.log( chalk.green("Press Y to confirm") + " | " + chalk.cyan("Press N to cancel") + " | " + chalk.dim("Esc to go back") ); } async executeDelete() { const item = this.confirmationItem; if (!item || !this.azureClient) return; console.clear(); console.log(chalk.blue("Deleting work item...")); try { const result = await this.azureClient.deleteWorkItem(item.id); // Remove the item from our tree structure this.removeItemFromTree(item); // Optimistic approach: return directly to tree view this.mode = "TREE_NAV"; this.selectedIndex = Math.max( 0, Math.min(this.selectedIndex, this.flatList.length - 1) ); this.renderTree(); } catch (error) { // Show error message for failed deletions this.mode = "SUCCESS_MESSAGE"; this.messageText = `❌ Failed to delete work item: ${error.message}`; this.messageType = "error"; this.renderMessage(); } } removeItemFromTree(itemToRemove) { // Find and remove from parent's children array if (itemToRemove.parent) { const parentChildren = itemToRemove.parent.children; const index = parentChildren.findIndex( (child) => child.id === itemToRemove.id ); if (index !== -1) { parentChildren.splice(index, 1); } } else { // Remove from root level const index = this.tree.findIndex((item) => item.id === itemToRemove.id); if (index !== -1) { this.tree.splice(index, 1); } } // Rebuild flat list to reflect changes this.rebuildFlatList(); } showCommentInput() { const item = this.flatList[this.selectedIndex]; if (!item) return; this.mode = "COMMENT_INPUT"; this.commentText = ""; this.renderCommentInput(); } renderCommentInput() { const item = this.flatList[this.selectedIndex]; console.clear(); console.log(chalk.bold.blue("Add Comment to Work Item")); console.log("─".repeat(30)); console.log(`${chalk.bold("Work Item:")} #${item.id} - ${item.title}`); console.log(`${chalk.bold("Type:")} ${item.type}`); console.log( `${chalk.bold("State:")} ${this.getStateColor(item.state)(item.state)}` ); console.log("\n" + chalk.bold("Enter your comment:")); console.log( chalk.dim("(Type your comment, Enter to submit, Esc to cancel)") ); console.log("─".repeat(30)); // Show current comment with cursor console.log(this.commentText + chalk.inverse(" ")); } async addComment() { const item = this.flatList[this.selectedIndex]; const fullComment = this.commentText.trim(); if (!item || !fullComment || !this.azureClient) return; console.clear(); console.log(chalk.blue("Adding comment...")); try { const result = await this.azureClient.addWorkItemComment( item.id, fullComment ); // Reset comment state this.commentText = ""; // Refresh comments list try { this.currentComments = await this.azureClient.getWorkItemComments( item.id ); } catch (error) { // Ignore error, we already added the comment } // Return to action menu to show updated comments this.mode = "ACTION_MENU"; this.renderDetailsWithActions(); } catch (error) { // Show error message for failed comments this.mode = "SUCCESS_MESSAGE"; this.messageText = `❌ Failed to add comment: ${error.message}`; this.messageType = "error"; this.renderMessage(); } } cleanup() { this.running = false; // Remove this handler from the global keypress manager if (this.handlerId) { keypressManager.removeHandler(this.handlerId); this.handlerId = null; } } }