Files
english/.opencode/skills/plans-kanban/scripts/lib/plan-metadata-extractor.cjs
2026-04-12 01:06:31 +07:00

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