@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
JavaScript
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;
}
}
}