495 lines
15 KiB
JavaScript
495 lines
15 KiB
JavaScript
/**
|
|
* Plan Metadata Extractor
|
|
* Extracts rich metadata from plan.md files including dates, effort, priority, issues
|
|
* Supports YAML frontmatter (primary) with regex fallback for legacy plans
|
|
*
|
|
* @module plan-metadata-extractor
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const matter = require('gray-matter');
|
|
|
|
// Import shared normalizeStatus from plan-table-parser (DRY — single source of truth)
|
|
const sharedParser = require('../../../_shared/lib/plan-table-parser.cjs');
|
|
|
|
/**
|
|
* Normalize status string to standard format.
|
|
* Delegates to shared parser for core statuses (pending|in-progress|completed),
|
|
* then extends with metadata-specific statuses (cancelled, in-review).
|
|
* @param {string} status - Raw status string
|
|
* @returns {string} - Normalized status (pending|in-progress|completed|cancelled|in-review)
|
|
*/
|
|
function normalizeStatus(status) {
|
|
if (!status) return 'pending';
|
|
const s = String(status).toLowerCase().trim();
|
|
// Metadata-specific statuses not in the shared parser
|
|
if (s === 'cancelled' || s === 'canceled') return 'cancelled';
|
|
if (s === 'in-review' || s === 'review') return 'in-review';
|
|
// Delegate to shared parser for standard statuses
|
|
return sharedParser.normalizeStatus(status);
|
|
}
|
|
|
|
/**
|
|
* Normalize priority to P1/P2/P3 format
|
|
* @param {string} priority - Raw priority string
|
|
* @returns {string|null} - Normalized priority (P1|P2|P3) or null
|
|
*/
|
|
function normalizePriority(priority) {
|
|
if (!priority) return null;
|
|
const p = String(priority).toUpperCase().trim();
|
|
if (p === 'P1' || p === 'HIGH' || p === 'CRITICAL') return 'P1';
|
|
if (p === 'P2' || p === 'MEDIUM' || p === 'NORMAL') return 'P2';
|
|
if (p === 'P3' || p === 'LOW') return 'P3';
|
|
if (p.match(/^P[0-3]$/)) return p;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract metadata from YAML frontmatter (primary method)
|
|
* @param {string} content - File content
|
|
* @returns {Object|null} - Extracted metadata or null if no frontmatter
|
|
*/
|
|
function extractFromFrontmatter(content) {
|
|
if (!content || !content.trim().startsWith('---')) return null;
|
|
|
|
try {
|
|
const { data } = matter(content);
|
|
if (!data || Object.keys(data).length === 0) return null;
|
|
|
|
return {
|
|
title: data.title || null,
|
|
description: data.description || null,
|
|
status: normalizeStatus(data.status),
|
|
priority: normalizePriority(data.priority),
|
|
effort: data.effort || null,
|
|
issue: data.issue ? String(data.issue) : null,
|
|
branch: data.branch || null,
|
|
tags: Array.isArray(data.tags) ? data.tags : [],
|
|
createdDate: data.created ? new Date(data.created) : null,
|
|
completedDate: data.completed ? new Date(data.completed) : null,
|
|
assignee: data.assignee || null
|
|
};
|
|
} catch (e) {
|
|
// YAML parse error - fall back to regex
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract description from ## Overview section
|
|
* @param {string} content - File content
|
|
* @returns {string|null} - First paragraph of Overview section or null
|
|
*/
|
|
function extractDescriptionFromOverview(content) {
|
|
if (!content) return null;
|
|
|
|
// Look for ## Overview section
|
|
const overviewMatch = content.match(/##\s*Overview\s*\n+([^\n#]+)/i);
|
|
if (overviewMatch) {
|
|
const desc = overviewMatch[1].trim();
|
|
// Return first sentence or first 150 chars
|
|
const firstSentence = desc.match(/^[^.!?]+[.!?]/);
|
|
if (firstSentence) return firstSentence[0].trim();
|
|
return desc.slice(0, 150).trim();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse date from plan directory name (YYMMDD or YYYYMMDD format)
|
|
* @param {string} dirName - Directory name like "251211-feature-name"
|
|
* @returns {Date|null} - Parsed date or null
|
|
*/
|
|
function parseDateFromDirName(dirName) {
|
|
// Match YYMMDD-HHMM- or YYMMDD- or YYYYMMDD-
|
|
const match = dirName.match(/^(\d{6,8})(?:-(\d{4}))?-/);
|
|
if (!match) return null;
|
|
|
|
const dateStr = match[1];
|
|
let year, month, day;
|
|
|
|
if (dateStr.length === 6) {
|
|
// YYMMDD format
|
|
year = 2000 + parseInt(dateStr.slice(0, 2), 10);
|
|
month = parseInt(dateStr.slice(2, 4), 10) - 1;
|
|
day = parseInt(dateStr.slice(4, 6), 10);
|
|
} else {
|
|
// YYYYMMDD format
|
|
year = parseInt(dateStr.slice(0, 4), 10);
|
|
month = parseInt(dateStr.slice(4, 6), 10) - 1;
|
|
day = parseInt(dateStr.slice(6, 8), 10);
|
|
}
|
|
|
|
const date = new Date(year, month, day);
|
|
return isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
/**
|
|
* Extract metadata from plan.md header section
|
|
* Looks for patterns like **Key:** Value or **Key**: Value
|
|
* @param {string} content - Plan file content
|
|
* @returns {Object} - Extracted metadata
|
|
*/
|
|
function extractHeaderMetadata(content) {
|
|
const metadata = {
|
|
createdDate: null,
|
|
completedDate: null,
|
|
priority: null,
|
|
issue: null,
|
|
branch: null,
|
|
planId: null,
|
|
headerStatus: null
|
|
};
|
|
|
|
// Only look at first ~50 lines for header metadata
|
|
const headerSection = content.split('\n').slice(0, 50).join('\n');
|
|
|
|
// **Created:** 2025-12-01 or **Date:** 2025-12-11
|
|
const createdMatch = headerSection.match(/\*\*(?:Created|Date):?\*\*:?\s*(\d{4}-\d{2}-\d{2})/i);
|
|
if (createdMatch) {
|
|
metadata.createdDate = new Date(createdMatch[1]);
|
|
}
|
|
|
|
// **Status:** ✓ Complete (2025-12-01) - extract completion date
|
|
const statusMatch = headerSection.match(/\*\*Status:?\*\*:?\s*(.+)/i);
|
|
if (statusMatch) {
|
|
metadata.headerStatus = statusMatch[1].trim();
|
|
const completedMatch = statusMatch[1].match(/(?:complete|done).*?(\d{4}-\d{2}-\d{2})/i);
|
|
if (completedMatch) {
|
|
metadata.completedDate = new Date(completedMatch[1]);
|
|
}
|
|
}
|
|
|
|
// **Priority:** P1 - High
|
|
const priorityMatch = headerSection.match(/\*\*Priority:?\*\*:?\s*(P[0-3]|High|Medium|Low)/i);
|
|
if (priorityMatch) {
|
|
metadata.priority = priorityMatch[1].toUpperCase();
|
|
}
|
|
|
|
// **Issue:** #74 or **Issue**: https://github.com/.../issues/74
|
|
const issueMatch = headerSection.match(/\*\*Issue:?\*\*:?\s*(?:#(\d+)|.*?issues\/(\d+))/i);
|
|
if (issueMatch) {
|
|
metadata.issue = issueMatch[1] || issueMatch[2];
|
|
}
|
|
|
|
// **Branch:** `kai/feat/feature-name` or **Branch:** kai/feat/feature-name
|
|
const branchMatch = headerSection.match(/\*\*Branch:?\*\*:?\s*`?([^`\n]+)`?/i);
|
|
if (branchMatch) {
|
|
metadata.branch = branchMatch[1].trim();
|
|
}
|
|
|
|
// **Plan ID:** 20251201-1849-cli-ui-enhancement
|
|
const planIdMatch = headerSection.match(/\*\*Plan ID\*\*:?\s*(\S+)/i);
|
|
if (planIdMatch) {
|
|
metadata.planId = planIdMatch[1].trim();
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
/**
|
|
* Parse effort/time string to hours
|
|
* @param {string} effortStr - Effort string like "4h", "2 hours", "30m", "1.5h"
|
|
* @returns {number} - Hours as decimal
|
|
*/
|
|
function parseEffortToHours(effortStr) {
|
|
if (!effortStr) return 0;
|
|
|
|
const str = effortStr.toLowerCase().trim();
|
|
|
|
// Match patterns: 4h, 4 hours, 4hr, 30m, 30 min, 1.5h
|
|
const hoursMatch = str.match(/(\d+(?:\.\d+)?)\s*(?:h|hours?|hrs?)/);
|
|
if (hoursMatch) {
|
|
return parseFloat(hoursMatch[1]);
|
|
}
|
|
|
|
const minutesMatch = str.match(/(\d+)\s*(?:m|min|minutes?)/);
|
|
if (minutesMatch) {
|
|
return parseInt(minutesMatch[1], 10) / 60;
|
|
}
|
|
|
|
const daysMatch = str.match(/(\d+(?:\.\d+)?)\s*(?:d|days?)/);
|
|
if (daysMatch) {
|
|
return parseFloat(daysMatch[1]) * 8; // Assume 8h work day
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Extract effort estimates from phase table
|
|
* Looks for Effort/Time/Estimate columns in tables
|
|
* @param {string} content - Plan file content
|
|
* @returns {{totalEffort: number, phaseEfforts: Array<{phase: number, effort: number, effortStr: string}>}}
|
|
*/
|
|
function extractEffortFromTable(content) {
|
|
const result = {
|
|
totalEffort: 0,
|
|
phaseEfforts: []
|
|
};
|
|
|
|
// Match table rows with effort column
|
|
// Pattern: | Phase | Description | Status | Effort |
|
|
// Or: | [Phase 1](path) | Description | Status | 4h |
|
|
const tableRowRegex = /\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+(?:\.\d+)?\s*(?:h|m|d|hours?|min|days?)?)\s*\|/gi;
|
|
|
|
let match;
|
|
let phaseNum = 1;
|
|
while ((match = tableRowRegex.exec(content)) !== null) {
|
|
const effortStr = match[1].trim();
|
|
const effort = parseEffortToHours(effortStr);
|
|
if (effort > 0) {
|
|
result.phaseEfforts.push({
|
|
phase: phaseNum,
|
|
effort,
|
|
effortStr
|
|
});
|
|
result.totalEffort += effort;
|
|
phaseNum++;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Calculate plan duration in days
|
|
* @param {Date} startDate - Start date
|
|
* @param {Date} endDate - End date (or now if not completed)
|
|
* @returns {number} - Duration in days
|
|
*/
|
|
function calculateDuration(startDate, endDate) {
|
|
if (!startDate) return 0;
|
|
const end = endDate || new Date();
|
|
const diffMs = end.getTime() - startDate.getTime();
|
|
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
/**
|
|
* Format duration as human-readable string
|
|
* @param {number} days - Duration in days
|
|
* @returns {string} - Formatted string like "3 days" or "2 weeks"
|
|
*/
|
|
function formatDuration(days) {
|
|
if (days === 0) return 'Today';
|
|
if (days === 1) return '1 day';
|
|
if (days < 7) return `${days} days`;
|
|
if (days < 14) return '1 week';
|
|
if (days < 30) return `${Math.floor(days / 7)} weeks`;
|
|
if (days < 60) return '1 month';
|
|
return `${Math.floor(days / 30)} months`;
|
|
}
|
|
|
|
/**
|
|
* Extract all rich metadata from a plan
|
|
* Tries YAML frontmatter first, falls back to regex extraction
|
|
* @param {string} planFilePath - Path to plan.md
|
|
* @returns {Object} - Complete metadata object
|
|
*/
|
|
function extractPlanMetadata(planFilePath) {
|
|
const content = fs.readFileSync(planFilePath, 'utf8');
|
|
const dir = path.dirname(planFilePath);
|
|
const dirName = path.basename(dir);
|
|
const stats = fs.statSync(planFilePath);
|
|
|
|
// Try YAML frontmatter first (new format)
|
|
const frontmatter = extractFromFrontmatter(content);
|
|
|
|
// Get header metadata (legacy regex extraction)
|
|
const headerMeta = extractHeaderMetadata(content);
|
|
|
|
// Get date from directory name as fallback
|
|
const dirDate = parseDateFromDirName(dirName);
|
|
|
|
// Merge frontmatter with regex fallback
|
|
// Frontmatter takes priority, regex fills gaps
|
|
const createdDate = frontmatter?.createdDate || headerMeta.createdDate || dirDate || null;
|
|
const completedDate = frontmatter?.completedDate || headerMeta.completedDate || null;
|
|
const priority = frontmatter?.priority || normalizePriority(headerMeta.priority);
|
|
const issue = frontmatter?.issue || headerMeta.issue;
|
|
const branch = frontmatter?.branch || headerMeta.branch;
|
|
|
|
// Extract description: frontmatter > Overview section
|
|
const description = frontmatter?.description || extractDescriptionFromOverview(content);
|
|
|
|
// Tags from frontmatter only (no regex extraction for tags)
|
|
const tags = frontmatter?.tags || [];
|
|
|
|
// Get effort data from table
|
|
const effortData = extractEffortFromTable(content);
|
|
// Override with frontmatter effort if present
|
|
let totalEffort = effortData.totalEffort;
|
|
if (frontmatter?.effort) {
|
|
const parsed = parseEffortToHours(frontmatter.effort);
|
|
if (parsed > 0) totalEffort = parsed;
|
|
}
|
|
|
|
// Calculate duration
|
|
const duration = calculateDuration(createdDate, completedDate);
|
|
|
|
return {
|
|
// Dates
|
|
createdDate: createdDate ? createdDate.toISOString() : null,
|
|
completedDate: completedDate ? completedDate.toISOString() : null,
|
|
lastModified: stats.mtime.toISOString(),
|
|
|
|
// Duration
|
|
durationDays: duration,
|
|
durationFormatted: formatDuration(duration),
|
|
isCompleted: !!completedDate,
|
|
|
|
// Effort
|
|
totalEffortHours: totalEffort,
|
|
totalEffortFormatted: totalEffort > 0
|
|
? `${totalEffort.toFixed(1)}h`
|
|
: null,
|
|
phaseEfforts: effortData.phaseEfforts,
|
|
|
|
// Metadata (merged from frontmatter + regex)
|
|
title: frontmatter?.title || null,
|
|
description,
|
|
priority,
|
|
issue,
|
|
branch,
|
|
tags,
|
|
assignee: frontmatter?.assignee || null,
|
|
planId: headerMeta.planId,
|
|
// Use frontmatter status if available, otherwise regex-extracted header status
|
|
headerStatus: frontmatter?.status || headerMeta.headerStatus,
|
|
|
|
// Source indicator for debugging
|
|
hasFrontmatter: !!frontmatter
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate timeline data for multiple plans
|
|
* @param {Array} plans - Array of plan objects with metadata
|
|
* @returns {Object} - Timeline statistics
|
|
*/
|
|
function generateTimelineStats(plans) {
|
|
const now = new Date();
|
|
const stats = {
|
|
totalPlans: plans.length,
|
|
completedPlans: 0,
|
|
activePlans: 0,
|
|
pendingPlans: 0,
|
|
avgDurationDays: 0,
|
|
longestPlan: null,
|
|
totalEffortHours: 0,
|
|
completedEffortHours: 0,
|
|
thisWeekCompleted: 0,
|
|
thisMonthCompleted: 0
|
|
};
|
|
|
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
let totalDuration = 0;
|
|
let durationCount = 0;
|
|
|
|
for (const plan of plans) {
|
|
// Count by status
|
|
if (plan.status === 'completed') {
|
|
stats.completedPlans++;
|
|
if (plan.completedDate) {
|
|
const completed = new Date(plan.completedDate);
|
|
if (completed >= weekAgo) stats.thisWeekCompleted++;
|
|
if (completed >= monthAgo) stats.thisMonthCompleted++;
|
|
}
|
|
if (plan.totalEffortHours) {
|
|
stats.completedEffortHours += plan.totalEffortHours;
|
|
}
|
|
} else if (plan.status === 'in-progress') {
|
|
stats.activePlans++;
|
|
} else {
|
|
stats.pendingPlans++;
|
|
}
|
|
|
|
// Track duration
|
|
if (plan.durationDays > 0) {
|
|
totalDuration += plan.durationDays;
|
|
durationCount++;
|
|
if (!stats.longestPlan || plan.durationDays > stats.longestPlan.durationDays) {
|
|
stats.longestPlan = { name: plan.name, durationDays: plan.durationDays };
|
|
}
|
|
}
|
|
|
|
// Sum effort
|
|
if (plan.totalEffortHours) {
|
|
stats.totalEffortHours += plan.totalEffortHours;
|
|
}
|
|
}
|
|
|
|
stats.avgDurationDays = durationCount > 0
|
|
? Math.round(totalDuration / durationCount)
|
|
: 0;
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Generate activity data for heatmap (last 12 weeks)
|
|
* @param {Array} plans - Array of plan objects with metadata
|
|
* @returns {Array} - Array of weekly activity counts
|
|
*/
|
|
function generateActivityHeatmap(plans) {
|
|
const now = new Date();
|
|
const weeks = [];
|
|
|
|
// Generate 12 weeks of data
|
|
for (let w = 11; w >= 0; w--) {
|
|
const weekStart = new Date(now.getTime() - w * 7 * 24 * 60 * 60 * 1000);
|
|
weekStart.setHours(0, 0, 0, 0);
|
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay()); // Start of week (Sunday)
|
|
|
|
const weekEnd = new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
|
|
let activity = 0;
|
|
for (const plan of plans) {
|
|
// Count plans created or completed in this week
|
|
if (plan.createdDate) {
|
|
const created = new Date(plan.createdDate);
|
|
if (created >= weekStart && created < weekEnd) activity++;
|
|
}
|
|
if (plan.completedDate) {
|
|
const completed = new Date(plan.completedDate);
|
|
if (completed >= weekStart && completed < weekEnd) activity++;
|
|
}
|
|
}
|
|
|
|
weeks.push({
|
|
weekStart: weekStart.toISOString(),
|
|
weekLabel: weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
|
activity,
|
|
level: activity === 0 ? 0 : activity === 1 ? 1 : activity <= 3 ? 2 : 3
|
|
});
|
|
}
|
|
|
|
return weeks;
|
|
}
|
|
|
|
module.exports = {
|
|
// Core extraction functions
|
|
extractPlanMetadata,
|
|
extractFromFrontmatter,
|
|
extractDescriptionFromOverview,
|
|
extractHeaderMetadata,
|
|
extractEffortFromTable,
|
|
|
|
// Normalization helpers
|
|
normalizeStatus,
|
|
normalizePriority,
|
|
|
|
// Date/time utilities
|
|
parseDateFromDirName,
|
|
parseEffortToHours,
|
|
calculateDuration,
|
|
formatDuration,
|
|
|
|
// Statistics generators
|
|
generateTimelineStats,
|
|
generateActivityHeatmap
|
|
};
|