285 lines
9.1 KiB
JavaScript
285 lines
9.1 KiB
JavaScript
/**
|
|
* 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<string, Set<string>>} depMap — Dependency map
|
|
* @param {Map<string, number>} depLevels — Computed levels
|
|
* @param {Map<string, object>} 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 };
|