Files
16gagent/research/pr-37/pr-37-delivery-planning/batch-planner.mjs
T
2026-06-06 10:40:48 +08:00

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