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:
catlog22
2025-12-28 19:27:34 +08:00
parent 3ef1e54412
commit 169f218f7a
18 changed files with 1602 additions and 612 deletions

View File

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

View File

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

View File

@@ -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': '发现会话已删除',

View File

@@ -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');
}