diff --git a/.changeset/lively-moles-caper.md b/.changeset/lively-moles-caper.md new file mode 100644 index 000000000..a24af0393 --- /dev/null +++ b/.changeset/lively-moles-caper.md @@ -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. diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index a51b80f09..43cefdeaa 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -1895,10 +1895,62 @@ function getMilestoneInfo(cwd) { * to the current milestone based on ROADMAP.md phase headings. * 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(); + let missingExplicitVersion = false; 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:) const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; let m; @@ -1910,6 +1962,7 @@ function getMilestonePhaseFilter(cwd) { if (milestonePhaseNums.size === 0) { const passAll = () => true; passAll.phaseCount = 0; + passAll.missingExplicitVersion = missingExplicitVersion; return passAll; } @@ -1927,6 +1980,7 @@ function getMilestonePhaseFilter(cwd) { return false; } isDirInMilestone.phaseCount = milestonePhaseNums.size; + isDirInMilestone.missingExplicitVersion = missingExplicitVersion; return isDirInMilestone; } diff --git a/get-shit-done/bin/lib/milestone.cjs b/get-shit-done/bin/lib/milestone.cjs index 23c8dd4ba..d891ee362 100644 --- a/get-shit-done/bin/lib/milestone.cjs +++ b/get-shit-done/bin/lib/milestone.cjs @@ -107,7 +107,10 @@ function cmdMilestoneComplete(cwd, version, options, raw) { // Scope stats and accomplishments to only the phases belonging to the // current milestone's ROADMAP. Uses the shared filter from core.cjs // (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) let phaseCount = 0; diff --git a/tests/bug-3043-milestone-complete-scope.test.cjs b/tests/bug-3043-milestone-complete-scope.test.cjs new file mode 100644 index 000000000..2235aba14 --- /dev/null +++ b/tests/bug-3043-milestone-complete-scope.test.cjs @@ -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); + } + }); +});