386 lines
12 KiB
JavaScript
386 lines
12 KiB
JavaScript
/**
|
|
* PR-37 Delivery Planning — Release Planner (Part G)
|
|
*
|
|
* Maps MVP/V1/V2/Enterprise/Global phases to concrete releases:
|
|
* - Release 1.0 (MVP)
|
|
* - Release 1.1 (MVP polish)
|
|
* - Release 2.0 (V1)
|
|
* - Enterprise Edition
|
|
* - Global Edition
|
|
*
|
|
* Each release includes version info, feature counts, validation gates,
|
|
* release goals, dependency checks, and relative timing.
|
|
*
|
|
* @module pr-37-delivery-planning/release-planner
|
|
* @since PR-37
|
|
*/
|
|
|
|
/**
|
|
* Standard validation gates applied to every release.
|
|
*
|
|
* @type {string[]}
|
|
*/
|
|
const DEFAULT_VALIDATION_GATES = [
|
|
"All ACs pass",
|
|
"Regression test pass",
|
|
"Performance benchmark meet",
|
|
"Security scan pass",
|
|
"UAT sign-off",
|
|
];
|
|
|
|
/**
|
|
* Generate a release plan from SF-02 output.
|
|
*
|
|
* @param {object} opts
|
|
* @param {object} opts.mvpScope — MVP scope from SF-02 (mvp, v1, v2, enterprise, global phases)
|
|
* @param {object[]} opts.priorities — Priority matrix entries (from SF-02)
|
|
* @param {object} opts.dependencies — Dependency graph { nodes, edges }
|
|
* @param {object[]} opts.stories — User stories (from SF-02 userStories)
|
|
* @param {object[]} opts.epics — Epics (from SF-02)
|
|
* @param {object[]} opts.features — Features (from SF-02)
|
|
* @returns {object} Release plan
|
|
*
|
|
* @example
|
|
* ```js
|
|
* const plan = generateReleasePlan({
|
|
* mvpScope: sf02MvpScope,
|
|
* priorities: sf02Priorities,
|
|
* dependencies: sf02Graph,
|
|
* stories: sf02Stories,
|
|
* epics: sf02Epics,
|
|
* features: sf02Features,
|
|
* });
|
|
* ```
|
|
*/
|
|
export function generateReleasePlan({ mvpScope, priorities, dependencies, stories, epics, features }) {
|
|
// ── Input validation ──
|
|
if (!stories || stories.length === 0) {
|
|
return {
|
|
releases: [],
|
|
warnings: ["No stories provided; no releases generated"],
|
|
};
|
|
}
|
|
|
|
const warnings = [];
|
|
const priorityMap = new Map();
|
|
for (const p of (priorities || [])) {
|
|
if (p.itemType === "story") {
|
|
priorityMap.set(p.itemId, p.category);
|
|
}
|
|
}
|
|
|
|
// Build lookup maps
|
|
const storyMap = new Map();
|
|
for (const s of (stories || [])) storyMap.set(s.id, s);
|
|
|
|
const featureMap = new Map();
|
|
for (const f of (features || [])) featureMap.set(f.id, f);
|
|
|
|
const epicMap = new Map();
|
|
for (const e of (epics || [])) epicMap.set(e.id, e);
|
|
|
|
const dependencyEdges = (dependencies && dependencies.edges) ? dependencies.edges : [];
|
|
|
|
/**
|
|
* Map phase scope from MVP scope → actual story/feature/epic IDs.
|
|
*/
|
|
function resolvePhaseStories(phase) {
|
|
if (!phase || !phase.mustHaveStories) return [];
|
|
return phase.mustHaveStories.map(s => s.id || s);
|
|
}
|
|
|
|
function resolvePhaseEpics(phase, epics) {
|
|
if (!phase || !phase.epics) return [];
|
|
// If epics is a count, we can't resolve IDs; return empty
|
|
if (typeof phase.epics === "number") return [];
|
|
return [...phase.epics].slice(0);
|
|
}
|
|
|
|
function resolvePhaseFeatures(phase, features) {
|
|
if (!phase || !phase.features) return [];
|
|
if (typeof phase.features === "number") return [];
|
|
return [...phase.features].slice(0);
|
|
}
|
|
|
|
// ── Helper: filter stories by priority ──
|
|
function filterStoriesByPriority(categories) {
|
|
const ids = [];
|
|
for (const s of (stories || [])) {
|
|
const cat = priorityMap.get(s.id) || s.priority || "Could Have";
|
|
if (categories.includes(cat)) ids.push(s.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
// ── Helper: find stories with critical unresolved deps ──
|
|
function findCriticalUnresolved(storyIds, availableIds) {
|
|
const available = new Set(availableIds);
|
|
const issues = [];
|
|
for (const sid of storyIds) {
|
|
const story = storyMap.get(sid);
|
|
if (!story) continue;
|
|
const deps = story.dependencies || [];
|
|
for (const depId of deps) {
|
|
if (!available.has(depId)) {
|
|
const depStory = storyMap.get(depId);
|
|
issues.push({
|
|
storyId: sid,
|
|
storyStatement: story.statement || sid,
|
|
unresolvedDepId: depId,
|
|
unresolvedDepStatement: depStory ? depStory.statement : depId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Also check dependency graph edges
|
|
const edgeIssues = [];
|
|
const storyIdSet = new Set(storyIds);
|
|
for (const edge of dependencyEdges) {
|
|
if (storyIdSet.has(edge.to)) {
|
|
const fromStory = storyMap.get(edge.from);
|
|
const toStory = storyMap.get(edge.to);
|
|
if (!available.has(edge.from) && fromStory && toStory) {
|
|
edgeIssues.push({
|
|
storyId: edge.to,
|
|
storyStatement: toStory.statement || edge.to,
|
|
unresolvedDepId: edge.from,
|
|
unresolvedDepStatement: fromStory.statement || edge.from,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return [...issues, ...edgeIssues].slice(0, 5); // limit to 5
|
|
}
|
|
|
|
// ── Helper: build release object ──
|
|
function buildRelease({ version, name, description, storyIds, releaseNumber }) {
|
|
const uniqueEpicIds = new Set();
|
|
const uniqueFeatureIds = new Set();
|
|
let storyCount = 0;
|
|
let storyPoints = 0;
|
|
|
|
for (const sid of storyIds) {
|
|
const s = storyMap.get(sid);
|
|
if (!s) continue;
|
|
storyCount++;
|
|
storyPoints += s.storyPoints || 1;
|
|
if (s.epicId) uniqueEpicIds.add(s.epicId);
|
|
if (s.featureId) uniqueFeatureIds.add(s.featureId);
|
|
}
|
|
|
|
// Resolve epic/feature names
|
|
const epicNames = [...uniqueEpicIds].map(eid => {
|
|
const epic = epicMap.get(eid);
|
|
return epic ? epic.title : eid;
|
|
}).filter(Boolean);
|
|
|
|
const featureNames = [...uniqueFeatureIds].map(fid => {
|
|
const feat = featureMap.get(fid);
|
|
return feat ? feat.title : fid;
|
|
}).filter(Boolean);
|
|
|
|
// Determine all resolved IDs for dependency check
|
|
const resolvedIds = new Set(storyIds);
|
|
|
|
const depIssues = findCriticalUnresolved(storyIds, resolvedIds);
|
|
|
|
return {
|
|
version,
|
|
name,
|
|
description,
|
|
features: {
|
|
epicCount: uniqueEpicIds.size,
|
|
epicNames,
|
|
featureCount: uniqueFeatureIds.size,
|
|
featureNames: featureNames.slice(0, 10), // top 10
|
|
storyCount,
|
|
storyPoints,
|
|
},
|
|
validationGates: [...DEFAULT_VALIDATION_GATES],
|
|
releaseGoals: deriveReleaseGoals(name, version, storyIds, storyMap),
|
|
dependencyCheck: depIssues,
|
|
releaseDate: `Release ${releaseNumber}`,
|
|
};
|
|
}
|
|
|
|
// ── 1. Release 1.0 (MVP) ──
|
|
// Must Have stories from MVP phase
|
|
const mvpPhase = mvpScope && mvpScope.mvp;
|
|
let mvpStoryIds = [];
|
|
|
|
if (mvpPhase && mvpPhase.mustHaveStories && Array.isArray(mvpPhase.mustHaveStories)) {
|
|
mvpStoryIds = mvpPhase.mustHaveStories.map(s => s.id || s);
|
|
}
|
|
|
|
// Fallback: filter Must Have from all stories
|
|
if (mvpStoryIds.length === 0) {
|
|
mvpStoryIds = filterStoriesByPriority(["Must Have"]);
|
|
}
|
|
|
|
// ── 2. Release 1.1 (MVP polish) ──
|
|
// Remaining Must Have + critical Should Have fixes
|
|
const shouldHaveIds = filterStoriesByPriority(["Should Have"]);
|
|
// Take first ~30% of Should Have for critical fixes
|
|
const criticalShouldHave = shouldHaveIds.slice(0, Math.max(1, Math.ceil(shouldHaveIds.length * 0.3)));
|
|
|
|
// Remaining Must Have stories not in MVP
|
|
const remainingMustHave = mvpStoryIds.filter(id => !mvpStoryIds.includes(id));
|
|
|
|
// ── 3. Release 2.0 (V1) ──
|
|
// All Should Have stories that aren't in 1.1
|
|
const v1StoryIds = shouldHaveIds.filter(id => !criticalShouldHave.includes(id));
|
|
|
|
// ── 4. Enterprise Edition ──
|
|
const enterprisePhase = mvpScope && mvpScope.enterprise;
|
|
let enterpriseStoryIds = [];
|
|
if (enterprisePhase && enterprisePhase.stories) {
|
|
enterpriseStoryIds = [...enterprisePhase.stories];
|
|
}
|
|
|
|
// ── 5. Global Edition ──
|
|
const globalPhase = mvpScope && mvpScope.global;
|
|
let globalStoryIds = [];
|
|
if (globalPhase && globalPhase.stories) {
|
|
globalStoryIds = [...globalPhase.stories];
|
|
}
|
|
|
|
// Remove duplicates: Enterprise/Global should contain stories not in earlier releases
|
|
const v2CouldIds = filterStoriesByPriority(["Could Have"]);
|
|
if (enterpriseStoryIds.length === 0) {
|
|
// Fallback: Could Have stories for Enterprise
|
|
enterpriseStoryIds = v2CouldIds.slice(0, Math.ceil(v2CouldIds.length * 0.6));
|
|
}
|
|
if (globalStoryIds.length === 0) {
|
|
globalStoryIds = v2CouldIds.slice(Math.ceil(v2CouldIds.length * 0.6));
|
|
}
|
|
|
|
// ── Assemble releases ──
|
|
const releases = [];
|
|
|
|
if (mvpStoryIds.length > 0) {
|
|
releases.push(buildRelease({
|
|
version: "1.0.0",
|
|
name: "MVP",
|
|
description: "Minimum Viable Product — core Must Have functionalities for initial market validation",
|
|
storyIds: mvpStoryIds,
|
|
releaseNumber: 1,
|
|
}));
|
|
}
|
|
|
|
if (criticalShouldHave.length > 0 || remainingMustHave.length > 0) {
|
|
const polishIds = [...new Set([...criticalShouldHave, ...remainingMustHave])];
|
|
releases.push(buildRelease({
|
|
version: "1.1.0",
|
|
name: "MVP Polish",
|
|
description: "MVP refinement — remaining Must Have items and critical Should Have fixes",
|
|
storyIds: polishIds,
|
|
releaseNumber: 2,
|
|
}));
|
|
}
|
|
|
|
if (v1StoryIds.length > 0) {
|
|
releases.push(buildRelease({
|
|
version: "2.0.0",
|
|
name: "V1",
|
|
description: "First full version — complete Should Have feature set for competitive feature parity",
|
|
storyIds: v1StoryIds,
|
|
releaseNumber: 3,
|
|
}));
|
|
}
|
|
|
|
if (enterpriseStoryIds.length > 0) {
|
|
releases.push(buildRelease({
|
|
version: "3.0.0",
|
|
name: "Enterprise Edition",
|
|
description: "Enterprise-grade features — advanced security, multi-tenancy, compliance, and admin capabilities",
|
|
storyIds: enterpriseStoryIds,
|
|
releaseNumber: 4,
|
|
}));
|
|
}
|
|
|
|
if (globalStoryIds.length > 0) {
|
|
releases.push(buildRelease({
|
|
version: "4.0.0",
|
|
name: "Global Edition",
|
|
description: "Global-scale features — i18n, multi-region, compliance, and localization",
|
|
storyIds: globalStoryIds,
|
|
releaseNumber: 5,
|
|
}));
|
|
}
|
|
|
|
// ── Generate warnings ──
|
|
if (mvpStoryIds.length === 0) {
|
|
warnings.push("No MVP stories found; Release 1.0 will be empty");
|
|
}
|
|
if (releases.length === 0) {
|
|
warnings.push("No releases could be generated from the provided data");
|
|
}
|
|
for (const rel of releases) {
|
|
if (rel.dependencyCheck.length > 0) {
|
|
warnings.push(`${rel.name} (${rel.version}): ${rel.dependencyCheck.length} stories have unresolved critical dependencies`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
releases,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Derive business-oriented release goals from the story set.
|
|
*
|
|
* @param {string} name — Release name
|
|
* @param {string} version — Semver
|
|
* @param {string[]} storyIds — Stories in this release
|
|
* @param {Map<string, object>} storyMap — Story lookup
|
|
* @returns {string[]} Goals
|
|
*/
|
|
function deriveReleaseGoals(name, version, storyIds, storyMap) {
|
|
const goals = [];
|
|
|
|
switch (name) {
|
|
case "MVP":
|
|
goals.push("Deliver core value proposition to early adopters");
|
|
goals.push("Validate product-market fit with real users");
|
|
goals.push("Establish foundational user identity and access system");
|
|
break;
|
|
case "MVP Polish":
|
|
goals.push("Address critical feedback from MVP users");
|
|
goals.push("Stabilize core features for broader adoption");
|
|
goals.push("Improve reliability and performance to production standards");
|
|
break;
|
|
case "V1":
|
|
goals.push("Achieve feature parity with market competitors");
|
|
goals.push("Provide comprehensive user experience for mainstream adoption");
|
|
goals.push("Enable self-service onboarding and administration");
|
|
break;
|
|
case "Enterprise Edition":
|
|
goals.push("Meet enterprise security and compliance requirements");
|
|
goals.push("Support multi-tenant deployment at scale");
|
|
goals.push("Provide advanced audit, governance, and admin tools");
|
|
break;
|
|
case "Global Edition":
|
|
goals.push("Support internationalization and localization");
|
|
goals.push("Meet global data residency and privacy regulations");
|
|
goals.push("Enable multi-region deployment and failover");
|
|
break;
|
|
default:
|
|
goals.push(`Deliver ${name} (${version}) features to users`);
|
|
break;
|
|
}
|
|
|
|
// Add story-specific goal from top stories
|
|
const topStories = storyIds.slice(0, 3);
|
|
for (const sid of topStories) {
|
|
const s = storyMap.get(sid);
|
|
if (s && s.value) {
|
|
goals.push(`Enable: ${s.value.length > 60 ? s.value.slice(0, 60) + "…" : s.value}`);
|
|
}
|
|
}
|
|
|
|
return goals;
|
|
}
|
|
|
|
export default { generateReleasePlan };
|