feat(issue-management): Implement interactive issue management command with CRUD operations

- Added `/issue:manage` command for interactive issue management via CLI.
- Implemented features for listing, viewing, editing, deleting, and bulk operations on issues.
- Integrated GitHub issue fetching and text description parsing for issue creation.
- Enhanced user experience with menu-driven interface and structured output.
- Created helper functions for parsing user input and managing issue data.
- Added error handling and related command references for better usability.

feat(issue-creation): Introduce structured issue creation from GitHub URL or text description

- Added `/issue:new` command to create structured issues from GitHub issues or text descriptions.
- Implemented parsing logic for extracting key elements from issue descriptions.
- Integrated user confirmation for issue creation with options to edit title and priority.
- Ensured proper writing of issues to `.workflow/issues/issues.jsonl` with metadata.
- Included examples and error handling for various input scenarios.
This commit is contained in:
catlog22
2025-12-27 10:20:34 +08:00
parent 0157e36344
commit 8f310339df
9 changed files with 2359 additions and 37 deletions

View File

@@ -0,0 +1,865 @@
---
name: manage
description: Interactive issue management (CRUD) via ccw cli endpoints with menu-driven interface
argument-hint: "[issue-id] [--action list|view|edit|delete|bulk]"
allowed-tools: TodoWrite(*), Bash(*), Read(*), Write(*), AskUserQuestion(*), Task(*)
---
# Issue Manage Command (/issue:manage)
## Overview
Interactive menu-driven interface for issue management using `ccw issue` CLI endpoints:
- **List**: Browse and filter issues
- **View**: Detailed issue inspection
- **Edit**: Modify issue fields
- **Delete**: Remove issues
- **Bulk**: Batch operations on multiple issues
## CLI Endpoints Reference
```bash
# Core endpoints (ccw issue)
ccw issue list # List all issues
ccw issue list <id> --json # Get issue details
ccw issue status <id> # Detailed status
ccw issue init <id> --title "..." # Create issue
ccw issue task <id> --title "..." # Add task
# Queue management
ccw issue queue # List queue
ccw issue queue add <id> # Add to queue
ccw issue next # Get next task
ccw issue done <queue-id> # Complete task
```
## Usage
```bash
# Interactive mode (menu-driven)
/issue:manage
# Direct to specific issue
/issue:manage GH-123
# Direct action
/issue:manage --action list
/issue:manage GH-123 --action edit
```
## Implementation
### Phase 1: Entry Point
```javascript
const issueId = parseIssueId(userInput);
const action = flags.action;
// Show main menu if no action specified
if (!action) {
await showMainMenu(issueId);
} else {
await executeAction(action, issueId);
}
```
### Phase 2: Main Menu
```javascript
async function showMainMenu(preselectedIssue = null) {
// Fetch current issues summary
const issuesResult = Bash('ccw issue list --json 2>/dev/null || echo "[]"');
const issues = JSON.parse(issuesResult) || [];
const queueResult = Bash('ccw issue status --json 2>/dev/null');
const queueStatus = JSON.parse(queueResult || '{}');
console.log(`
## Issue Management Dashboard
**Total Issues**: ${issues.length}
**Queue Status**: ${queueStatus.queue?.total_tasks || 0} tasks (${queueStatus.queue?.pending_count || 0} pending)
### Quick Stats
- Registered: ${issues.filter(i => i.status === 'registered').length}
- Planned: ${issues.filter(i => i.status === 'planned').length}
- Executing: ${issues.filter(i => i.status === 'executing').length}
- Completed: ${issues.filter(i => i.status === 'completed').length}
`);
const answer = AskUserQuestion({
questions: [{
question: 'What would you like to do?',
header: 'Action',
multiSelect: false,
options: [
{ label: 'List Issues', description: 'Browse all issues with filters' },
{ label: 'View Issue', description: 'Detailed view of specific issue' },
{ label: 'Create Issue', description: 'Add new issue from text or GitHub' },
{ label: 'Edit Issue', description: 'Modify issue fields' },
{ label: 'Delete Issue', description: 'Remove issue(s)' },
{ label: 'Bulk Operations', description: 'Batch actions on multiple issues' }
]
}]
});
const selected = parseAnswer(answer);
switch (selected) {
case 'List Issues':
await listIssuesInteractive();
break;
case 'View Issue':
await viewIssueInteractive(preselectedIssue);
break;
case 'Create Issue':
await createIssueInteractive();
break;
case 'Edit Issue':
await editIssueInteractive(preselectedIssue);
break;
case 'Delete Issue':
await deleteIssueInteractive(preselectedIssue);
break;
case 'Bulk Operations':
await bulkOperationsInteractive();
break;
}
}
```
### Phase 3: List Issues
```javascript
async function listIssuesInteractive() {
// Ask for filter
const filterAnswer = AskUserQuestion({
questions: [{
question: 'Filter issues by status?',
header: 'Filter',
multiSelect: true,
options: [
{ label: 'All', description: 'Show all issues' },
{ label: 'Registered', description: 'New, unplanned issues' },
{ label: 'Planned', description: 'Issues with bound solutions' },
{ label: 'Queued', description: 'In execution queue' },
{ label: 'Executing', description: 'Currently being worked on' },
{ label: 'Completed', description: 'Finished issues' },
{ label: 'Failed', description: 'Failed issues' }
]
}]
});
const filters = parseMultiAnswer(filterAnswer);
// Fetch and filter issues
const result = Bash('ccw issue list --json');
let issues = JSON.parse(result) || [];
if (!filters.includes('All')) {
const statusMap = {
'Registered': 'registered',
'Planned': 'planned',
'Queued': 'queued',
'Executing': 'executing',
'Completed': 'completed',
'Failed': 'failed'
};
const allowedStatuses = filters.map(f => statusMap[f]).filter(Boolean);
issues = issues.filter(i => allowedStatuses.includes(i.status));
}
if (issues.length === 0) {
console.log('No issues found matching filters.');
return showMainMenu();
}
// Display issues table
console.log(`
## Issues (${issues.length})
| ID | Status | Priority | Title |
|----|--------|----------|-------|
${issues.map(i => `| ${i.id} | ${i.status} | P${i.priority} | ${i.title.substring(0, 40)} |`).join('\n')}
`);
// Ask for action on issue
const actionAnswer = AskUserQuestion({
questions: [{
question: 'Select an issue to view/edit, or return to menu:',
header: 'Select',
multiSelect: false,
options: [
...issues.slice(0, 10).map(i => ({
label: i.id,
description: i.title.substring(0, 50)
})),
{ label: 'Back to Menu', description: 'Return to main menu' }
]
}]
});
const selected = parseAnswer(actionAnswer);
if (selected === 'Back to Menu') {
return showMainMenu();
}
// View selected issue
await viewIssueInteractive(selected);
}
```
### Phase 4: View Issue
```javascript
async function viewIssueInteractive(issueId) {
if (!issueId) {
// Ask for issue ID
const issues = JSON.parse(Bash('ccw issue list --json') || '[]');
const idAnswer = AskUserQuestion({
questions: [{
question: 'Select issue to view:',
header: 'Issue',
multiSelect: false,
options: issues.slice(0, 10).map(i => ({
label: i.id,
description: `${i.status} - ${i.title.substring(0, 40)}`
}))
}]
});
issueId = parseAnswer(idAnswer);
}
// Fetch detailed status
const result = Bash(`ccw issue status ${issueId} --json`);
const data = JSON.parse(result);
const issue = data.issue;
const solutions = data.solutions || [];
const bound = data.bound;
console.log(`
## Issue: ${issue.id}
**Title**: ${issue.title}
**Status**: ${issue.status}
**Priority**: P${issue.priority}
**Created**: ${issue.created_at}
**Updated**: ${issue.updated_at}
### Context
${issue.context || 'No context provided'}
### Solutions (${solutions.length})
${solutions.length === 0 ? 'No solutions registered' :
solutions.map(s => `- ${s.is_bound ? '◉' : '○'} ${s.id}: ${s.tasks?.length || 0} tasks`).join('\n')}
${bound ? `### Bound Solution: ${bound.id}\n**Tasks**: ${bound.tasks?.length || 0}` : ''}
`);
// Show tasks if bound solution exists
if (bound?.tasks?.length > 0) {
console.log(`
### Tasks
| ID | Action | Scope | Title |
|----|--------|-------|-------|
${bound.tasks.map(t => `| ${t.id} | ${t.action} | ${t.scope?.substring(0, 20) || '-'} | ${t.title.substring(0, 30)} |`).join('\n')}
`);
}
// Action menu
const actionAnswer = AskUserQuestion({
questions: [{
question: 'What would you like to do?',
header: 'Action',
multiSelect: false,
options: [
{ label: 'Edit Issue', description: 'Modify issue fields' },
{ label: 'Plan Issue', description: 'Generate solution (/issue:plan)' },
{ label: 'Add to Queue', description: 'Queue bound solution tasks' },
{ label: 'View Queue', description: 'See queue status' },
{ label: 'Delete Issue', description: 'Remove this issue' },
{ label: 'Back to Menu', description: 'Return to main menu' }
]
}]
});
const action = parseAnswer(actionAnswer);
switch (action) {
case 'Edit Issue':
await editIssueInteractive(issueId);
break;
case 'Plan Issue':
console.log(`Running: /issue:plan ${issueId}`);
// Invoke plan skill
break;
case 'Add to Queue':
Bash(`ccw issue queue add ${issueId}`);
console.log(`✓ Added ${issueId} tasks to queue`);
break;
case 'View Queue':
const queueOutput = Bash('ccw issue queue');
console.log(queueOutput);
break;
case 'Delete Issue':
await deleteIssueInteractive(issueId);
break;
default:
return showMainMenu();
}
}
```
### Phase 5: Edit Issue
```javascript
async function editIssueInteractive(issueId) {
if (!issueId) {
const issues = JSON.parse(Bash('ccw issue list --json') || '[]');
const idAnswer = AskUserQuestion({
questions: [{
question: 'Select issue to edit:',
header: 'Issue',
multiSelect: false,
options: issues.slice(0, 10).map(i => ({
label: i.id,
description: `${i.status} - ${i.title.substring(0, 40)}`
}))
}]
});
issueId = parseAnswer(idAnswer);
}
// Get current issue data
const result = Bash(`ccw issue list ${issueId} --json`);
const issueData = JSON.parse(result);
const issue = issueData.issue || issueData;
// Ask which field to edit
const fieldAnswer = AskUserQuestion({
questions: [{
question: 'Which field to edit?',
header: 'Field',
multiSelect: false,
options: [
{ label: 'Title', description: `Current: ${issue.title?.substring(0, 40)}` },
{ label: 'Priority', description: `Current: P${issue.priority}` },
{ label: 'Status', description: `Current: ${issue.status}` },
{ label: 'Context', description: 'Edit problem description' },
{ label: 'Labels', description: `Current: ${issue.labels?.join(', ') || 'none'}` },
{ label: 'Back', description: 'Return without changes' }
]
}]
});
const field = parseAnswer(fieldAnswer);
if (field === 'Back') {
return viewIssueInteractive(issueId);
}
let updatePayload = {};
switch (field) {
case 'Title':
const titleAnswer = AskUserQuestion({
questions: [{
question: 'Enter new title (or select current to keep):',
header: 'Title',
multiSelect: false,
options: [
{ label: issue.title.substring(0, 50), description: 'Keep current title' }
]
}]
});
const newTitle = parseAnswer(titleAnswer);
if (newTitle && newTitle !== issue.title.substring(0, 50)) {
updatePayload.title = newTitle;
}
break;
case 'Priority':
const priorityAnswer = AskUserQuestion({
questions: [{
question: 'Select priority:',
header: 'Priority',
multiSelect: false,
options: [
{ label: 'P1 - Critical', description: 'Production blocking' },
{ label: 'P2 - High', description: 'Major functionality' },
{ label: 'P3 - Medium', description: 'Normal priority (default)' },
{ label: 'P4 - Low', description: 'Minor issues' },
{ label: 'P5 - Trivial', description: 'Nice to have' }
]
}]
});
const priorityStr = parseAnswer(priorityAnswer);
updatePayload.priority = parseInt(priorityStr.charAt(1));
break;
case 'Status':
const statusAnswer = AskUserQuestion({
questions: [{
question: 'Select status:',
header: 'Status',
multiSelect: false,
options: [
{ label: 'registered', description: 'New issue, not yet planned' },
{ label: 'planning', description: 'Solution being generated' },
{ label: 'planned', description: 'Solution bound, ready for queue' },
{ label: 'queued', description: 'In execution queue' },
{ label: 'executing', description: 'Currently being worked on' },
{ label: 'completed', description: 'All tasks finished' },
{ label: 'failed', description: 'Execution failed' },
{ label: 'paused', description: 'Temporarily on hold' }
]
}]
});
updatePayload.status = parseAnswer(statusAnswer);
break;
case 'Context':
console.log(`Current context:\n${issue.context || '(empty)'}\n`);
const contextAnswer = AskUserQuestion({
questions: [{
question: 'Enter new context (problem description):',
header: 'Context',
multiSelect: false,
options: [
{ label: 'Keep current', description: 'No changes' }
]
}]
});
const newContext = parseAnswer(contextAnswer);
if (newContext && newContext !== 'Keep current') {
updatePayload.context = newContext;
}
break;
case 'Labels':
const labelsAnswer = AskUserQuestion({
questions: [{
question: 'Enter labels (comma-separated):',
header: 'Labels',
multiSelect: false,
options: [
{ label: issue.labels?.join(',') || '', description: 'Keep current labels' }
]
}]
});
const labelsStr = parseAnswer(labelsAnswer);
if (labelsStr) {
updatePayload.labels = labelsStr.split(',').map(l => l.trim());
}
break;
}
// Apply update if any
if (Object.keys(updatePayload).length > 0) {
// Read, update, write issues.jsonl
const issuesPath = '.workflow/issues/issues.jsonl';
const allIssues = Bash(`cat "${issuesPath}"`)
.split('\n')
.filter(line => line.trim())
.map(line => JSON.parse(line));
const idx = allIssues.findIndex(i => i.id === issueId);
if (idx !== -1) {
allIssues[idx] = {
...allIssues[idx],
...updatePayload,
updated_at: new Date().toISOString()
};
Write(issuesPath, allIssues.map(i => JSON.stringify(i)).join('\n'));
console.log(`✓ Updated ${issueId}: ${Object.keys(updatePayload).join(', ')}`);
}
}
// Continue editing or return
const continueAnswer = AskUserQuestion({
questions: [{
question: 'Continue editing?',
header: 'Continue',
multiSelect: false,
options: [
{ label: 'Edit Another Field', description: 'Continue editing this issue' },
{ label: 'View Issue', description: 'See updated issue' },
{ label: 'Back to Menu', description: 'Return to main menu' }
]
}]
});
const cont = parseAnswer(continueAnswer);
if (cont === 'Edit Another Field') {
await editIssueInteractive(issueId);
} else if (cont === 'View Issue') {
await viewIssueInteractive(issueId);
} else {
return showMainMenu();
}
}
```
### Phase 6: Delete Issue
```javascript
async function deleteIssueInteractive(issueId) {
if (!issueId) {
const issues = JSON.parse(Bash('ccw issue list --json') || '[]');
const idAnswer = AskUserQuestion({
questions: [{
question: 'Select issue to delete:',
header: 'Delete',
multiSelect: false,
options: issues.slice(0, 10).map(i => ({
label: i.id,
description: `${i.status} - ${i.title.substring(0, 40)}`
}))
}]
});
issueId = parseAnswer(idAnswer);
}
// Confirm deletion
const confirmAnswer = AskUserQuestion({
questions: [{
question: `Delete issue ${issueId}? This will also remove associated solutions.`,
header: 'Confirm',
multiSelect: false,
options: [
{ label: 'Delete', description: 'Permanently remove issue and solutions' },
{ label: 'Cancel', description: 'Keep issue' }
]
}]
});
if (parseAnswer(confirmAnswer) !== 'Delete') {
console.log('Deletion cancelled.');
return showMainMenu();
}
// Remove from issues.jsonl
const issuesPath = '.workflow/issues/issues.jsonl';
const allIssues = Bash(`cat "${issuesPath}"`)
.split('\n')
.filter(line => line.trim())
.map(line => JSON.parse(line));
const filtered = allIssues.filter(i => i.id !== issueId);
Write(issuesPath, filtered.map(i => JSON.stringify(i)).join('\n'));
// Remove solutions file if exists
const solPath = `.workflow/issues/solutions/${issueId}.jsonl`;
Bash(`rm -f "${solPath}" 2>/dev/null || true`);
// Remove from queue if present
const queuePath = '.workflow/issues/queue.json';
if (Bash(`test -f "${queuePath}" && echo exists`) === 'exists') {
const queue = JSON.parse(Bash(`cat "${queuePath}"`));
queue.queue = queue.queue.filter(q => q.issue_id !== issueId);
Write(queuePath, JSON.stringify(queue, null, 2));
}
console.log(`✓ Deleted issue ${issueId}`);
return showMainMenu();
}
```
### Phase 7: Bulk Operations
```javascript
async function bulkOperationsInteractive() {
const bulkAnswer = AskUserQuestion({
questions: [{
question: 'Select bulk operation:',
header: 'Bulk',
multiSelect: false,
options: [
{ label: 'Update Status', description: 'Change status of multiple issues' },
{ label: 'Update Priority', description: 'Change priority of multiple issues' },
{ label: 'Add Labels', description: 'Add labels to multiple issues' },
{ label: 'Delete Multiple', description: 'Remove multiple issues' },
{ label: 'Queue All Planned', description: 'Add all planned issues to queue' },
{ label: 'Retry All Failed', description: 'Reset all failed tasks to pending' },
{ label: 'Back', description: 'Return to main menu' }
]
}]
});
const operation = parseAnswer(bulkAnswer);
if (operation === 'Back') {
return showMainMenu();
}
// Get issues for selection
const allIssues = JSON.parse(Bash('ccw issue list --json') || '[]');
if (operation === 'Queue All Planned') {
const planned = allIssues.filter(i => i.status === 'planned' && i.bound_solution_id);
for (const issue of planned) {
Bash(`ccw issue queue add ${issue.id}`);
console.log(`✓ Queued ${issue.id}`);
}
console.log(`\n✓ Queued ${planned.length} issues`);
return showMainMenu();
}
if (operation === 'Retry All Failed') {
Bash('ccw issue retry');
console.log('✓ Reset all failed tasks to pending');
return showMainMenu();
}
// Multi-select issues
const selectAnswer = AskUserQuestion({
questions: [{
question: 'Select issues (multi-select):',
header: 'Select',
multiSelect: true,
options: allIssues.slice(0, 15).map(i => ({
label: i.id,
description: `${i.status} - ${i.title.substring(0, 30)}`
}))
}]
});
const selectedIds = parseMultiAnswer(selectAnswer);
if (selectedIds.length === 0) {
console.log('No issues selected.');
return showMainMenu();
}
// Execute bulk operation
const issuesPath = '.workflow/issues/issues.jsonl';
let issues = Bash(`cat "${issuesPath}"`)
.split('\n')
.filter(line => line.trim())
.map(line => JSON.parse(line));
switch (operation) {
case 'Update Status':
const statusAnswer = AskUserQuestion({
questions: [{
question: 'Select new status:',
header: 'Status',
multiSelect: false,
options: [
{ label: 'registered', description: 'Reset to registered' },
{ label: 'paused', description: 'Pause issues' },
{ label: 'completed', description: 'Mark completed' }
]
}]
});
const newStatus = parseAnswer(statusAnswer);
issues = issues.map(i =>
selectedIds.includes(i.id)
? { ...i, status: newStatus, updated_at: new Date().toISOString() }
: i
);
break;
case 'Update Priority':
const prioAnswer = AskUserQuestion({
questions: [{
question: 'Select new priority:',
header: 'Priority',
multiSelect: false,
options: [
{ label: 'P1', description: 'Critical' },
{ label: 'P2', description: 'High' },
{ label: 'P3', description: 'Medium' },
{ label: 'P4', description: 'Low' },
{ label: 'P5', description: 'Trivial' }
]
}]
});
const newPrio = parseInt(parseAnswer(prioAnswer).charAt(1));
issues = issues.map(i =>
selectedIds.includes(i.id)
? { ...i, priority: newPrio, updated_at: new Date().toISOString() }
: i
);
break;
case 'Add Labels':
const labelAnswer = AskUserQuestion({
questions: [{
question: 'Enter labels to add (comma-separated):',
header: 'Labels',
multiSelect: false,
options: [
{ label: 'bug', description: 'Bug fix' },
{ label: 'feature', description: 'New feature' },
{ label: 'urgent', description: 'Urgent priority' }
]
}]
});
const newLabels = parseAnswer(labelAnswer).split(',').map(l => l.trim());
issues = issues.map(i =>
selectedIds.includes(i.id)
? {
...i,
labels: [...new Set([...(i.labels || []), ...newLabels])],
updated_at: new Date().toISOString()
}
: i
);
break;
case 'Delete Multiple':
const confirmDelete = AskUserQuestion({
questions: [{
question: `Delete ${selectedIds.length} issues permanently?`,
header: 'Confirm',
multiSelect: false,
options: [
{ label: 'Delete All', description: 'Remove selected issues' },
{ label: 'Cancel', description: 'Keep issues' }
]
}]
});
if (parseAnswer(confirmDelete) === 'Delete All') {
issues = issues.filter(i => !selectedIds.includes(i.id));
// Clean up solutions
for (const id of selectedIds) {
Bash(`rm -f ".workflow/issues/solutions/${id}.jsonl" 2>/dev/null || true`);
}
} else {
console.log('Deletion cancelled.');
return showMainMenu();
}
break;
}
Write(issuesPath, issues.map(i => JSON.stringify(i)).join('\n'));
console.log(`✓ Updated ${selectedIds.length} issues`);
return showMainMenu();
}
```
### Phase 8: Create Issue (Redirect)
```javascript
async function createIssueInteractive() {
const typeAnswer = AskUserQuestion({
questions: [{
question: 'Create issue from:',
header: 'Source',
multiSelect: false,
options: [
{ label: 'GitHub URL', description: 'Import from GitHub issue' },
{ label: 'Text Description', description: 'Enter problem description' },
{ label: 'Quick Create', description: 'Just title and priority' }
]
}]
});
const type = parseAnswer(typeAnswer);
if (type === 'GitHub URL' || type === 'Text Description') {
console.log('Use /issue:new for structured issue creation');
console.log('Example: /issue:new https://github.com/org/repo/issues/123');
return showMainMenu();
}
// Quick create
const titleAnswer = AskUserQuestion({
questions: [{
question: 'Enter issue title:',
header: 'Title',
multiSelect: false,
options: [
{ label: 'Authentication Bug', description: 'Example title' }
]
}]
});
const title = parseAnswer(titleAnswer);
const prioAnswer = AskUserQuestion({
questions: [{
question: 'Select priority:',
header: 'Priority',
multiSelect: false,
options: [
{ label: 'P3 - Medium (Recommended)', description: 'Normal priority' },
{ label: 'P1 - Critical', description: 'Production blocking' },
{ label: 'P2 - High', description: 'Major functionality' }
]
}]
});
const priority = parseInt(parseAnswer(prioAnswer).charAt(1));
// Generate ID and create
const id = `ISS-${Date.now()}`;
Bash(`ccw issue init ${id} --title "${title}" --priority ${priority}`);
console.log(`✓ Created issue ${id}`);
await viewIssueInteractive(id);
}
```
## Helper Functions
```javascript
function parseAnswer(answer) {
// Extract selected option from AskUserQuestion response
if (typeof answer === 'string') return answer;
if (answer.answers) {
const values = Object.values(answer.answers);
return values[0] || '';
}
return '';
}
function parseMultiAnswer(answer) {
// Extract multiple selections
if (typeof answer === 'string') return answer.split(',').map(s => s.trim());
if (answer.answers) {
const values = Object.values(answer.answers);
return values.flatMap(v => v.split(',').map(s => s.trim()));
}
return [];
}
function parseFlags(input) {
const flags = {};
const matches = input.matchAll(/--(\w+)\s+([^\s-]+)/g);
for (const match of matches) {
flags[match[1]] = match[2];
}
return flags;
}
function parseIssueId(input) {
const match = input.match(/^([A-Z]+-\d+|ISS-\d+|GH-\d+)/i);
return match ? match[1] : null;
}
```
## Error Handling
| Error | Resolution |
|-------|------------|
| No issues found | Suggest creating with /issue:new |
| Issue not found | Show available issues, ask for correction |
| Invalid selection | Show error, re-prompt |
| Write failure | Check permissions, show error |
| Queue operation fails | Show ccw issue error, suggest fix |
## Related Commands
- `/issue:new` - Create structured issue
- `/issue:plan` - Plan solution for issue
- `/issue:queue` - Form execution queue
- `/issue:execute` - Execute queued tasks
- `ccw issue list` - CLI list command
- `ccw issue status` - CLI status command

View File

@@ -0,0 +1,366 @@
---
name: new
description: Create structured issue from GitHub URL or text description, extracting key elements into issues.jsonl
argument-hint: "<github-url | text-description> [--priority 1-5] [--labels label1,label2]"
allowed-tools: TodoWrite(*), Bash(*), Read(*), Write(*), WebFetch(*), AskUserQuestion(*)
---
# Issue New Command (/issue:new)
## Overview
Creates a new structured issue from either:
1. **GitHub Issue URL** - Fetches and parses issue content via `gh` CLI
2. **Text Description** - Parses natural language into structured fields
Outputs a well-formed issue entry to `.workflow/issues/issues.jsonl`.
## Issue Structure
```typescript
interface Issue {
id: string; // GH-123 or ISS-YYYYMMDD-HHMMSS
title: string; // Issue title (clear, concise)
status: 'registered'; // Initial status
priority: number; // 1 (critical) to 5 (low)
context: string; // Problem description
source: 'github' | 'text'; // Input source type
source_url?: string; // GitHub URL if applicable
labels?: string[]; // Categorization labels
// Structured extraction
problem_statement: string; // What is the problem?
expected_behavior?: string; // What should happen?
actual_behavior?: string; // What actually happens?
affected_components?: string[];// Files/modules affected
reproduction_steps?: string[]; // Steps to reproduce
// Metadata
bound_solution_id: null;
solution_count: 0;
created_at: string;
updated_at: string;
}
```
## Usage
```bash
# From GitHub URL
/issue:new https://github.com/owner/repo/issues/123
# From text description
/issue:new "Login fails when password contains special characters. Expected: successful login. Actual: 500 error. Affects src/auth/*"
# With options
/issue:new <url-or-text> --priority 2 --labels "bug,auth"
```
## Implementation
### Phase 1: Input Detection
```javascript
const input = userInput.trim();
const flags = parseFlags(userInput); // --priority, --labels
// Detect input type
const isGitHubUrl = input.match(/github\.com\/[\w-]+\/[\w-]+\/issues\/\d+/);
const isGitHubShort = input.match(/^#(\d+)$/); // #123 format
let issueData = {};
if (isGitHubUrl || isGitHubShort) {
// GitHub issue - fetch via gh CLI
issueData = await fetchGitHubIssue(input);
} else {
// Text description - parse structure
issueData = await parseTextDescription(input);
}
```
### Phase 2: GitHub Issue Fetching
```javascript
async function fetchGitHubIssue(urlOrNumber) {
let issueRef;
if (urlOrNumber.startsWith('http')) {
// Extract owner/repo/number from URL
const match = urlOrNumber.match(/github\.com\/([\w-]+)\/([\w-]+)\/issues\/(\d+)/);
if (!match) throw new Error('Invalid GitHub URL');
issueRef = `${match[1]}/${match[2]}#${match[3]}`;
} else {
// #123 format - use current repo
issueRef = urlOrNumber.replace('#', '');
}
// Fetch via gh CLI
const result = Bash(`gh issue view ${issueRef} --json number,title,body,labels,state,url`);
const ghIssue = JSON.parse(result);
// Parse body for structure
const parsed = parseIssueBody(ghIssue.body);
return {
id: `GH-${ghIssue.number}`,
title: ghIssue.title,
source: 'github',
source_url: ghIssue.url,
labels: ghIssue.labels.map(l => l.name),
context: ghIssue.body,
...parsed
};
}
function parseIssueBody(body) {
// Extract structured sections from markdown body
const sections = {};
// Problem/Description
const problemMatch = body.match(/##?\s*(problem|description|issue)[:\s]*([\s\S]*?)(?=##|$)/i);
if (problemMatch) sections.problem_statement = problemMatch[2].trim();
// Expected behavior
const expectedMatch = body.match(/##?\s*(expected|should)[:\s]*([\s\S]*?)(?=##|$)/i);
if (expectedMatch) sections.expected_behavior = expectedMatch[2].trim();
// Actual behavior
const actualMatch = body.match(/##?\s*(actual|current)[:\s]*([\s\S]*?)(?=##|$)/i);
if (actualMatch) sections.actual_behavior = actualMatch[2].trim();
// Steps to reproduce
const stepsMatch = body.match(/##?\s*(steps|reproduce)[:\s]*([\s\S]*?)(?=##|$)/i);
if (stepsMatch) {
const stepsText = stepsMatch[2].trim();
sections.reproduction_steps = stepsText
.split('\n')
.filter(line => line.match(/^\s*[\d\-\*]/))
.map(line => line.replace(/^\s*[\d\.\-\*]\s*/, '').trim());
}
// Affected components (from file references)
const fileMatches = body.match(/`[^`]*\.(ts|js|tsx|jsx|py|go|rs)[^`]*`/g);
if (fileMatches) {
sections.affected_components = [...new Set(fileMatches.map(f => f.replace(/`/g, '')))];
}
// Fallback: use entire body as problem statement
if (!sections.problem_statement) {
sections.problem_statement = body.substring(0, 500);
}
return sections;
}
```
### Phase 3: Text Description Parsing
```javascript
async function parseTextDescription(text) {
// Generate unique ID
const id = `ISS-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}`;
// Extract structured elements using patterns
const result = {
id,
source: 'text',
title: '',
problem_statement: '',
expected_behavior: null,
actual_behavior: null,
affected_components: [],
reproduction_steps: []
};
// Pattern: "Title. Description. Expected: X. Actual: Y. Affects: files"
const sentences = text.split(/\.(?=\s|$)/);
// First sentence as title
result.title = sentences[0]?.trim() || 'Untitled Issue';
// Look for keywords
for (const sentence of sentences) {
const s = sentence.trim();
if (s.match(/^expected:?\s*/i)) {
result.expected_behavior = s.replace(/^expected:?\s*/i, '');
} else if (s.match(/^actual:?\s*/i)) {
result.actual_behavior = s.replace(/^actual:?\s*/i, '');
} else if (s.match(/^affects?:?\s*/i)) {
const components = s.replace(/^affects?:?\s*/i, '').split(/[,\s]+/);
result.affected_components = components.filter(c => c.includes('/') || c.includes('.'));
} else if (s.match(/^steps?:?\s*/i)) {
result.reproduction_steps = s.replace(/^steps?:?\s*/i, '').split(/[,;]/);
} else if (!result.problem_statement && s.length > 10) {
result.problem_statement = s;
}
}
// Fallback problem statement
if (!result.problem_statement) {
result.problem_statement = text.substring(0, 300);
}
return result;
}
```
### Phase 4: User Confirmation
```javascript
// Show parsed data and ask for confirmation
console.log(`
## Parsed Issue
**ID**: ${issueData.id}
**Title**: ${issueData.title}
**Source**: ${issueData.source}${issueData.source_url ? ` (${issueData.source_url})` : ''}
### Problem Statement
${issueData.problem_statement}
${issueData.expected_behavior ? `### Expected Behavior\n${issueData.expected_behavior}\n` : ''}
${issueData.actual_behavior ? `### Actual Behavior\n${issueData.actual_behavior}\n` : ''}
${issueData.affected_components?.length ? `### Affected Components\n${issueData.affected_components.map(c => `- ${c}`).join('\n')}\n` : ''}
${issueData.reproduction_steps?.length ? `### Reproduction Steps\n${issueData.reproduction_steps.map((s, i) => `${i+1}. ${s}`).join('\n')}\n` : ''}
`);
// Ask user to confirm or edit
const answer = AskUserQuestion({
questions: [{
question: 'Create this issue?',
header: 'Confirm',
multiSelect: false,
options: [
{ label: 'Create', description: 'Save issue to issues.jsonl' },
{ label: 'Edit Title', description: 'Modify the issue title' },
{ label: 'Edit Priority', description: 'Change priority (1-5)' },
{ label: 'Cancel', description: 'Discard and exit' }
]
}]
});
if (answer.includes('Cancel')) {
console.log('Issue creation cancelled.');
return;
}
if (answer.includes('Edit Title')) {
const titleAnswer = AskUserQuestion({
questions: [{
question: 'Enter new title:',
header: 'Title',
multiSelect: false,
options: [
{ label: issueData.title.substring(0, 40), description: 'Keep current' }
]
}]
});
// Handle custom input via "Other"
if (titleAnswer.customText) {
issueData.title = titleAnswer.customText;
}
}
```
### Phase 5: Write to JSONL
```javascript
// Construct final issue object
const priority = flags.priority ? parseInt(flags.priority) : 3;
const labels = flags.labels ? flags.labels.split(',').map(l => l.trim()) : [];
const newIssue = {
id: issueData.id,
title: issueData.title,
status: 'registered',
priority,
context: issueData.problem_statement,
source: issueData.source,
source_url: issueData.source_url || null,
labels: [...(issueData.labels || []), ...labels],
// Structured fields
problem_statement: issueData.problem_statement,
expected_behavior: issueData.expected_behavior || null,
actual_behavior: issueData.actual_behavior || null,
affected_components: issueData.affected_components || [],
reproduction_steps: issueData.reproduction_steps || [],
// Metadata
bound_solution_id: null,
solution_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
// Ensure directory exists
Bash('mkdir -p .workflow/issues');
// Append to issues.jsonl
const issuesPath = '.workflow/issues/issues.jsonl';
Bash(`echo '${JSON.stringify(newIssue)}' >> "${issuesPath}"`);
console.log(`
## Issue Created
**ID**: ${newIssue.id}
**Title**: ${newIssue.title}
**Priority**: ${newIssue.priority}
**Labels**: ${newIssue.labels.join(', ') || 'none'}
**Source**: ${newIssue.source}
### Next Steps
1. Plan solution: \`/issue:plan ${newIssue.id}\`
2. View details: \`ccw issue status ${newIssue.id}\`
3. Manage issues: \`/issue:manage\`
`);
```
## Examples
### GitHub Issue
```bash
/issue:new https://github.com/myorg/myrepo/issues/42 --priority 2
# Output:
## Issue Created
**ID**: GH-42
**Title**: Fix memory leak in WebSocket handler
**Priority**: 2
**Labels**: bug, performance
**Source**: github (https://github.com/myorg/myrepo/issues/42)
```
### Text Description
```bash
/issue:new "API rate limiting not working. Expected: 429 after 100 requests. Actual: No limit. Affects src/middleware/rate-limit.ts"
# Output:
## Issue Created
**ID**: ISS-20251227-142530
**Title**: API rate limiting not working
**Priority**: 3
**Labels**: none
**Source**: text
```
## Error Handling
| Error | Resolution |
|-------|------------|
| Invalid GitHub URL | Show format hint, ask for correction |
| gh CLI not available | Fall back to WebFetch for public issues |
| Empty description | Prompt user for required fields |
| Duplicate issue ID | Auto-increment or suggest merge |
| Parse failure | Show raw input, ask for manual structuring |
## Related Commands
- `/issue:plan` - Plan solution for issue
- `/issue:manage` - Interactive issue management
- `ccw issue list` - List all issues
- `ccw issue status <id>` - View issue details

View File

@@ -663,9 +663,11 @@ async function queueAction(subAction: string | undefined, issueId: string | unde
for (const item of queue.queue) {
const statusColor = {
'pending': chalk.gray,
'ready': chalk.cyan,
'executing': chalk.yellow,
'completed': chalk.green,
'failed': chalk.red
'failed': chalk.red,
'blocked': chalk.magenta
}[item.status] || chalk.white;
console.log(

View File

@@ -21,6 +21,7 @@ const MODULE_FILES = [
'dashboard-js/components/tabs-other.js',
'dashboard-js/components/carousel.js',
'dashboard-js/components/notifications.js',
'dashboard-js/components/cli-stream-viewer.js',
'dashboard-js/components/global-notifications.js',
'dashboard-js/components/cli-status.js',
'dashboard-js/components/cli-history.js',

View File

@@ -88,7 +88,8 @@ const MODULE_CSS_FILES = [
'29-help.css',
'30-core-memory.css',
'31-api-settings.css',
'32-issue-manager.css'
'32-issue-manager.css',
'33-cli-stream-viewer.css'
];
// Modular JS files in dependency order
@@ -109,6 +110,7 @@ const MODULE_FILES = [
'components/flowchart.js',
'components/carousel.js',
'components/notifications.js',
'components/cli-stream-viewer.js',
'components/global-notifications.js',
'components/task-queue-sidebar.js',
'components/cli-status.js',

View File

@@ -16,6 +16,11 @@
color: hsl(var(--muted-foreground));
}
/* Issue Header */
.issue-header {
margin-bottom: 1.5rem;
}
/* View Toggle (Issues/Queue) */
.issue-view-toggle {
display: inline-flex;
@@ -60,17 +65,59 @@
width: 100%;
}
.issues-empty-state {
.issues-empty-state,
.issue-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 160px;
min-height: 200px;
text-align: center;
color: hsl(var(--muted-foreground));
}
.issue-empty svg {
margin-bottom: 1rem;
opacity: 0.5;
}
/* Issue Filters */
.issue-filters {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.issue-filter-btn {
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted));
border: 1px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
}
.issue-filter-btn:hover {
background: hsl(var(--muted) / 0.8);
color: hsl(var(--foreground));
}
.issue-filter-btn.active {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
/* Issue Card */
.issue-card {
position: relative;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 1rem;
transition: all 0.2s ease;
cursor: pointer;
}
@@ -78,6 +125,7 @@
.issue-card:hover {
border-color: hsl(var(--primary));
transform: translateY(-2px);
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.08);
}
.issue-card-header {
@@ -93,14 +141,16 @@
color: hsl(var(--muted-foreground));
}
.issue-card-title {
.issue-card-title,
.issue-title {
font-weight: 600;
font-size: 0.9375rem;
line-height: 1.4;
margin-top: 0.25rem;
}
.issue-card-meta {
.issue-card-meta,
.issue-meta {
display: flex;
align-items: center;
gap: 0.5rem;
@@ -141,6 +191,11 @@
color: hsl(var(--muted-foreground));
}
.issue-status.planning {
background: hsl(38 92% 50% / 0.15);
color: hsl(38 92% 50%);
}
.issue-status.planned {
background: hsl(217 91% 60% / 0.15);
color: hsl(217 91% 60%);
@@ -210,13 +265,30 @@
gap: 1.5rem;
}
.queue-empty-state {
.queue-empty-state,
.queue-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
text-align: center;
color: hsl(var(--muted-foreground));
}
.queue-empty svg {
margin-bottom: 1rem;
opacity: 0.5;
}
/* Issue ID */
.issue-id {
font-family: var(--font-mono);
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted));
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
/* Execution Group */
@@ -977,3 +1049,713 @@
font-size: 0.75rem;
font-weight: 500;
}
/* ==========================================
TOOLBAR & SEARCH
========================================== */
.issue-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.75rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
border: 1px solid hsl(var(--border));
}
.issue-search {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 200px;
max-width: 320px;
}
.issue-search > i:first-child {
position: absolute;
left: 0.75rem;
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.issue-search input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.25rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
font-size: 0.875rem;
color: hsl(var(--foreground));
transition: border-color 0.15s ease;
}
.issue-search input:focus {
outline: none;
border-color: hsl(var(--primary));
}
.issue-search input::placeholder {
color: hsl(var(--muted-foreground));
}
.issue-search-clear {
position: absolute;
right: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
border-radius: 9999px;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
}
.issue-search-clear:hover {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
/* ==========================================
CREATE BUTTON
========================================== */
.issue-create-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.issue-create-btn:hover {
background: hsl(var(--primary) / 0.9);
transform: translateY(-1px);
}
.issue-create-btn:active {
transform: translateY(0);
}
/* ==========================================
ISSUE STATS
========================================== */
.issue-stats {
padding: 0.5rem 0;
}
/* ==========================================
EMPTY STATE (CENTERED)
========================================== */
.issue-empty-container {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
.issue-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
}
.issue-empty > i,
.issue-empty > svg {
color: hsl(var(--muted-foreground));
opacity: 0.5;
margin-bottom: 1rem;
}
.issue-empty-title {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.issue-empty-hint {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-bottom: 1.5rem;
}
.issue-empty-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.issue-empty-btn:hover {
background: hsl(var(--primary) / 0.9);
}
/* ==========================================
MODAL STYLES
========================================== */
.issue-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.issue-modal.hidden {
display: none;
}
.issue-modal-backdrop {
position: absolute;
inset: 0;
background: hsl(var(--foreground) / 0.5);
animation: fadeIn 0.15s ease-out;
}
.issue-modal-content {
position: relative;
width: 90%;
max-width: 480px;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
box-shadow: 0 20px 40px hsl(var(--foreground) / 0.15);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.issue-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid hsl(var(--border));
}
.issue-modal-header h3 {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.issue-modal-body {
padding: 1.25rem;
max-height: 60vh;
overflow-y: auto;
}
.issue-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.3);
}
/* ==========================================
FORM STYLES
========================================== */
.form-group {
margin-bottom: 1rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--foreground));
margin-bottom: 0.375rem;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
font-size: 0.875rem;
color: hsl(var(--foreground));
transition: border-color 0.15s ease;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: hsl(var(--primary));
}
.form-group input::placeholder,
.form-group textarea::placeholder {
color: hsl(var(--muted-foreground));
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-group select {
cursor: pointer;
}
/* ==========================================
BUTTON STYLES
========================================== */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary:hover {
background: hsl(var(--primary) / 0.9);
}
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: hsl(var(--muted));
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-secondary:hover {
background: hsl(var(--muted) / 0.8);
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
background: transparent;
color: hsl(var(--muted-foreground));
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-icon:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
/* ==========================================
DETAIL PANEL ENHANCEMENTS
========================================== */
.issue-detail-content {
padding: 1.25rem;
overflow-y: auto;
flex: 1;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--muted-foreground));
margin-bottom: 0.5rem;
}
.detail-editable {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.detail-value {
flex: 1;
font-size: 1rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.detail-context {
position: relative;
}
.detail-pre {
font-family: inherit;
font-size: 0.875rem;
line-height: 1.6;
color: hsl(var(--foreground));
white-space: pre-wrap;
word-break: break-word;
padding: 0.75rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.375rem;
}
.btn-edit {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
background: transparent;
color: hsl(var(--muted-foreground));
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-edit:hover {
background: hsl(var(--muted));
color: hsl(var(--primary));
}
/* Solutions List */
.solutions-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.solution-item {
padding: 0.75rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.solution-item:hover {
border-color: hsl(var(--primary) / 0.5);
}
.solution-item.bound {
border-color: hsl(var(--success));
background: hsl(var(--success) / 0.05);
}
.solution-header {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.solution-id {
color: hsl(var(--muted-foreground));
}
.solution-bound-badge {
display: inline-flex;
padding: 0.125rem 0.375rem;
background: hsl(var(--success) / 0.15);
color: hsl(var(--success));
font-size: 0.6875rem;
font-weight: 500;
border-radius: 0.25rem;
}
.solution-tasks {
margin-left: auto;
color: hsl(var(--muted-foreground));
}
.solution-tasks-list {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid hsl(var(--border) / 0.5);
}
/* Tasks List */
.tasks-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.task-item-detail {
padding: 0.75rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
}
.task-title-detail {
font-size: 0.875rem;
color: hsl(var(--foreground));
margin-top: 0.375rem;
}
/* Edit Mode */
.edit-input,
.edit-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--primary));
border-radius: 0.375rem;
background: hsl(var(--background));
font-size: 0.875rem;
color: hsl(var(--foreground));
}
.edit-input:focus,
.edit-textarea:focus {
outline: none;
}
.edit-textarea {
resize: vertical;
min-height: 100px;
}
.edit-actions {
display: flex;
gap: 0.25rem;
margin-top: 0.5rem;
}
.btn-save,
.btn-cancel {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-save {
background: hsl(var(--success) / 0.15);
color: hsl(var(--success));
}
.btn-save:hover {
background: hsl(var(--success) / 0.25);
}
.btn-cancel {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
.btn-cancel:hover {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
/* ==========================================
QUEUE ENHANCEMENTS
========================================== */
.queue-info {
padding: 0.5rem 0;
}
.queue-items {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.queue-items.parallel {
flex-direction: row;
flex-wrap: wrap;
}
.queue-items.parallel .queue-item {
flex: 1;
min-width: 200px;
}
.queue-group-type {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
}
.queue-group-type.parallel {
color: hsl(142 71% 45%);
}
.queue-group-type.sequential {
color: hsl(262 83% 58%);
}
/* Queue Item Status Colors */
.queue-item.ready {
border-color: hsl(199 89% 48%);
}
.queue-item.executing {
border-color: hsl(45 93% 47%);
background: hsl(45 93% 47% / 0.05);
}
.queue-item.completed {
border-color: hsl(var(--success));
background: hsl(var(--success) / 0.05);
}
.queue-item.failed {
border-color: hsl(var(--destructive));
background: hsl(var(--destructive) / 0.05);
}
.queue-item.blocked {
border-color: hsl(262 83% 58%);
opacity: 0.7;
}
/* Priority indicator */
.issue-priority {
display: inline-flex;
align-items: center;
gap: 0.125rem;
}
/* Conflicts list */
.conflicts-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.conflict-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: hsl(45 93% 47% / 0.1);
border: 1px solid hsl(45 93% 47% / 0.3);
border-radius: 0.375rem;
}
.conflict-file {
color: hsl(var(--primary));
}
.conflict-tasks {
flex: 1;
}
.conflict-status {
font-size: 0.6875rem;
font-weight: 500;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.conflict-status.pending {
background: hsl(45 93% 47% / 0.15);
color: hsl(45 93% 47%);
}
.conflict-status.resolved {
background: hsl(var(--success) / 0.15);
color: hsl(var(--success));
}
/* ==========================================
RESPONSIVE TOOLBAR
========================================== */
@media (max-width: 640px) {
.issue-toolbar {
flex-direction: column;
align-items: stretch;
}
.issue-search {
max-width: none;
}
.issue-filters {
justify-content: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}

View File

@@ -212,7 +212,7 @@ function renderStreamTabs() {
<span class="cli-stream-tab-mode">${exec.mode}</span>
<button class="cli-stream-tab-close ${canClose ? '' : 'disabled'}"
onclick="event.stopPropagation(); closeStream('${id}')"
title="${canClose ? t('cliStream.close') : t('cliStream.cannotCloseRunning')}"
title="${canClose ? _streamT('cliStream.close') : _streamT('cliStream.cannotCloseRunning')}"
${canClose ? '' : 'disabled'}>×</button>
</div>
`;
@@ -238,8 +238,8 @@ function renderStreamContent(executionId) {
contentContainer.innerHTML = `
<div class="cli-stream-empty">
<i data-lucide="terminal"></i>
<div class="cli-stream-empty-title" data-i18n="cliStream.noStreams">${t('cliStream.noStreams')}</div>
<div class="cli-stream-empty-hint" data-i18n="cliStream.noStreamsHint">${t('cliStream.noStreamsHint')}</div>
<div class="cli-stream-empty-title" data-i18n="cliStream.noStreams">${_streamT('cliStream.noStreams')}</div>
<div class="cli-stream-empty-hint" data-i18n="cliStream.noStreamsHint">${_streamT('cliStream.noStreamsHint')}</div>
</div>
`;
if (typeof lucide !== 'undefined') lucide.createIcons();
@@ -279,10 +279,10 @@ function renderStreamStatus(executionId) {
: formatDuration(Date.now() - exec.startTime);
const statusLabel = exec.status === 'running'
? t('cliStream.running')
? _streamT('cliStream.running')
: exec.status === 'completed'
? t('cliStream.completed')
: t('cliStream.error');
? _streamT('cliStream.completed')
: _streamT('cliStream.error');
statusContainer.innerHTML = `
<div class="cli-stream-status-info">
@@ -296,15 +296,15 @@ function renderStreamStatus(executionId) {
</div>
<div class="cli-stream-status-item">
<i data-lucide="file-text"></i>
<span>${exec.output.length} ${t('cliStream.lines') || 'lines'}</span>
<span>${exec.output.length} ${_streamT('cliStream.lines') || 'lines'}</span>
</div>
</div>
<div class="cli-stream-status-actions">
<button class="cli-stream-toggle-btn ${autoScrollEnabled ? 'active' : ''}"
onclick="toggleAutoScroll()"
title="${t('cliStream.autoScroll')}">
title="${_streamT('cliStream.autoScroll')}">
<i data-lucide="arrow-down-to-line"></i>
<span data-i18n="cliStream.autoScroll">${t('cliStream.autoScroll')}</span>
<span data-i18n="cliStream.autoScroll">${_streamT('cliStream.autoScroll')}</span>
</button>
</div>
`;
@@ -428,10 +428,15 @@ function escapeHtml(text) {
return div.innerHTML;
}
// Translation helper with fallback
function t(key) {
if (typeof window.t === 'function') {
return window.t(key);
// Translation helper with fallback (uses global t from i18n.js)
function _streamT(key) {
// First try global t() from i18n.js
if (typeof t === 'function' && t !== _streamT) {
try {
return t(key);
} catch (e) {
// Fall through to fallbacks
}
}
// Fallback values
const fallbacks = {

View File

@@ -1729,6 +1729,50 @@ const i18n = {
'nav.issues': 'Issues',
'nav.issueManager': 'Manager',
'title.issueManager': 'Issue Manager',
// issues.* keys (used by issue-manager.js)
'issues.title': 'Issue Manager',
'issues.description': 'Manage issues, solutions, and execution queue',
'issues.viewIssues': 'Issues',
'issues.viewQueue': 'Queue',
'issues.filterStatus': 'Status',
'issues.filterAll': 'All',
'issues.noIssues': 'No issues found',
'issues.createHint': 'Click "Create" to add your first issue',
'issues.priority': 'Priority',
'issues.tasks': 'tasks',
'issues.solutions': 'solutions',
'issues.boundSolution': 'Bound',
'issues.queueEmpty': 'Queue is empty',
'issues.reorderHint': 'Drag items within a group to reorder',
'issues.parallelGroup': 'Parallel',
'issues.sequentialGroup': 'Sequential',
'issues.dependsOn': 'Depends on',
// Create & Search
'issues.create': 'Create',
'issues.createTitle': 'Create New Issue',
'issues.issueId': 'Issue ID',
'issues.issueTitle': 'Title',
'issues.issueContext': 'Context',
'issues.issuePriority': 'Priority',
'issues.titlePlaceholder': 'Brief description of the issue',
'issues.contextPlaceholder': 'Detailed description, requirements, etc.',
'issues.priorityLowest': 'Lowest',
'issues.priorityLow': 'Low',
'issues.priorityMedium': 'Medium',
'issues.priorityHigh': 'High',
'issues.priorityCritical': 'Critical',
'issues.searchPlaceholder': 'Search issues...',
'issues.showing': 'Showing',
'issues.of': 'of',
'issues.issues': 'issues',
'issues.tryDifferentFilter': 'Try adjusting your search or filters',
'issues.createFirst': 'Create First Issue',
'issues.idRequired': 'Issue ID is required',
'issues.titleRequired': 'Title is required',
'issues.created': 'Issue created successfully',
'issues.confirmDelete': 'Are you sure you want to delete this issue?',
'issues.deleted': 'Issue deleted',
// issue.* keys (legacy)
'issue.viewIssues': 'Issues',
'issue.viewQueue': 'Queue',
'issue.filterAll': 'All',
@@ -3508,6 +3552,50 @@ const i18n = {
'nav.issues': '议题',
'nav.issueManager': '管理器',
'title.issueManager': '议题管理器',
// issues.* keys (used by issue-manager.js)
'issues.title': '议题管理器',
'issues.description': '管理议题、解决方案和执行队列',
'issues.viewIssues': '议题',
'issues.viewQueue': '队列',
'issues.filterStatus': '状态',
'issues.filterAll': '全部',
'issues.noIssues': '暂无议题',
'issues.createHint': '点击"创建"添加您的第一个议题',
'issues.priority': '优先级',
'issues.tasks': '任务',
'issues.solutions': '解决方案',
'issues.boundSolution': '已绑定',
'issues.queueEmpty': '队列为空',
'issues.reorderHint': '在组内拖拽项目以重新排序',
'issues.parallelGroup': '并行',
'issues.sequentialGroup': '顺序',
'issues.dependsOn': '依赖于',
// Create & Search
'issues.create': '创建',
'issues.createTitle': '创建新议题',
'issues.issueId': '议题ID',
'issues.issueTitle': '标题',
'issues.issueContext': '上下文',
'issues.issuePriority': '优先级',
'issues.titlePlaceholder': '简要描述议题',
'issues.contextPlaceholder': '详细描述、需求等',
'issues.priorityLowest': '最低',
'issues.priorityLow': '低',
'issues.priorityMedium': '中',
'issues.priorityHigh': '高',
'issues.priorityCritical': '紧急',
'issues.searchPlaceholder': '搜索议题...',
'issues.showing': '显示',
'issues.of': '共',
'issues.issues': '条议题',
'issues.tryDifferentFilter': '尝试调整搜索或筛选条件',
'issues.createFirst': '创建第一个议题',
'issues.idRequired': '议题ID为必填',
'issues.titleRequired': '标题为必填',
'issues.created': '议题创建成功',
'issues.confirmDelete': '确定要删除此议题吗?',
'issues.deleted': '议题已删除',
// issue.* keys (legacy)
'issue.viewIssues': '议题',
'issue.viewQueue': '队列',
'issue.filterAll': '全部',

View File

@@ -10,6 +10,7 @@ var issueData = {
selectedIssue: null,
selectedSolution: null,
statusFilter: 'all',
searchQuery: '',
viewMode: 'issues' // 'issues' | 'queue'
};
var issueLoading = false;
@@ -91,10 +92,20 @@ function renderIssueView() {
if (!container) return;
const issues = issueData.issues || [];
const filteredIssues = issueData.statusFilter === 'all'
// Apply both status and search filters
let filteredIssues = issueData.statusFilter === 'all'
? issues
: issues.filter(i => i.status === issueData.statusFilter);
if (issueData.searchQuery) {
const query = issueData.searchQuery.toLowerCase();
filteredIssues = filteredIssues.filter(i =>
i.id.toLowerCase().includes(query) ||
(i.title && i.title.toLowerCase().includes(query)) ||
(i.context && i.context.toLowerCase().includes(query))
);
}
container.innerHTML = `
<div class="issue-manager">
<!-- Header -->
@@ -110,16 +121,24 @@ function renderIssueView() {
</div>
</div>
<!-- View Toggle -->
<div class="issue-view-toggle">
<button class="${issueData.viewMode === 'issues' ? 'active' : ''}" onclick="switchIssueView('issues')">
<i data-lucide="list" class="w-4 h-4 mr-1"></i>
${t('issues.viewIssues') || 'Issues'}
</button>
<button class="${issueData.viewMode === 'queue' ? 'active' : ''}" onclick="switchIssueView('queue')">
<i data-lucide="git-branch" class="w-4 h-4 mr-1"></i>
${t('issues.viewQueue') || 'Queue'}
<div class="flex items-center gap-3">
<!-- Create Button -->
<button class="issue-create-btn" onclick="showCreateIssueModal()">
<i data-lucide="plus" class="w-4 h-4"></i>
<span>${t('issues.create') || 'Create'}</span>
</button>
<!-- View Toggle -->
<div class="issue-view-toggle">
<button class="${issueData.viewMode === 'issues' ? 'active' : ''}" onclick="switchIssueView('issues')">
<i data-lucide="list" class="w-4 h-4 mr-1"></i>
${t('issues.viewIssues') || 'Issues'}
</button>
<button class="${issueData.viewMode === 'queue' ? 'active' : ''}" onclick="switchIssueView('queue')">
<i data-lucide="git-branch" class="w-4 h-4 mr-1"></i>
${t('issues.viewQueue') || 'Queue'}
</button>
</div>
</div>
</div>
</div>
@@ -128,6 +147,47 @@ function renderIssueView() {
<!-- Detail Panel -->
<div id="issueDetailPanel" class="issue-detail-panel hidden"></div>
<!-- Create Issue Modal -->
<div id="createIssueModal" class="issue-modal hidden">
<div class="issue-modal-backdrop" onclick="hideCreateIssueModal()"></div>
<div class="issue-modal-content">
<div class="issue-modal-header">
<h3>${t('issues.createTitle') || 'Create New Issue'}</h3>
<button class="btn-icon" onclick="hideCreateIssueModal()">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="issue-modal-body">
<div class="form-group">
<label>${t('issues.issueId') || 'Issue ID'}</label>
<input type="text" id="newIssueId" placeholder="e.g., GH-123 or TASK-001" />
</div>
<div class="form-group">
<label>${t('issues.issueTitle') || 'Title'}</label>
<input type="text" id="newIssueTitle" placeholder="${t('issues.titlePlaceholder') || 'Brief description of the issue'}" />
</div>
<div class="form-group">
<label>${t('issues.issueContext') || 'Context'} (${t('common.optional') || 'optional'})</label>
<textarea id="newIssueContext" rows="4" placeholder="${t('issues.contextPlaceholder') || 'Detailed description, requirements, etc.'}"></textarea>
</div>
<div class="form-group">
<label>${t('issues.issuePriority') || 'Priority'}</label>
<select id="newIssuePriority">
<option value="1">1 - ${t('issues.priorityLowest') || 'Lowest'}</option>
<option value="2">2 - ${t('issues.priorityLow') || 'Low'}</option>
<option value="3" selected>3 - ${t('issues.priorityMedium') || 'Medium'}</option>
<option value="4">4 - ${t('issues.priorityHigh') || 'High'}</option>
<option value="5">5 - ${t('issues.priorityCritical') || 'Critical'}</option>
</select>
</div>
</div>
<div class="issue-modal-footer">
<button class="btn-secondary" onclick="hideCreateIssueModal()">${t('common.cancel') || 'Cancel'}</button>
<button class="btn-primary" onclick="createIssue()">${t('issues.create') || 'Create'}</button>
</div>
</div>
</div>
</div>
`;
@@ -147,11 +207,26 @@ function switchIssueView(mode) {
// ========== Issue List Section ==========
function renderIssueListSection(issues) {
const statuses = ['all', 'registered', 'planning', 'planned', 'queued', 'executing', 'completed', 'failed'];
const totalIssues = issueData.issues?.length || 0;
return `
<!-- Filters -->
<div class="issue-filters mb-4">
<div class="flex items-center gap-2 flex-wrap">
<!-- Toolbar: Search + Filters -->
<div class="issue-toolbar mb-4">
<div class="issue-search">
<i data-lucide="search" class="w-4 h-4"></i>
<input type="text"
id="issueSearchInput"
placeholder="${t('issues.searchPlaceholder') || 'Search issues...'}"
value="${issueData.searchQuery}"
oninput="handleIssueSearch(this.value)" />
${issueData.searchQuery ? `
<button class="issue-search-clear" onclick="clearIssueSearch()">
<i data-lucide="x" class="w-3 h-3"></i>
</button>
` : ''}
</div>
<div class="issue-filters">
<span class="text-sm text-muted-foreground">${t('issues.filterStatus') || 'Status'}:</span>
${statuses.map(status => `
<button class="issue-filter-btn ${issueData.statusFilter === status ? 'active' : ''}"
@@ -162,13 +237,30 @@ function renderIssueListSection(issues) {
</div>
</div>
<!-- Issues Stats -->
<div class="issue-stats mb-4">
<span class="text-sm text-muted-foreground">
${t('issues.showing') || 'Showing'} <strong>${issues.length}</strong> ${t('issues.of') || 'of'} <strong>${totalIssues}</strong> ${t('issues.issues') || 'issues'}
</span>
</div>
<!-- Issues Grid -->
<div class="issues-grid">
${issues.length === 0 ? `
<div class="issue-empty">
<i data-lucide="inbox" class="w-12 h-12 text-muted-foreground mb-4"></i>
<p class="text-muted-foreground">${t('issues.noIssues') || 'No issues found'}</p>
<p class="text-sm text-muted-foreground mt-2">${t('issues.createHint') || 'Create issues using: ccw issue init <id>'}</p>
<div class="issue-empty-container">
<div class="issue-empty">
<i data-lucide="inbox" class="w-16 h-16"></i>
<p class="issue-empty-title">${t('issues.noIssues') || 'No issues found'}</p>
<p class="issue-empty-hint">${issueData.searchQuery || issueData.statusFilter !== 'all'
? (t('issues.tryDifferentFilter') || 'Try adjusting your search or filters')
: (t('issues.createHint') || 'Click "Create" to add your first issue')}</p>
${!issueData.searchQuery && issueData.statusFilter === 'all' ? `
<button class="issue-empty-btn" onclick="showCreateIssueModal()">
<i data-lucide="plus" class="w-4 h-4"></i>
${t('issues.createFirst') || 'Create First Issue'}
</button>
` : ''}
</div>
</div>
` : issues.map(issue => renderIssueCard(issue)).join('')}
</div>
@@ -702,3 +794,122 @@ async function updateTaskStatus(issueId, taskId, status) {
showNotification('Failed to update task status', 'error');
}
}
// ========== Search Functions ==========
function handleIssueSearch(value) {
issueData.searchQuery = value;
renderIssueView();
}
function clearIssueSearch() {
issueData.searchQuery = '';
renderIssueView();
}
// ========== Create Issue Modal ==========
function showCreateIssueModal() {
const modal = document.getElementById('createIssueModal');
if (modal) {
modal.classList.remove('hidden');
lucide.createIcons();
// Focus on first input
setTimeout(() => {
document.getElementById('newIssueId')?.focus();
}, 100);
}
}
function hideCreateIssueModal() {
const modal = document.getElementById('createIssueModal');
if (modal) {
modal.classList.add('hidden');
// Clear form
const idInput = document.getElementById('newIssueId');
const titleInput = document.getElementById('newIssueTitle');
const contextInput = document.getElementById('newIssueContext');
const prioritySelect = document.getElementById('newIssuePriority');
if (idInput) idInput.value = '';
if (titleInput) titleInput.value = '';
if (contextInput) contextInput.value = '';
if (prioritySelect) prioritySelect.value = '3';
}
}
async function createIssue() {
const idInput = document.getElementById('newIssueId');
const titleInput = document.getElementById('newIssueTitle');
const contextInput = document.getElementById('newIssueContext');
const prioritySelect = document.getElementById('newIssuePriority');
const issueId = idInput?.value?.trim();
const title = titleInput?.value?.trim();
const context = contextInput?.value?.trim();
const priority = parseInt(prioritySelect?.value || '3');
if (!issueId) {
showNotification(t('issues.idRequired') || 'Issue ID is required', 'error');
idInput?.focus();
return;
}
if (!title) {
showNotification(t('issues.titleRequired') || 'Title is required', 'error');
titleInput?.focus();
return;
}
try {
const response = await fetch('/api/issues?path=' + encodeURIComponent(projectPath), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: issueId,
title: title,
context: context,
priority: priority,
source: 'dashboard'
})
});
const result = await response.json();
if (!response.ok || result.error) {
showNotification(result.error || 'Failed to create issue', 'error');
return;
}
showNotification(t('issues.created') || 'Issue created successfully', 'success');
hideCreateIssueModal();
// Reload data and refresh view
await loadIssueData();
renderIssueView();
} catch (err) {
console.error('Failed to create issue:', err);
showNotification('Failed to create issue', 'error');
}
}
// ========== Delete Issue ==========
async function deleteIssue(issueId) {
if (!confirm(t('issues.confirmDelete') || 'Are you sure you want to delete this issue?')) {
return;
}
try {
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '?path=' + encodeURIComponent(projectPath), {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete');
showNotification(t('issues.deleted') || 'Issue deleted', 'success');
closeIssueDetail();
// Reload data and refresh view
await loadIssueData();
renderIssueView();
} catch (err) {
showNotification('Failed to delete issue', 'error');
}
}