Merge pull request #3109 from gsd-build/fix/3043-milestone-complete-version-scoping
fix: respect explicit milestone version in milestone complete
This commit is contained in:
5
.changeset/lively-moles-caper.md
Normal file
5
.changeset/lively-moles-caper.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 3043
|
||||||
|
---
|
||||||
|
milestone complete now scopes phase stats to the explicit version argument and errors when that version is missing from a versioned ROADMAP milestone section.
|
||||||
@@ -1895,10 +1895,62 @@ function getMilestoneInfo(cwd) {
|
|||||||
* to the current milestone based on ROADMAP.md phase headings.
|
* to the current milestone based on ROADMAP.md phase headings.
|
||||||
* If no ROADMAP exists or no phases are listed, returns a pass-all filter.
|
* If no ROADMAP exists or no phases are listed, returns a pass-all filter.
|
||||||
*/
|
*/
|
||||||
function getMilestonePhaseFilter(cwd) {
|
function getMilestonePhaseFilter(cwd, versionOverride) {
|
||||||
const milestonePhaseNums = new Set();
|
const milestonePhaseNums = new Set();
|
||||||
|
let missingExplicitVersion = false;
|
||||||
try {
|
try {
|
||||||
const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
|
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||||
|
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
|
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||||||
|
|
||||||
|
if (versionOverride) {
|
||||||
|
const escapedVersion = escapeRegex(versionOverride);
|
||||||
|
const sectionPattern = new RegExp(`(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`, 'mi');
|
||||||
|
const sectionMatch = roadmapContent.match(sectionPattern);
|
||||||
|
if (!sectionMatch) {
|
||||||
|
// Only treat this as an error case when the roadmap is milestone-versioned.
|
||||||
|
// Older/flat roadmap formats without vX.Y milestone headings should keep
|
||||||
|
// legacy pass-through behavior for milestone.complete.
|
||||||
|
const hasVersionedMilestones = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
|
||||||
|
if (hasVersionedMilestones) {
|
||||||
|
roadmap = '';
|
||||||
|
missingExplicitVersion = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sectionStart = sectionMatch.index;
|
||||||
|
const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length;
|
||||||
|
const restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length);
|
||||||
|
const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
|
||||||
|
|
||||||
|
let sectionEnd = roadmapContent.length;
|
||||||
|
let fenceChar = null;
|
||||||
|
let fenceLen = 0;
|
||||||
|
let charOffset = 0;
|
||||||
|
for (const line of restContent.split('\n')) {
|
||||||
|
const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const char = fenceMatch[1][0];
|
||||||
|
const len = fenceMatch[1].length;
|
||||||
|
const trailing = fenceMatch[2] || '';
|
||||||
|
if (!fenceChar) {
|
||||||
|
fenceChar = char;
|
||||||
|
fenceLen = len;
|
||||||
|
} else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
|
||||||
|
fenceChar = null;
|
||||||
|
fenceLen = 0;
|
||||||
|
}
|
||||||
|
} else if (!fenceChar && nextMilestonePattern.test(line)) {
|
||||||
|
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
charOffset += line.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
|
||||||
|
roadmap = currentSection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
|
// Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
|
||||||
const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||||
let m;
|
let m;
|
||||||
@@ -1910,6 +1962,7 @@ function getMilestonePhaseFilter(cwd) {
|
|||||||
if (milestonePhaseNums.size === 0) {
|
if (milestonePhaseNums.size === 0) {
|
||||||
const passAll = () => true;
|
const passAll = () => true;
|
||||||
passAll.phaseCount = 0;
|
passAll.phaseCount = 0;
|
||||||
|
passAll.missingExplicitVersion = missingExplicitVersion;
|
||||||
return passAll;
|
return passAll;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1927,6 +1980,7 @@ function getMilestonePhaseFilter(cwd) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
isDirInMilestone.phaseCount = milestonePhaseNums.size;
|
isDirInMilestone.phaseCount = milestonePhaseNums.size;
|
||||||
|
isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
|
||||||
return isDirInMilestone;
|
return isDirInMilestone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|||||||
// Scope stats and accomplishments to only the phases belonging to the
|
// Scope stats and accomplishments to only the phases belonging to the
|
||||||
// current milestone's ROADMAP. Uses the shared filter from core.cjs
|
// current milestone's ROADMAP. Uses the shared filter from core.cjs
|
||||||
// (same logic used by cmdPhasesList and other callers).
|
// (same logic used by cmdPhasesList and other callers).
|
||||||
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
|
||||||
|
if (isDirInMilestone.missingExplicitVersion) {
|
||||||
|
error(`no phases found for milestone ${version} in ROADMAP.md`);
|
||||||
|
}
|
||||||
|
|
||||||
// Gather stats from phases (scoped to current milestone only)
|
// Gather stats from phases (scoped to current milestone only)
|
||||||
let phaseCount = 0;
|
let phaseCount = 0;
|
||||||
|
|||||||
57
tests/bug-3043-milestone-complete-scope.test.cjs
Normal file
57
tests/bug-3043-milestone-complete-scope.test.cjs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
describe('bug #3043: milestone complete respects explicit version scope', () => {
|
||||||
|
test('milestone.complete v3.6 uses v3.6 phases even when STATE milestone is v3.5', () => {
|
||||||
|
const tmpDir = createTempProject('gsd-bug-3043-');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v3.5\n---\n');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
'# Roadmap\n\n## 🚧 v3.5 Paused\n### Phase 103: old\n### Phase 104: old2\n\n## 🚧 v3.6 Current\n### Phase 108: new\n',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
|
||||||
|
|
||||||
|
const oldDirA = path.join(tmpDir, '.planning', 'phases', '103.old');
|
||||||
|
const oldDirB = path.join(tmpDir, '.planning', 'phases', '104.old');
|
||||||
|
const newDir = path.join(tmpDir, '.planning', 'phases', '108.new');
|
||||||
|
fs.mkdirSync(oldDirA, { recursive: true });
|
||||||
|
fs.mkdirSync(oldDirB, { recursive: true });
|
||||||
|
fs.mkdirSync(newDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(oldDirA, 'SUMMARY.md'), 'one-liner: old milestone A\n\n## Summary\nold\n');
|
||||||
|
fs.writeFileSync(path.join(oldDirB, 'SUMMARY.md'), 'one-liner: old milestone B\n\n## Summary\nold\n');
|
||||||
|
fs.writeFileSync(path.join(newDir, 'SUMMARY.md'), 'one-liner: new milestone\n\n## Summary\nnew\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(['milestone', 'complete', 'v3.6', '--raw'], tmpDir);
|
||||||
|
assert.equal(result.success, true, result.error || result.output);
|
||||||
|
const payload = JSON.parse(result.output);
|
||||||
|
|
||||||
|
assert.equal(payload.version, 'v3.6');
|
||||||
|
assert.equal(payload.phases, 1, `expected v3.6 to scope to one phase, got ${payload.phases}`);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('milestone.complete fails when explicit milestone version resolves no phases', () => {
|
||||||
|
const tmpDir = createTempProject('gsd-bug-3043-empty-');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v1.0\n---\n');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
'# Roadmap\n\n## 🚧 v1.0\n### Phase 1: foundation\n',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n');
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||||
|
|
||||||
|
const result = runGsdTools(['milestone', 'complete', 'v9.9', '--raw'], tmpDir);
|
||||||
|
assert.equal(result.success, false, 'expected command to fail when no phases match explicit version');
|
||||||
|
assert.match(result.error || '', /no phases|phase/i);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user