/** * PR-37 Delivery Planning — Batch Planner (Part H) * * Generates 4 batch types based on dependency analysis: * - DevelopmentBatch: Group stories by dependency level for parallel dev * - TestingBatch: Group completed dev batches ready for testing * - ReleaseBatch: Group features ready for staged deployment * - DeliveryBatch: Customer-facing delivery increments * * Each batch captures parallel groups, prerequisite batches, and story/feature/epic references. * * @module pr-37-delivery-planning/batch-planner * @since PR-37 */ let _batchSeq = 0; /** * Generate a complete batch plan from SF-02 output and sprint/release plans. * * @param {object} opts * @param {object[]} opts.stories — User stories (from SF-02) * @param {object} opts.dependencies — Dependency graph { nodes, edges } * @param {object[]} opts.priorities — Priority matrix entries * @param {object} opts.sprints — Sprint plan (from sprint-planner) * @param {object} opts.releases — Release plan (from release-planner) * @returns {object} Batch plan { batches, summary } * * @example * ```js * const batchPlan = generateBatchPlan({ * stories: myStories, * dependencies: myGraph, * priorities: myPriorities, * sprints: sprintPlan, * releases: releasePlan, * }); * ``` */ export function generateBatchPlan({ stories, dependencies, priorities, sprints, releases }) { // ── Input validation ── if (!stories || stories.length === 0) { return { batches: [], summary: { totalBatches: 0, devBatches: 0, testBatches: 0, releaseBatches: 0, deliveryBatches: 0 }, warnings: ["No stories provided"], }; } const warnings = []; const batches = []; const graph = dependencies && dependencies.graph ? dependencies.graph : (dependencies || { nodes: [], edges: [] }); const storyMap = new Map(); for (const s of stories) storyMap.set(s.id, s); // Build dependency map: storyId → set of dependency IDs const depMap = new Map(); for (const s of stories) { depMap.set(s.id, new Set(s.dependencies || [])); } for (const edge of (graph.edges || [])) { // edge.from depends on edge.to (SF-02 semantics: from REQUIRES to) if (!depMap.has(edge.from)) depMap.set(edge.from, new Set()); depMap.get(edge.from).add(edge.to); } // ── Compute dependency levels ── const depLevels = new Map(); function computeLevel(id, visited = new Set()) { if (visited.has(id)) return 0; visited.add(id); const deps = depMap.get(id) || new Set(); if (deps.size === 0) { depLevels.set(id, 0); return 0; } let max = 0; for (const depId of deps) { max = Math.max(max, computeLevel(depId, visited) + 1); } depLevels.set(id, max); return max; } for (const s of stories) { if (!depLevels.has(s.id)) computeLevel(s.id); } // ── Priority map ── const priorityMap = new Map(); for (const p of (priorities || [])) { if (p.itemType === "story") priorityMap.set(p.itemId, p.category); } // ── 1. Development Batches ── // Group by dependency level const levelGroups = new Map(); // level → [storyIds] for (const s of stories) { const level = depLevels.get(s.id) ?? 0; // Bucket: L0, L1, L2, L3+ const bucket = level >= 3 ? "L3" : `L${level}`; if (!levelGroups.has(bucket)) levelGroups.set(bucket, []); levelGroups.get(bucket).push(s.id); } const levelOrder = ["L0", "L1", "L2", "L3"]; for (const bucket of levelOrder) { const ids = levelGroups.get(bucket) || []; if (ids.length === 0) continue; // Stories at same level can be developed in parallel — group them const batchId = `dev-batch-${++_batchSeq}`; const batchName = `Development ${bucket.replace("L", "Level ")}`; // Collect feature and epic IDs const featureIds = new Set(); const epicIds = new Set(); for (const id of ids) { const s = storyMap.get(id); if (s) { if (s.featureId) featureIds.add(s.featureId); if (s.epicId) epicIds.add(s.epicId); } } // Prerequisite: all lower-level batches const levelNum = parseInt(bucket.slice(1)); const prerequisiteBatches = []; for (let l = 0; l < levelNum; l++) { const lowerBucket = `L${l}`; const lowerIds = levelGroups.get(lowerBucket); if (lowerIds && lowerIds.length > 0) { prerequisiteBatches.push(`dev-batch-${lowerBucket}`); } } batches.push({ batchId, batchType: "DevelopmentBatch", name: batchName, storyIds: ids, featureIds: [...featureIds], epicIds: [...epicIds], parallelGroups: groupForParallel(ids, depMap, depLevels, storyMap), prerequisiteBatches, }); } // ── 2. Testing Batches ── // Group completed dev batches ready for testing (by sprint boundaries) if (sprints && sprints.sprints) { for (const sprint of sprints.sprints) { const testBatchId = `test-batch-${++_batchSeq}`; const batchName = `Testing Sprint ${sprint.sprintNumber}`; const featureIds = new Set(); const epicIds = new Set(); for (const sid of (sprint.storyIds || [])) { const s = storyMap.get(sid); if (s) { if (s.featureId) featureIds.add(s.featureId); if (s.epicId) epicIds.add(s.epicId); } } batches.push({ batchId: testBatchId, batchType: "TestingBatch", name: batchName, storyIds: sprint.storyIds || [], featureIds: [...featureIds], epicIds: [...epicIds], parallelGroups: [sprint.storyIds || []], // All stories from a sprint can be tested together prerequisiteBatches: [`dev-batch-${batchName}`], }); } } // ── 3. Release Batches ── // Group features ready for staged deployment (by release) if (releases && releases.releases) { for (const rel of releases.releases) { const releaseBatchId = `release-batch-${++_batchSeq}`; const featureIds = new Set(); const epicIds = new Set(); for (const sid of (rel.storyIds || [])) { const s = storyMap.get(sid); if (s) { if (s.featureId) featureIds.add(s.featureId); if (s.epicId) epicIds.add(s.epicId); } } batches.push({ batchId: releaseBatchId, batchType: "ReleaseBatch", name: `Release ${rel.name} (${rel.version})`, storyIds: rel.storyIds || [], featureIds: [...featureIds], epicIds: [...epicIds], parallelGroups: [rel.storyIds || []], prerequisiteBatches: [], }); } } // ── 4. Delivery Batches ── // Customer-facing delivery increments — group by release if (releases && releases.releases) { for (const rel of releases.releases) { const deliveryBatchId = `delivery-batch-${++_batchSeq}`; const featureIds = new Set(); const epicIds = new Set(); for (const sid of (rel.storyIds || [])) { const s = storyMap.get(sid); if (s) { if (s.featureId) featureIds.add(s.featureId); if (s.epicId) epicIds.add(s.epicId); } } batches.push({ batchId: deliveryBatchId, batchType: "DeliveryBatch", name: `Delivery ${rel.name} (${rel.version})`, storyIds: rel.storyIds || [], featureIds: [...featureIds], epicIds: [...epicIds], parallelGroups: [], prerequisiteBatches: [`release-batch-${rel.name}`], }); } } // ── Generate warnings ── if (levelGroups.size === 0) { warnings.push("No development batches generated — empty story list"); } if (batches.length === 0) { warnings.push("No batches of any type could be generated"); } const summary = { totalBatches: batches.length, devBatches: batches.filter(b => b.batchType === "DevelopmentBatch").length, testBatches: batches.filter(b => b.batchType === "TestingBatch").length, releaseBatches: batches.filter(b => b.batchType === "ReleaseBatch").length, deliveryBatches: batches.filter(b => b.batchType === "DeliveryBatch").length, }; return { batches, summary, warnings }; } /** * Group story IDs into parallel work groups based on dependency analysis. * Stories with no interdependency at the same level can be done in parallel. * * @param {string[]} ids — Story IDs at the same dependency level * @param {Map>} depMap — Dependency map * @param {Map} depLevels — Computed levels * @param {Map} storyMap — Story lookup * @returns {string[][]} Groups of stories that can be developed in parallel */ function groupForParallel(ids, depMap, depLevels, storyMap) { if (ids.length === 0) return []; if (ids.length === 1) return [ids]; // Simple heuristic: group by epic ID so stories from same epic are together, // but different epics can proceed in parallel const epicGroups = new Map(); for (const id of ids) { const s = storyMap.get(id); const epicId = s ? s.epicId || "unknown" : "unknown"; if (!epicGroups.has(epicId)) epicGroups.set(epicId, []); epicGroups.get(epicId).push(id); } // Return epic-based groups as parallel units return [...epicGroups.values()]; } export default { generateBatchPlan };