@nan0web/release 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nan0release.js +33 -2
- package/bin/serve.js +73 -0
- package/package.json +45 -17
- package/src/Release/Document.js +1 -0
- package/src/Release/Scanner.js +3 -2
- package/src/Release.js +7 -7
- package/src/domain/App.js +31 -2
- package/src/domain/BumpCommand.js +10 -3
- package/src/domain/CheckCommand.js +504 -146
- package/src/domain/CheckCommand.story.js +92 -0
- package/src/domain/CloseCommand.js +13 -9
- package/src/domain/Dashboard/Dashboard.story.js +155 -0
- package/src/domain/Dashboard/DashboardModel.js +308 -0
- package/src/domain/Dashboard/ReleaseDiscovery.js +132 -0
- package/src/domain/Dashboard/ReleaseParser.js +226 -0
- package/src/domain/Dashboard/index.js +19 -0
- package/src/domain/DepsCommand.js +5 -2
- package/src/domain/InitCommand.js +18 -16
- package/src/domain/ListCommand.js +45 -0
- package/src/domain/PreviewCommand.js +39 -0
- package/src/domain/PublishCommand.js +7 -3
- package/src/domain/Registry/Registry.story.js +82 -0
- package/src/domain/Registry/RegistryModel.js +124 -0
- package/src/domain/ServeCommand.js +165 -0
- package/src/domain/ServeCommand.story.js +88 -0
- package/src/domain/SpecCommand.js +3 -1
- package/src/domain/StatusCommand.js +249 -3
- package/src/domain/ViewCommand.js +99 -0
- package/src/domain/WebCommand.js +140 -0
- package/src/domain/inspect/ReleaseAuditor.js +508 -0
- package/src/domain/inspect/ReleaseAuditor.story.js +136 -0
- package/src/domain/templates/release.html +34 -0
- package/src/domain/templates/release.html.template.js +34 -0
- package/src/index.js +6 -0
- package/src/inspectors/inspect-js.js +32 -0
- package/src/releases/1/0/v1.0.3/task.test.js +1 -1
- package/src/utils/nodejs/GitTelemetry.js +154 -0
- package/types/Release/Document.d.ts +24 -27
- package/types/Release/MarkdownToTest.d.ts +1 -1
- package/types/Release/Person.d.ts +9 -7
- package/types/Release/Scanner.d.ts +41 -41
- package/types/Release.d.ts +42 -42
- package/types/architecture/ProjectManagementAsCode.d.ts +59 -64
- package/types/domain/App.d.ts +24 -7
- package/types/domain/BumpCommand.d.ts +11 -6
- package/types/domain/CheckCommand.d.ts +128 -16
- package/types/domain/CheckCommand.story.d.ts +1 -0
- package/types/domain/CloseCommand.d.ts +4 -6
- package/types/domain/Dashboard/Dashboard.story.d.ts +1 -0
- package/types/domain/Dashboard/DashboardModel.d.ts +273 -0
- package/types/domain/Dashboard/GitTelemetry.d.ts +23 -0
- package/types/domain/Dashboard/ReleaseDiscovery.d.ts +47 -0
- package/types/domain/Dashboard/ReleaseParser.d.ts +79 -0
- package/types/domain/Dashboard/index.d.ts +3 -0
- package/types/domain/DepsCommand.d.ts +6 -6
- package/types/domain/InitCommand.d.ts +4 -6
- package/types/domain/ListCommand.d.ts +18 -0
- package/types/domain/PreviewCommand.d.ts +6 -0
- package/types/domain/PublishCommand.d.ts +8 -6
- package/types/domain/Registry/Registry.story.d.ts +1 -0
- package/types/domain/Registry/RegistryModel.d.ts +61 -0
- package/types/domain/ServeCommand.d.ts +59 -0
- package/types/domain/ServeCommand.story.d.ts +1 -0
- package/types/domain/SpecCommand.d.ts +4 -6
- package/types/domain/StatusCommand.d.ts +38 -3
- package/types/domain/ViewCommand.d.ts +27 -0
- package/types/domain/WebCommand.d.ts +47 -0
- package/types/domain/inspect/ReleaseAuditor.d.ts +134 -0
- package/types/domain/inspect/ReleaseAuditor.story.d.ts +1 -0
- package/types/domain/templates/release.html.template.d.ts +2 -0
- package/types/index.d.ts +18 -10
- package/types/inspectors/inspect-js.d.ts +2 -0
- package/types/utils/nodejs/GitTelemetry.d.ts +23 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { extractTasks } from './ReleaseParser.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse semver string into [major, minor, patch] numbers
|
|
5
|
+
* @param {string} [v='']
|
|
6
|
+
* @returns {[number, number, number]}
|
|
7
|
+
*/
|
|
8
|
+
export function parseSemver(v = '') {
|
|
9
|
+
const match = String(v).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/)
|
|
10
|
+
if (!match) return [0, 0, 0]
|
|
11
|
+
return [
|
|
12
|
+
parseInt(match[1] || '0', 10),
|
|
13
|
+
parseInt(match[2] || '0', 10),
|
|
14
|
+
parseInt(match[3] || '0', 10),
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compare two semver strings in descending order
|
|
20
|
+
* @param {string} [a='']
|
|
21
|
+
* @param {string} [b='']
|
|
22
|
+
* @returns {number}
|
|
23
|
+
*/
|
|
24
|
+
export function compareSemverDesc(a = '', b = '') {
|
|
25
|
+
const pa = parseSemver(a)
|
|
26
|
+
const pb = parseSemver(b)
|
|
27
|
+
if (pa[0] !== pb[0]) return pb[0] - pa[0]
|
|
28
|
+
if (pa[1] !== pb[1]) return pb[1] - pa[1]
|
|
29
|
+
return pb[2] - pa[2]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format release version to path segments e.g. "1.0.0" -> "1/0/v1.0.0"
|
|
34
|
+
* @param {string} [version='']
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function versionToPath(version = '') {
|
|
38
|
+
const clean = version.replace(/^v/, '')
|
|
39
|
+
const parts = clean.split('.').slice(0, 2)
|
|
40
|
+
return [...parts, `v${clean}`].join('/')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check whether a document exists in the virtual database
|
|
45
|
+
* @param {any} db
|
|
46
|
+
* @param {string} path
|
|
47
|
+
* @returns {Promise<boolean>}
|
|
48
|
+
*/
|
|
49
|
+
export async function documentExists(db, path) {
|
|
50
|
+
try {
|
|
51
|
+
const doc = await db.loadDocument(path, null)
|
|
52
|
+
return doc !== null && doc !== undefined
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load release doc (release.md, task.md, plan.md) and extract tasks
|
|
60
|
+
* @param {any} db
|
|
61
|
+
* @param {string} releaseDir
|
|
62
|
+
* @returns {Promise<{ releaseContent: string, sourceDoc: string, tasks: import('./ReleaseParser.js').TaskItem[] }>}
|
|
63
|
+
*/
|
|
64
|
+
export async function loadReleaseDoc(db, releaseDir) {
|
|
65
|
+
let releaseContent = ''
|
|
66
|
+
let sourceDoc = `${releaseDir}/release.md`
|
|
67
|
+
for (const docFile of ['release.md', 'task.md', 'plan.md']) {
|
|
68
|
+
try {
|
|
69
|
+
const content = await db.loadDocument(`${releaseDir}/${docFile}`, '')
|
|
70
|
+
if (content) {
|
|
71
|
+
releaseContent = content
|
|
72
|
+
sourceDoc = `${releaseDir}/${docFile}`
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
return { releaseContent, sourceDoc, tasks: releaseContent ? extractTasks(releaseContent) : [] }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Discover release version directories using database listDir
|
|
82
|
+
* @param {any} db
|
|
83
|
+
* @param {string} projectRoot
|
|
84
|
+
* @returns {Promise<Array<{ version: string, path: string }>>}
|
|
85
|
+
*/
|
|
86
|
+
export async function discoverReleases(db, projectRoot) {
|
|
87
|
+
const results = []
|
|
88
|
+
const releasesUri = `${projectRoot}releases`.replace(/^\/+/, '') || 'releases'
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
if (typeof db.listDir === 'function') {
|
|
92
|
+
const queue = [{ uri: releasesUri, depth: 0 }]
|
|
93
|
+
while (queue.length > 0) {
|
|
94
|
+
const item = queue.shift()
|
|
95
|
+
if (!item || item.depth > 3) continue
|
|
96
|
+
let entries = []
|
|
97
|
+
try {
|
|
98
|
+
entries = await db.listDir(item.uri)
|
|
99
|
+
} catch {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(entries)) continue
|
|
103
|
+
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
const rawName = entry.name || (entry.path || '').split('/').pop() || ''
|
|
106
|
+
const name = rawName.replace(/\/$/, '')
|
|
107
|
+
const isDir = Boolean(
|
|
108
|
+
entry.stat?.isDirectory ||
|
|
109
|
+
entry.isDirectory ||
|
|
110
|
+
entry.isDir ||
|
|
111
|
+
rawName.endsWith('/')
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const dirMatch = name.match(/^v(\d+\.\d+\.\d+[a-zA-Z0-9._-]*)/)
|
|
115
|
+
if (dirMatch) {
|
|
116
|
+
const version = dirMatch[1]
|
|
117
|
+
const entryPath = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
|
|
118
|
+
const normalizedPath = entryPath.replace(/^\.\//, '')
|
|
119
|
+
if (!results.find((r) => r.path === normalizedPath)) {
|
|
120
|
+
results.push({ version, path: normalizedPath })
|
|
121
|
+
}
|
|
122
|
+
} else if (isDir && !name.startsWith('.') && name !== 'node_modules' && name !== '_') {
|
|
123
|
+
const nextUri = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
|
|
124
|
+
queue.push({ uri: nextUri, depth: item.depth + 1 })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch {}
|
|
130
|
+
|
|
131
|
+
return results.sort((a, b) => compareSemverDesc(a.version, b.version))
|
|
132
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} TaskItem
|
|
3
|
+
* @property {string} content - Task title or description
|
|
4
|
+
* @property {string} status - todo | InProgress | Done
|
|
5
|
+
* @property {string} slug - Task slug / identifier
|
|
6
|
+
* @property {'0' | '1' | '2' | '3'} [priority] - Numeric priority
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse frontmatter YAML from markdown text
|
|
11
|
+
* @param {*} input
|
|
12
|
+
* @returns {Record<string, any>}
|
|
13
|
+
*/
|
|
14
|
+
export function parseFrontmatter(input) {
|
|
15
|
+
const content = String(input || '')
|
|
16
|
+
if (!content.trim()) return {}
|
|
17
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
18
|
+
if (!match) return {}
|
|
19
|
+
const res = {}
|
|
20
|
+
for (const rawLine of match[1].split('\n')) {
|
|
21
|
+
const line = rawLine.trim()
|
|
22
|
+
if (!line || line.startsWith('#') || !line.includes(':')) continue
|
|
23
|
+
const key = line.slice(0, line.indexOf(':')).trim()
|
|
24
|
+
const val = line.slice(line.indexOf(':') + 1).trim().replace(/^['"](.*)['"]$/, '$1')
|
|
25
|
+
res[key] = val
|
|
26
|
+
}
|
|
27
|
+
return res
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize priority to numeric string '0' | '1' | '2' | '3'
|
|
32
|
+
* @param {any} input
|
|
33
|
+
* @returns {'0' | '1' | '2' | '3'}
|
|
34
|
+
*/
|
|
35
|
+
export function normalizePriority(input) {
|
|
36
|
+
const str = String(input ?? '1').trim().toLowerCase()
|
|
37
|
+
if (str === '0' || str === 'p0' || str.includes('urgent') || str.includes('high') || str.includes('критич')) return '0'
|
|
38
|
+
if (str === '2' || str === 'p2' || str.includes('low') || str.includes('backlog') || str.includes('фон')) return '2'
|
|
39
|
+
if (str === '3' || str === 'p3') return '3'
|
|
40
|
+
return '1'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function detectPriority(text) {
|
|
44
|
+
const m = text.match(/\b(p0|p1|p2|p3|urgent|high|med|medium|low)\b/i)
|
|
45
|
+
if (!m) return '1'
|
|
46
|
+
const p = m[1].toLowerCase()
|
|
47
|
+
if (p === 'p0' || p === 'urgent' || p === 'high') return '0'
|
|
48
|
+
if (p === 'p2') return '2'
|
|
49
|
+
if (p === 'p3' || p === 'low') return '3'
|
|
50
|
+
return '1'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cleanTaskTitle(text) {
|
|
54
|
+
return text.replace(/\[\s*\]\([^)]+\)/g, '').replace(/\[([^\]]+)\]$/, '').replace(/^\*\*|\*\*$/g, '').trim()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract clean task items from release markdown
|
|
59
|
+
* @param {*} input
|
|
60
|
+
* @returns {TaskItem[]}
|
|
61
|
+
*/
|
|
62
|
+
export function extractTasks(input) {
|
|
63
|
+
const content = String(input || '')
|
|
64
|
+
if (!content.trim()) return []
|
|
65
|
+
const tasks = []
|
|
66
|
+
|
|
67
|
+
for (const rawLine of content.split('\n')) {
|
|
68
|
+
const line = rawLine.trim()
|
|
69
|
+
if (!line) continue
|
|
70
|
+
|
|
71
|
+
const checkMatch = line.match(/^[-*+]?\s*(?:\[([ xX])\]|\d+\.\s*\[([ xX])\])\s*(.+)$/)
|
|
72
|
+
if (checkMatch) {
|
|
73
|
+
const isChecked = (checkMatch[1] || checkMatch[2] || '').toLowerCase() === 'x'
|
|
74
|
+
const text = cleanTaskTitle(checkMatch[3])
|
|
75
|
+
tasks.push({
|
|
76
|
+
content: text,
|
|
77
|
+
status: isChecked ? 'Done' : 'Todo',
|
|
78
|
+
slug: text.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
79
|
+
priority: detectPriority(checkMatch[3]),
|
|
80
|
+
})
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const headingMatch = line.match(/^###\s*(Done|InProgress|Todo|Draft)\s+(.+)$/i)
|
|
85
|
+
if (headingMatch) {
|
|
86
|
+
const statusWord = headingMatch[1].toLowerCase()
|
|
87
|
+
const rawTitle = headingMatch[2].trim()
|
|
88
|
+
const slugMatch = rawTitle.match(/\[([^\]]+)\]$/)
|
|
89
|
+
const slug = slugMatch ? slugMatch[1] : ''
|
|
90
|
+
const text = cleanTaskTitle(rawTitle)
|
|
91
|
+
const status = statusWord === 'done' ? 'Done' : statusWord === 'inprogress' ? 'InProgress' : 'Todo'
|
|
92
|
+
tasks.push({
|
|
93
|
+
content: text,
|
|
94
|
+
status,
|
|
95
|
+
slug: slug || text.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
96
|
+
priority: detectPriority(rawTitle),
|
|
97
|
+
})
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const numMatch = line.match(/^###\s*\d+\.\s*(.+)$/)
|
|
102
|
+
if (numMatch) {
|
|
103
|
+
const rawTitle = numMatch[1].trim()
|
|
104
|
+
if (/^(Team|Tasks|Overview|Section|User Stories|Feedback|Acceptance Criteria)/i.test(rawTitle)) continue
|
|
105
|
+
const text = cleanTaskTitle(rawTitle)
|
|
106
|
+
if (text.length > 0) {
|
|
107
|
+
tasks.push({
|
|
108
|
+
content: text,
|
|
109
|
+
status: 'Todo',
|
|
110
|
+
slug: text.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
111
|
+
priority: detectPriority(rawTitle),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return tasks
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Calculate days remaining until deadline
|
|
121
|
+
* @param {string} [deadlineStr]
|
|
122
|
+
* @returns {number | null}
|
|
123
|
+
*/
|
|
124
|
+
export function calculateDaysRemaining(deadlineStr = '') {
|
|
125
|
+
if (!deadlineStr) return null
|
|
126
|
+
try {
|
|
127
|
+
const target = new Date(deadlineStr)
|
|
128
|
+
if (isNaN(target.getTime())) return null
|
|
129
|
+
return Math.ceil((target.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
|
130
|
+
} catch {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse user.md content for telemetry metrics (hours, iterations, RRS) across languages
|
|
137
|
+
* @param {*} [input]
|
|
138
|
+
* @returns {{ hours: number, iterations: number, rrs: number }}
|
|
139
|
+
*/
|
|
140
|
+
export function parseUserMetrics(input = '') {
|
|
141
|
+
const content = String(input || '')
|
|
142
|
+
if (!content.trim()) return { hours: 0, iterations: 0, rrs: 0 }
|
|
143
|
+
|
|
144
|
+
let hours = 0, iterations = 0, rrs = 0
|
|
145
|
+
const fm = parseFrontmatter(content)
|
|
146
|
+
if (fm.hours || fm.spentHours || fm.time) hours = parseFloat(fm.hours || fm.spentHours || fm.time || '0') || 0
|
|
147
|
+
if (fm.iterations || fm.iters || fm.runs) iterations = parseInt(fm.iterations || fm.iters || fm.runs || '0', 10) || 0
|
|
148
|
+
if (fm.rrs || fm.score) rrs = parseInt(fm.rrs || fm.score || '0', 10) || 0
|
|
149
|
+
|
|
150
|
+
for (const rawLine of content.split('\n')) {
|
|
151
|
+
const line = rawLine.trim()
|
|
152
|
+
if (!line || !line.includes(':')) continue
|
|
153
|
+
const beforeColon = line.slice(0, line.indexOf(':')).toLowerCase()
|
|
154
|
+
const numMatch = line.slice(line.indexOf(':') + 1).match(/([0-9]+(?:\.[0-9]+)?)/)
|
|
155
|
+
if (!numMatch) continue
|
|
156
|
+
const val = parseFloat(numMatch[1])
|
|
157
|
+
|
|
158
|
+
if (hours === 0 && /(витрачений час|час розробки|time spent|development time|spent time|dev hours|hours|годин)/.test(beforeColon)) {
|
|
159
|
+
hours = val
|
|
160
|
+
} else if (iterations === 0 && /(кількість ітерацій|ітерацій|iteration count|test runs|refactor runs|iterations|runs)/.test(beforeColon)) {
|
|
161
|
+
iterations = Math.round(val)
|
|
162
|
+
} else if (rrs === 0 && /(rrs|release readiness score|фінальний бал|бал rrs|readiness score)/.test(beforeColon)) {
|
|
163
|
+
rrs = Math.round(val)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { hours, iterations, rrs }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve priority from frontmatter or days remaining
|
|
171
|
+
* @param {Record<string, any>} fm
|
|
172
|
+
* @param {number | null} daysRemaining
|
|
173
|
+
* @returns {'0' | '1' | '2' | '3'}
|
|
174
|
+
*/
|
|
175
|
+
export function resolvePriority(fm = {}, daysRemaining = null) {
|
|
176
|
+
let priority = '1'
|
|
177
|
+
const rawPrio = String(fm.priority || fm.prio || '').toLowerCase()
|
|
178
|
+
if (rawPrio.includes('p0') || rawPrio.includes('high') || rawPrio.includes('urgent') || rawPrio.includes('критич')) priority = '0'
|
|
179
|
+
else if (rawPrio.includes('p2') || rawPrio.includes('low') || rawPrio.includes('backlog') || rawPrio.includes('фон')) priority = '2'
|
|
180
|
+
else if (rawPrio.includes('p3')) priority = '3'
|
|
181
|
+
else if (rawPrio.includes('p1') || rawPrio.includes('med')) priority = '1'
|
|
182
|
+
else if (daysRemaining !== null) {
|
|
183
|
+
if (daysRemaining <= 3) priority = '0'
|
|
184
|
+
else if (daysRemaining <= 7) priority = '1'
|
|
185
|
+
else priority = '2'
|
|
186
|
+
}
|
|
187
|
+
return normalizePriority(priority)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Sort tasks by criteria ('priority', 'status', 'alphabetical')
|
|
192
|
+
* @param {TaskItem[]} [tasks=[]]
|
|
193
|
+
* @param {'priority' | 'status' | 'alphabetical'} [criteria='priority']
|
|
194
|
+
* @returns {TaskItem[]}
|
|
195
|
+
*/
|
|
196
|
+
export function sortTasks(tasks = [], criteria = 'priority') {
|
|
197
|
+
const list = [...tasks]
|
|
198
|
+
if (criteria === 'priority') {
|
|
199
|
+
return list.sort((a, b) => parseInt(normalizePriority(a.priority), 10) - parseInt(normalizePriority(b.priority), 10))
|
|
200
|
+
}
|
|
201
|
+
if (criteria === 'status') {
|
|
202
|
+
const weight = { Todo: 0, InProgress: 1, Done: 2 }
|
|
203
|
+
return list.sort((a, b) => (weight[a.status] ?? 99) - (weight[b.status] ?? 99))
|
|
204
|
+
}
|
|
205
|
+
if (criteria === 'alphabetical') {
|
|
206
|
+
return list.sort((a, b) => (a.content || '').localeCompare(b.content || ''))
|
|
207
|
+
}
|
|
208
|
+
return list
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Determine release status ('wip' | 'closed' | 'unknown')
|
|
213
|
+
* @param {object} params
|
|
214
|
+
* @returns {'wip' | 'closed' | 'unknown'}
|
|
215
|
+
*/
|
|
216
|
+
export function determineReleaseStatus({ fmStatus = '', hasSpec = false, hasTest = false, tasks = [], userStr = '', metrics = { hours: 0, rrs: 0 } } = {}) {
|
|
217
|
+
const s = (fmStatus || '').toLowerCase()
|
|
218
|
+
if (s === 'active' || s === 'wip') return 'wip'
|
|
219
|
+
if (s === 'closed' || s === 'done' || s === 'sealed' || hasTest) return 'closed'
|
|
220
|
+
if (hasSpec) return 'wip'
|
|
221
|
+
if (tasks.length > 0) return tasks.every((t) => t.status === 'Done') ? 'closed' : 'wip'
|
|
222
|
+
if (userStr.includes('status: active') || userStr.includes('status: wip')) return 'wip'
|
|
223
|
+
if (userStr.includes('status: closed') || userStr.includes('status: done')) return 'closed'
|
|
224
|
+
if (metrics.hours > 0 || metrics.rrs > 0 || userStr.length > 0) return 'wip'
|
|
225
|
+
return 'unknown'
|
|
226
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { DashboardModel } from './DashboardModel.js'
|
|
2
|
+
export {
|
|
3
|
+
parseFrontmatter,
|
|
4
|
+
extractTasks,
|
|
5
|
+
calculateDaysRemaining,
|
|
6
|
+
parseUserMetrics,
|
|
7
|
+
normalizePriority,
|
|
8
|
+
resolvePriority,
|
|
9
|
+
sortTasks,
|
|
10
|
+
determineReleaseStatus,
|
|
11
|
+
} from './ReleaseParser.js'
|
|
12
|
+
export {
|
|
13
|
+
parseSemver,
|
|
14
|
+
compareSemverDesc,
|
|
15
|
+
versionToPath,
|
|
16
|
+
documentExists,
|
|
17
|
+
loadReleaseDoc,
|
|
18
|
+
discoverReleases,
|
|
19
|
+
} from './ReleaseDiscovery.js'
|
|
@@ -2,6 +2,11 @@ import { ModelAsApp, result, show } from '@nan0web/ui'
|
|
|
2
2
|
import { runSpawn } from '@nan0web/test'
|
|
3
3
|
|
|
4
4
|
export default class DepsCommand extends ModelAsApp {
|
|
5
|
+
/** @type {boolean} Apply fixes automatically */
|
|
6
|
+
fix = false
|
|
7
|
+
/** @type {boolean} Update packages to the latest available versions */
|
|
8
|
+
latest = false
|
|
9
|
+
|
|
5
10
|
static fix = {
|
|
6
11
|
help: 'Apply fixes automatically',
|
|
7
12
|
default: false,
|
|
@@ -21,8 +26,6 @@ export default class DepsCommand extends ModelAsApp {
|
|
|
21
26
|
*/
|
|
22
27
|
constructor(data = {}, options = {}) {
|
|
23
28
|
super(data, options)
|
|
24
|
-
/** @type {boolean} Apply fixes automatically */ this.fix
|
|
25
|
-
/** @type {boolean} Update packages to the latest available versions */ this.latest
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { ModelAsApp, result, show } from '@nan0web/ui'
|
|
2
2
|
|
|
3
3
|
export default class InitCommand extends ModelAsApp {
|
|
4
|
+
/** @type {string} Release version */
|
|
5
|
+
version = ''
|
|
6
|
+
|
|
4
7
|
static version = {
|
|
5
8
|
help: 'Release version',
|
|
6
9
|
default: '',
|
|
@@ -17,7 +20,6 @@ export default class InitCommand extends ModelAsApp {
|
|
|
17
20
|
*/
|
|
18
21
|
constructor(data = {}, options = {}) {
|
|
19
22
|
super(data, options)
|
|
20
|
-
/** @type {string} Release version */ this.version
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
/**
|
|
@@ -25,8 +27,11 @@ export default class InitCommand extends ModelAsApp {
|
|
|
25
27
|
*/
|
|
26
28
|
async *run() {
|
|
27
29
|
const { t, db } = this._
|
|
30
|
+
if (!db) {
|
|
31
|
+
throw new Error('Database instance ({ db }) is required in InitCommand')
|
|
32
|
+
}
|
|
28
33
|
if (!this.version) {
|
|
29
|
-
throw new Error(t(InitCommand.version.errorRequired))
|
|
34
|
+
throw new Error(t ? t(InitCommand.version.errorRequired) : InitCommand.version.errorRequired)
|
|
30
35
|
}
|
|
31
36
|
const v = this.version.startsWith('v') ? this.version : `v${this.version}`
|
|
32
37
|
|
|
@@ -34,24 +39,21 @@ export default class InitCommand extends ModelAsApp {
|
|
|
34
39
|
const major = parts[0] || '0'
|
|
35
40
|
const minor = parts[1] || '0'
|
|
36
41
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const dir = join(process.cwd(), 'releases', major, minor, v)
|
|
41
|
-
mkdirSync(dir, { recursive: true })
|
|
42
|
+
const taskPath = `releases/${major}/${minor}/${v}/task.md`
|
|
43
|
+
await db.saveDocument(taskPath, `# Release ${v}\n\n- [ ] Task 1\n`)
|
|
42
44
|
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const specPath = join(dir, 'test.spec.js')
|
|
47
|
-
writeFileSync(
|
|
45
|
+
const specPath = `releases/${major}/${minor}/${v}/task.spec.js`
|
|
46
|
+
await db.saveDocument(
|
|
48
47
|
specPath,
|
|
49
|
-
`import test from 'node:test'\nimport assert from 'node:assert'\n\ntest('
|
|
50
|
-
'utf8',
|
|
48
|
+
`import test from 'node:test'\nimport assert from 'node:assert'\n\ntest('Release ${v} Specification', () => {\n\tassert.ok(true)\n})\n`,
|
|
51
49
|
)
|
|
52
50
|
|
|
53
|
-
yield show(`✅ Initialized release ${v} in
|
|
51
|
+
yield show(`✅ Initialized release ${v} in releases/${major}/${minor}/${v}`, 'success')
|
|
54
52
|
|
|
55
|
-
return result({
|
|
53
|
+
return result({
|
|
54
|
+
version: v,
|
|
55
|
+
taskPath,
|
|
56
|
+
specPath,
|
|
57
|
+
})
|
|
56
58
|
}
|
|
57
59
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ModelAsApp, show, result } from '@nan0web/ui'
|
|
2
|
+
import Scanner from '../Release/Scanner.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ListCommand - ModelAsApp Subcommand to list all available releases in chronological order.
|
|
6
|
+
*/
|
|
7
|
+
export default class ListCommand extends ModelAsApp {
|
|
8
|
+
static alias = 'list'
|
|
9
|
+
|
|
10
|
+
static UI = {
|
|
11
|
+
title: 'list',
|
|
12
|
+
help: 'List all available release versions',
|
|
13
|
+
noReleases: 'No releases found.',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Partial<ListCommand>} [data]
|
|
18
|
+
* @param {import('@nan0web/types').ModelOptions} [options]
|
|
19
|
+
*/
|
|
20
|
+
constructor(data = {}, options = {}) {
|
|
21
|
+
super(data, options)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async *run() {
|
|
25
|
+
const targetDir = process.env.RELEASE_CWD || process.env.INIT_CWD || process.cwd()
|
|
26
|
+
const scanner = new Scanner(targetDir)
|
|
27
|
+
const statuses = scanner.status()
|
|
28
|
+
|
|
29
|
+
if (!statuses.length) {
|
|
30
|
+
yield show(ListCommand.UI.noReleases, 'warn')
|
|
31
|
+
return result({ versions: [] })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Sort SemVer descending (newest first)
|
|
35
|
+
const sorted = [...statuses].sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))
|
|
36
|
+
|
|
37
|
+
for (const item of sorted) {
|
|
38
|
+
yield show(item.version)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result({ versions: sorted.map((s) => s.version) })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { ListCommand }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import WebCommand from './WebCommand.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PreviewCommand - ModelAsApp Subcommand to generate, serve and preview PM-as-Code Web Dashboard.
|
|
5
|
+
*/
|
|
6
|
+
export default class PreviewCommand extends WebCommand {
|
|
7
|
+
static alias = 'preview'
|
|
8
|
+
|
|
9
|
+
static UI = {
|
|
10
|
+
title: 'preview',
|
|
11
|
+
help: 'Generate, serve and preview PM-as-Code Web Dashboard',
|
|
12
|
+
loading: 'Aggregating release telemetry and preparing Web Dashboard preview...',
|
|
13
|
+
generated: '✨ Web Dashboard успішно згенеровано: {$path}',
|
|
14
|
+
serving: '🌐 Web Dashboard запущено: http://localhost:{$port} (Натисніть Ctrl+C для зупинки)',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static port = {
|
|
18
|
+
help: 'Port to serve HTML dashboard over HTTP',
|
|
19
|
+
default: 3333,
|
|
20
|
+
type: 'number',
|
|
21
|
+
alias: 'p',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static open = {
|
|
25
|
+
help: 'Automatically open in browser',
|
|
26
|
+
default: true,
|
|
27
|
+
type: 'boolean',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {Partial<PreviewCommand>} [data]
|
|
32
|
+
* @param {import('@nan0web/types').ModelOptions} [options]
|
|
33
|
+
*/
|
|
34
|
+
constructor(data = {}, options = {}) {
|
|
35
|
+
super({ port: 3333, open: true, ...data }, options)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { PreviewCommand }
|
|
@@ -2,6 +2,13 @@ import { ModelAsApp, result, show } from '@nan0web/ui'
|
|
|
2
2
|
import { runSpawn } from '@nan0web/test'
|
|
3
3
|
|
|
4
4
|
export default class PublishCommand extends ModelAsApp {
|
|
5
|
+
/** @type {boolean} Bump major version */
|
|
6
|
+
major = false
|
|
7
|
+
/** @type {boolean} Bump minor version */
|
|
8
|
+
minor = false
|
|
9
|
+
/** @type {boolean} Bump patch version */
|
|
10
|
+
patch = false
|
|
11
|
+
|
|
5
12
|
static major = {
|
|
6
13
|
help: 'Bump major version',
|
|
7
14
|
default: false,
|
|
@@ -25,9 +32,6 @@ export default class PublishCommand extends ModelAsApp {
|
|
|
25
32
|
*/
|
|
26
33
|
constructor(data = {}, options = {}) {
|
|
27
34
|
super(data, options)
|
|
28
|
-
/** @type {boolean} Bump major version */ this.major
|
|
29
|
-
/** @type {boolean} Bump minor version */ this.minor
|
|
30
|
-
/** @type {boolean} Bump patch version */ this.patch
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import DB from '@nan0web/db'
|
|
4
|
+
import { RegistryModel } from './RegistryModel.js'
|
|
5
|
+
|
|
6
|
+
describe('RegistryModel OLMUI Scenario', () => {
|
|
7
|
+
it('parses releases.txt and extracts valid project paths ignoring comments and empty lines', async () => {
|
|
8
|
+
const rawContent = `# Global Registry
|
|
9
|
+
.
|
|
10
|
+
# packages
|
|
11
|
+
packages/release
|
|
12
|
+
|
|
13
|
+
# apps
|
|
14
|
+
apps/release.app
|
|
15
|
+
apps/3rdparty/industrialbank/bank
|
|
16
|
+
`
|
|
17
|
+
const db = new DB({
|
|
18
|
+
predefined: [
|
|
19
|
+
['releases.txt', rawContent],
|
|
20
|
+
['package.json', { name: '@nan0web/monorepo', version: '1.0.0' }],
|
|
21
|
+
['packages/release/package.json', { name: '@nan0web/release', version: '3.3.0' }],
|
|
22
|
+
['apps/release.app/package.json', { name: 'release.app', version: '0.1.0' }],
|
|
23
|
+
['apps/3rdparty/industrialbank/bank/package.json', { name: '@industrialbank/bank', version: '3.3.0' }],
|
|
24
|
+
],
|
|
25
|
+
})
|
|
26
|
+
await db.connect()
|
|
27
|
+
|
|
28
|
+
const registry = new RegistryModel({ filePath: 'releases.txt' }, { db })
|
|
29
|
+
const projects = await registry.loadProjects()
|
|
30
|
+
|
|
31
|
+
assert.equal(projects.length, 4)
|
|
32
|
+
assert.deepEqual(projects[0], {
|
|
33
|
+
path: '.',
|
|
34
|
+
name: '@nan0web/monorepo',
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
})
|
|
37
|
+
assert.deepEqual(projects[1], {
|
|
38
|
+
path: 'packages/release',
|
|
39
|
+
name: '@nan0web/release',
|
|
40
|
+
version: '3.3.0',
|
|
41
|
+
})
|
|
42
|
+
assert.deepEqual(projects[2], {
|
|
43
|
+
path: 'apps/release.app',
|
|
44
|
+
name: 'release.app',
|
|
45
|
+
version: '0.1.0',
|
|
46
|
+
})
|
|
47
|
+
assert.deepEqual(projects[3], {
|
|
48
|
+
path: 'apps/3rdparty/industrialbank/bank',
|
|
49
|
+
name: '@industrialbank/bank',
|
|
50
|
+
version: '3.3.0',
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('handles projects without package.json gracefully', async () => {
|
|
55
|
+
const db = new DB({
|
|
56
|
+
predefined: [
|
|
57
|
+
['releases.txt', 'packages/custom-pkg\n'],
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
await db.connect()
|
|
61
|
+
|
|
62
|
+
const registry = new RegistryModel({ filePath: 'releases.txt' }, { db })
|
|
63
|
+
const projects = await registry.loadProjects()
|
|
64
|
+
|
|
65
|
+
assert.equal(projects.length, 1)
|
|
66
|
+
assert.equal(projects[0].name, 'packages/custom-pkg')
|
|
67
|
+
assert.equal(projects[0].version, '0.0.0')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('parses raw text paths via static parsePaths utility', () => {
|
|
71
|
+
const text = `
|
|
72
|
+
# Comment 1
|
|
73
|
+
/path/one
|
|
74
|
+
|
|
75
|
+
/path/two # inline comment
|
|
76
|
+
# Comment 2
|
|
77
|
+
/path/three
|
|
78
|
+
`
|
|
79
|
+
const paths = RegistryModel.parsePaths(text)
|
|
80
|
+
assert.deepEqual(paths, ['/path/one', '/path/two', '/path/three'])
|
|
81
|
+
})
|
|
82
|
+
})
|