mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-09 02:24:11 +08:00
feat(discovery): enhance discovery index reading and issue exporting
- Improved the reading of the discovery index by adding a fallback mechanism to scan directories for discovery folders if the index.json is invalid or missing. - Added sorting of discoveries by creation time in descending order. - Enhanced the `appendToIssuesJsonl` function to include deduplication logic for issues based on ID and source finding ID. - Updated the discovery route handler to reflect the number of issues added and skipped during export. - Introduced UI elements for selecting and deselecting findings in the dashboard. - Added CSS styles for exported findings and action buttons. - Implemented search functionality for filtering findings based on title, file, and description. - Added internationalization support for new UI elements. - Created scripts for automated API extraction from various project types, including FastAPI and TypeScript. - Documented the API extraction process and library bundling instructions.
This commit is contained in:
@@ -46,11 +46,53 @@ function getDiscoveriesDir(projectPath: string): string {
|
||||
|
||||
function readDiscoveryIndex(discoveriesDir: string): { discoveries: any[]; total: number } {
|
||||
const indexPath = join(discoveriesDir, 'index.json');
|
||||
if (!existsSync(indexPath)) {
|
||||
|
||||
// Try to read index.json first
|
||||
if (existsSync(indexPath)) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(indexPath, 'utf8'));
|
||||
} catch {
|
||||
// Fall through to scan
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan directory for discovery folders
|
||||
if (!existsSync(discoveriesDir)) {
|
||||
return { discoveries: [], total: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(indexPath, 'utf8'));
|
||||
const entries = readdirSync(discoveriesDir, { withFileTypes: true });
|
||||
const discoveries: any[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('DSC-')) {
|
||||
const statePath = join(discoveriesDir, entry.name, 'discovery-state.json');
|
||||
if (existsSync(statePath)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
||||
discoveries.push({
|
||||
discovery_id: entry.name,
|
||||
target_pattern: state.target_pattern,
|
||||
perspectives: state.metadata?.perspectives || [],
|
||||
created_at: state.metadata?.created_at,
|
||||
completed_at: state.completed_at
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid entries
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation time descending
|
||||
discoveries.sort((a, b) => {
|
||||
const timeA = new Date(a.created_at || 0).getTime();
|
||||
const timeB = new Date(b.created_at || 0).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
return { discoveries, total: discoveries.length };
|
||||
} catch {
|
||||
return { discoveries: [], total: 0 };
|
||||
}
|
||||
@@ -139,7 +181,7 @@ function flattenFindings(perspectiveResults: any[]): any[] {
|
||||
return allFindings;
|
||||
}
|
||||
|
||||
function appendToIssuesJsonl(projectPath: string, issues: any[]) {
|
||||
function appendToIssuesJsonl(projectPath: string, issues: any[]): { added: number; skipped: number; skippedIds: string[] } {
|
||||
const issuesDir = join(projectPath, '.workflow', 'issues');
|
||||
const issuesPath = join(issuesDir, 'issues.jsonl');
|
||||
|
||||
@@ -158,24 +200,56 @@ function appendToIssuesJsonl(projectPath: string, issues: any[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert discovery issues to standard format and append
|
||||
const newIssues = issues.map(di => ({
|
||||
id: di.id,
|
||||
title: di.title,
|
||||
status: 'registered',
|
||||
priority: di.priority || 3,
|
||||
context: di.context || di.description || '',
|
||||
source: 'discovery',
|
||||
source_discovery_id: di.source_discovery_id,
|
||||
labels: di.labels || [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}));
|
||||
// Build set of existing IDs and source_finding combinations for deduplication
|
||||
const existingIds = new Set(existingIssues.map(i => i.id));
|
||||
const existingSourceFindings = new Set(
|
||||
existingIssues
|
||||
.filter(i => i.source === 'discovery' && i.source_finding_id)
|
||||
.map(i => `${i.source_discovery_id}:${i.source_finding_id}`)
|
||||
);
|
||||
|
||||
const allIssues = [...existingIssues, ...newIssues];
|
||||
writeFileSync(issuesPath, allIssues.map(i => JSON.stringify(i)).join('\n'));
|
||||
// Convert and filter duplicates
|
||||
const skippedIds: string[] = [];
|
||||
const newIssues: any[] = [];
|
||||
|
||||
return newIssues.length;
|
||||
for (const di of issues) {
|
||||
// Check for duplicate by ID
|
||||
if (existingIds.has(di.id)) {
|
||||
skippedIds.push(di.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicate by source_discovery_id + source_finding_id
|
||||
const sourceKey = `${di.source_discovery_id}:${di.source_finding_id}`;
|
||||
if (di.source_finding_id && existingSourceFindings.has(sourceKey)) {
|
||||
skippedIds.push(di.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
newIssues.push({
|
||||
id: di.id,
|
||||
title: di.title,
|
||||
status: 'registered',
|
||||
priority: di.priority || 3,
|
||||
context: di.context || di.description || '',
|
||||
source: 'discovery',
|
||||
source_discovery_id: di.source_discovery_id,
|
||||
source_finding_id: di.source_finding_id,
|
||||
perspective: di.perspective,
|
||||
file: di.file,
|
||||
line: di.line,
|
||||
labels: di.labels || [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
if (newIssues.length > 0) {
|
||||
const allIssues = [...existingIssues, ...newIssues];
|
||||
writeFileSync(issuesPath, allIssues.map(i => JSON.stringify(i)).join('\n'));
|
||||
}
|
||||
|
||||
return { added: newIssues.length, skipped: skippedIds.length, skippedIds };
|
||||
}
|
||||
|
||||
// ========== Route Handler ==========
|
||||
@@ -340,6 +414,7 @@ export async function handleDiscoveryRoutes(ctx: RouteContext): Promise<boolean>
|
||||
context: f.description || '',
|
||||
source: 'discovery',
|
||||
source_discovery_id: discoveryId,
|
||||
source_finding_id: f.id, // Track original finding ID for deduplication
|
||||
perspective: f.perspective,
|
||||
file: f.file,
|
||||
line: f.line,
|
||||
@@ -347,13 +422,49 @@ export async function handleDiscoveryRoutes(ctx: RouteContext): Promise<boolean>
|
||||
};
|
||||
});
|
||||
|
||||
// Append to main issues.jsonl
|
||||
const exportedCount = appendToIssuesJsonl(projectPath, issuesToExport);
|
||||
// Append to main issues.jsonl (with deduplication)
|
||||
const result = appendToIssuesJsonl(projectPath, issuesToExport);
|
||||
|
||||
// Mark exported findings in perspective files
|
||||
if (result.added > 0) {
|
||||
const exportedFindingIds = new Set(
|
||||
issuesToExport
|
||||
.filter((_, idx) => !result.skippedIds.includes(issuesToExport[idx].id))
|
||||
.map(i => i.source_finding_id)
|
||||
);
|
||||
|
||||
// Update each perspective file to mark findings as exported
|
||||
const perspectivesDir = join(discoveriesDir, discoveryId, 'perspectives');
|
||||
if (existsSync(perspectivesDir)) {
|
||||
const files = readdirSync(perspectivesDir).filter(f => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
const filePath = join(perspectivesDir, file);
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
if (content.findings) {
|
||||
let modified = false;
|
||||
for (const finding of content.findings) {
|
||||
if (exportedFindingIds.has(finding.id) && !finding.exported) {
|
||||
finding.exported = true;
|
||||
finding.exported_at = new Date().toISOString();
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (modified) {
|
||||
writeFileSync(filePath, JSON.stringify(content, null, 2));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update discovery state
|
||||
const state = readDiscoveryState(discoveriesDir, discoveryId);
|
||||
if (state) {
|
||||
state.issues_generated = (state.issues_generated || 0) + exportedCount;
|
||||
state.issues_generated = (state.issues_generated || 0) + result.added;
|
||||
writeFileSync(
|
||||
join(discoveriesDir, discoveryId, 'discovery-state.json'),
|
||||
JSON.stringify(state, null, 2)
|
||||
@@ -362,8 +473,12 @@ export async function handleDiscoveryRoutes(ctx: RouteContext): Promise<boolean>
|
||||
|
||||
return {
|
||||
success: true,
|
||||
exported_count: exportedCount,
|
||||
issue_ids: issuesToExport.map(i => i.id)
|
||||
exported_count: result.added,
|
||||
skipped_count: result.skipped,
|
||||
skipped_ids: result.skippedIds,
|
||||
message: result.skipped > 0
|
||||
? `Exported ${result.added} issues, skipped ${result.skipped} duplicates`
|
||||
: `Exported ${result.added} issues`
|
||||
};
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -358,17 +358,52 @@
|
||||
}
|
||||
|
||||
.findings-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.findings-count-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.findings-count .selected-count {
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.findings-count-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.select-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--border));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.select-action-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted));
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Findings List */
|
||||
.findings-list {
|
||||
flex: 1;
|
||||
@@ -413,6 +448,35 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.finding-item.exported {
|
||||
opacity: 0.6;
|
||||
background: hsl(var(--success) / 0.05);
|
||||
border: 1px solid hsl(var(--success) / 0.2);
|
||||
}
|
||||
|
||||
.finding-item.exported:hover {
|
||||
background: hsl(var(--success) / 0.08);
|
||||
}
|
||||
|
||||
/* Exported Badge */
|
||||
.exported-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: hsl(var(--success) / 0.1);
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.finding-checkbox input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.finding-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -28,6 +28,8 @@ const i18n = {
|
||||
'common.deleteFailed': 'Delete failed',
|
||||
'common.retry': 'Retry',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.back': 'Back',
|
||||
'common.search': 'Search...',
|
||||
'common.minutes': 'minutes',
|
||||
'common.enabled': 'Enabled',
|
||||
'common.disabled': 'Disabled',
|
||||
@@ -1783,6 +1785,8 @@ const i18n = {
|
||||
'discovery.impact': 'Impact',
|
||||
'discovery.recommendation': 'Recommendation',
|
||||
'discovery.exportAsIssues': 'Export as Issues',
|
||||
'discovery.selectAll': 'Select All',
|
||||
'discovery.deselectAll': 'Deselect All',
|
||||
'discovery.deleteSession': 'Delete Session',
|
||||
'discovery.confirmDelete': 'Are you sure you want to delete this discovery session?',
|
||||
'discovery.deleted': 'Discovery session deleted',
|
||||
@@ -1944,6 +1948,8 @@ const i18n = {
|
||||
'common.deleteFailed': '删除失败',
|
||||
'common.retry': '重试',
|
||||
'common.refresh': '刷新',
|
||||
'common.back': '返回',
|
||||
'common.search': '搜索...',
|
||||
'common.minutes': '分钟',
|
||||
'common.enabled': '已启用',
|
||||
'common.disabled': '已禁用',
|
||||
@@ -3527,6 +3533,8 @@ const i18n = {
|
||||
'common.edit': '编辑',
|
||||
'common.close': '关闭',
|
||||
'common.refresh': '刷新',
|
||||
'common.back': '返回',
|
||||
'common.search': '搜索...',
|
||||
'common.refreshed': '已刷新',
|
||||
'common.refreshing': '刷新中...',
|
||||
'common.loading': '加载中...',
|
||||
@@ -3708,6 +3716,8 @@ const i18n = {
|
||||
'discovery.impact': '影响',
|
||||
'discovery.recommendation': '建议',
|
||||
'discovery.exportAsIssues': '导出为议题',
|
||||
'discovery.selectAll': '全选',
|
||||
'discovery.deselectAll': '取消全选',
|
||||
'discovery.deleteSession': '删除会话',
|
||||
'discovery.confirmDelete': '确定要删除此发现会话吗?',
|
||||
'discovery.deleted': '发现会话已删除',
|
||||
|
||||
@@ -18,6 +18,28 @@ var discoveryData = {
|
||||
var discoveryLoading = false;
|
||||
var discoveryPollingInterval = null;
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
function getFilteredFindings() {
|
||||
const findings = discoveryData.findings || [];
|
||||
let filtered = findings;
|
||||
|
||||
if (discoveryData.perspectiveFilter !== 'all') {
|
||||
filtered = filtered.filter(f => f.perspective === discoveryData.perspectiveFilter);
|
||||
}
|
||||
if (discoveryData.priorityFilter !== 'all') {
|
||||
filtered = filtered.filter(f => f.priority === discoveryData.priorityFilter);
|
||||
}
|
||||
if (discoveryData.searchQuery) {
|
||||
const q = discoveryData.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(f =>
|
||||
(f.title && f.title.toLowerCase().includes(q)) ||
|
||||
(f.file && f.file.toLowerCase().includes(q)) ||
|
||||
(f.description && f.description.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// ========== Main Render Function ==========
|
||||
async function renderIssueDiscovery() {
|
||||
const container = document.getElementById('mainContent');
|
||||
@@ -258,23 +280,7 @@ function renderDiscoveryDetailSection() {
|
||||
|
||||
const findings = discoveryData.findings || [];
|
||||
const perspectives = [...new Set(findings.map(f => f.perspective))];
|
||||
|
||||
// Filter findings
|
||||
let filteredFindings = findings;
|
||||
if (discoveryData.perspectiveFilter !== 'all') {
|
||||
filteredFindings = filteredFindings.filter(f => f.perspective === discoveryData.perspectiveFilter);
|
||||
}
|
||||
if (discoveryData.priorityFilter !== 'all') {
|
||||
filteredFindings = filteredFindings.filter(f => f.priority === discoveryData.priorityFilter);
|
||||
}
|
||||
if (discoveryData.searchQuery) {
|
||||
const q = discoveryData.searchQuery.toLowerCase();
|
||||
filteredFindings = filteredFindings.filter(f =>
|
||||
(f.title && f.title.toLowerCase().includes(q)) ||
|
||||
(f.file && f.file.toLowerCase().includes(q)) ||
|
||||
(f.description && f.description.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
const filteredFindings = getFilteredFindings();
|
||||
|
||||
return `
|
||||
<div class="discovery-detail-container">
|
||||
@@ -305,10 +311,22 @@ function renderDiscoveryDetailSection() {
|
||||
|
||||
<!-- Findings Count -->
|
||||
<div class="findings-count">
|
||||
<span>${filteredFindings.length} ${t('discovery.findings') || 'findings'}</span>
|
||||
${discoveryData.selectedFindings.size > 0 ? `
|
||||
<span class="selected-count">(${discoveryData.selectedFindings.size} selected)</span>
|
||||
` : ''}
|
||||
<div class="findings-count-left">
|
||||
<span>${filteredFindings.length} ${t('discovery.findings') || 'findings'}</span>
|
||||
${discoveryData.selectedFindings.size > 0 ? `
|
||||
<span class="selected-count">(${discoveryData.selectedFindings.size} selected)</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="findings-count-actions">
|
||||
<button class="select-action-btn" onclick="selectAllFindings()">
|
||||
<i data-lucide="check-square" class="w-3 h-3"></i>
|
||||
<span>${t('discovery.selectAll') || 'Select All'}</span>
|
||||
</button>
|
||||
<button class="select-action-btn" onclick="deselectAllFindings()">
|
||||
<i data-lucide="square" class="w-3 h-3"></i>
|
||||
<span>${t('discovery.deselectAll') || 'Deselect All'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Findings List -->
|
||||
@@ -353,17 +371,19 @@ function renderDiscoveryDetailSection() {
|
||||
function renderFindingItem(finding) {
|
||||
const isSelected = discoveryData.selectedFindings.has(finding.id);
|
||||
const isActive = discoveryData.selectedFinding?.id === finding.id;
|
||||
const isExported = finding.exported === true;
|
||||
|
||||
return `
|
||||
<div class="finding-item ${isActive ? 'active' : ''} ${isSelected ? 'selected' : ''} ${finding.dismissed ? 'dismissed' : ''}"
|
||||
<div class="finding-item ${isActive ? 'active' : ''} ${isSelected ? 'selected' : ''} ${finding.dismissed ? 'dismissed' : ''} ${isExported ? 'exported' : ''}"
|
||||
onclick="selectFinding('${finding.id}')">
|
||||
<div class="finding-checkbox" onclick="event.stopPropagation(); toggleFindingSelection('${finding.id}')">
|
||||
<input type="checkbox" ${isSelected ? 'checked' : ''}>
|
||||
<input type="checkbox" ${isSelected ? 'checked' : ''} ${isExported ? 'disabled' : ''}>
|
||||
</div>
|
||||
<div class="finding-content">
|
||||
<div class="finding-header">
|
||||
<span class="perspective-badge ${finding.perspective}">${finding.perspective}</span>
|
||||
<span class="priority-badge ${finding.priority}">${finding.priority}</span>
|
||||
${isExported ? '<span class="exported-badge">' + (t('discovery.exported') || 'Exported') + '</span>' : ''}
|
||||
</div>
|
||||
<div class="finding-title">${finding.title || 'Untitled'}</div>
|
||||
<div class="finding-location">
|
||||
@@ -509,6 +529,23 @@ function toggleFindingSelection(findingId) {
|
||||
renderDiscoveryView();
|
||||
}
|
||||
|
||||
function selectAllFindings() {
|
||||
// Get filtered findings (respecting current filters)
|
||||
const filteredFindings = getFilteredFindings();
|
||||
// Select only non-exported findings
|
||||
for (const finding of filteredFindings) {
|
||||
if (!finding.exported) {
|
||||
discoveryData.selectedFindings.add(finding.id);
|
||||
}
|
||||
}
|
||||
renderDiscoveryView();
|
||||
}
|
||||
|
||||
function deselectAllFindings() {
|
||||
discoveryData.selectedFindings.clear();
|
||||
renderDiscoveryView();
|
||||
}
|
||||
|
||||
function filterDiscoveryByPerspective(perspective) {
|
||||
discoveryData.perspectiveFilter = perspective;
|
||||
renderDiscoveryView();
|
||||
@@ -539,11 +576,19 @@ async function exportSelectedFindings() {
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showNotification('success', `Exported ${result.exported_count} issues`);
|
||||
// Show detailed message if duplicates were skipped
|
||||
const msg = result.skipped_count > 0
|
||||
? `Exported ${result.exported_count} issues, skipped ${result.skipped_count} duplicates`
|
||||
: `Exported ${result.exported_count} issues`;
|
||||
showNotification('success', msg);
|
||||
discoveryData.selectedFindings.clear();
|
||||
// Reload discovery data
|
||||
// Reload discovery data to reflect exported status
|
||||
await loadDiscoveryData();
|
||||
renderDiscoveryView();
|
||||
if (discoveryData.selectedDiscovery) {
|
||||
await viewDiscoveryDetail(discoveryData.selectedDiscovery.discovery_id);
|
||||
} else {
|
||||
renderDiscoveryView();
|
||||
}
|
||||
} else {
|
||||
showNotification('error', result.error || 'Export failed');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user