@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,92 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import DB from '@nan0web/db'
|
|
4
|
+
import CheckCommand from './CheckCommand.js'
|
|
5
|
+
|
|
6
|
+
describe('CheckCommand OLMUI Scenario & Validator', () => {
|
|
7
|
+
it('validates a compliant project release structure without errors', async () => {
|
|
8
|
+
const pkgJson = { name: '@nan0web/sample-pkg', version: '1.0.0' }
|
|
9
|
+
const releaseMd = `# v1.0.0 - 2026-09-01
|
|
10
|
+
## Tasks
|
|
11
|
+
- [x] Implement initial feature
|
|
12
|
+
`
|
|
13
|
+
const userMd = `---
|
|
14
|
+
name: user-feedback
|
|
15
|
+
version: 1.0.0
|
|
16
|
+
---
|
|
17
|
+
## ⏱ 1. Метрики Виконання
|
|
18
|
+
- **Витрачений час розробки (Годин):** \`4.5h\`
|
|
19
|
+
- **Кількість ітерацій (Test Runs / Refactors):** \`10\`
|
|
20
|
+
- **Фінальний бал RRS (Release Readiness Score):** \`324\`
|
|
21
|
+
`
|
|
22
|
+
const taskSpec = `import { describe, it } from 'node:test'
|
|
23
|
+
import assert from 'node:assert/strict'
|
|
24
|
+
describe('sample contract', () => {
|
|
25
|
+
it('works', () => assert.ok(true))
|
|
26
|
+
})
|
|
27
|
+
`
|
|
28
|
+
const db = new DB({
|
|
29
|
+
predefined: [
|
|
30
|
+
['packages/sample-pkg/package.json', pkgJson],
|
|
31
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/release.md', releaseMd],
|
|
32
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/user.md', userMd],
|
|
33
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/task.spec.js', taskSpec],
|
|
34
|
+
],
|
|
35
|
+
})
|
|
36
|
+
await db.connect()
|
|
37
|
+
|
|
38
|
+
const cmd = new CheckCommand({ target: 'packages/sample-pkg' }, { db })
|
|
39
|
+
const gen = cmd.run()
|
|
40
|
+
|
|
41
|
+
let step = await gen.next()
|
|
42
|
+
const outputs = []
|
|
43
|
+
while (!step.done) {
|
|
44
|
+
outputs.push(step.value)
|
|
45
|
+
step = await gen.next()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const res = step.value?.data || step.value
|
|
49
|
+
assert.ok(res.success)
|
|
50
|
+
assert.equal(res.diagnostics.length, 0)
|
|
51
|
+
assert.equal(res.checkedProjects.length, 1)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('detects missing user.md metrics and missing contracts with actionable recommendations', async () => {
|
|
55
|
+
const pkgJson = { name: '@nan0web/broken-pkg', version: '2.0.0' }
|
|
56
|
+
const releaseMd = `# v2.0.0 - 2026-09-01
|
|
57
|
+
## Tasks
|
|
58
|
+
- [ ] Incomplete task
|
|
59
|
+
`
|
|
60
|
+
const db = new DB({
|
|
61
|
+
predefined: [
|
|
62
|
+
['packages/broken-pkg/package.json', pkgJson],
|
|
63
|
+
['packages/broken-pkg/releases/2/0/v2.0.0/release.md', releaseMd],
|
|
64
|
+
],
|
|
65
|
+
})
|
|
66
|
+
await db.connect()
|
|
67
|
+
|
|
68
|
+
const cmd = new CheckCommand({ target: 'packages/broken-pkg' }, { db })
|
|
69
|
+
const gen = cmd.run()
|
|
70
|
+
|
|
71
|
+
let step = await gen.next()
|
|
72
|
+
const outputs = []
|
|
73
|
+
while (!step.done) {
|
|
74
|
+
outputs.push(step.value)
|
|
75
|
+
step = await gen.next()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const res = step.value?.data || step.value
|
|
79
|
+
assert.equal(res.success, false)
|
|
80
|
+
assert.ok(res.diagnostics.length >= 2)
|
|
81
|
+
|
|
82
|
+
// Check for missing user.md diagnostic
|
|
83
|
+
const userDiag = res.diagnostics.find((d) => d.code === 'MISSING_USER_MD' || d.type === 'missing_user_md')
|
|
84
|
+
assert.ok(userDiag)
|
|
85
|
+
assert.ok(userDiag.fix.includes('user.md'))
|
|
86
|
+
|
|
87
|
+
// Check for missing contract diagnostic
|
|
88
|
+
const contractDiag = res.diagnostics.find((d) => d.code === 'MISSING_CONTRACT' || d.type === 'missing_contract')
|
|
89
|
+
assert.ok(contractDiag)
|
|
90
|
+
assert.ok(contractDiag.fix.includes('task.spec.js'))
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -3,6 +3,9 @@ import { runSpawn } from '@nan0web/test'
|
|
|
3
3
|
import Scanner from '../Release/Scanner.js'
|
|
4
4
|
|
|
5
5
|
export default class CloseCommand extends ModelAsApp {
|
|
6
|
+
/** @type {string} Release version */
|
|
7
|
+
version = ''
|
|
8
|
+
|
|
6
9
|
static version = {
|
|
7
10
|
help: 'Release version',
|
|
8
11
|
default: '',
|
|
@@ -18,16 +21,13 @@ export default class CloseCommand extends ModelAsApp {
|
|
|
18
21
|
*/
|
|
19
22
|
constructor(data = {}, options = {}) {
|
|
20
23
|
super(data, options)
|
|
21
|
-
/** @type {string} Release version */ this.version
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* @returns {AsyncGenerator<import('@nan0web/ui').Intent, import('@nan0web/ui').ResultIntent, any>}
|
|
26
28
|
*/
|
|
27
29
|
async *run() {
|
|
28
|
-
const
|
|
29
|
-
const { join, dirname } = await import('node:path')
|
|
30
|
-
|
|
30
|
+
const db = this._.db
|
|
31
31
|
const v = this.version || ''
|
|
32
32
|
yield show(`🛜 nan0release close ${v || 'all'}`)
|
|
33
33
|
|
|
@@ -51,18 +51,22 @@ export default class CloseCommand extends ModelAsApp {
|
|
|
51
51
|
|
|
52
52
|
let movedCount = 0
|
|
53
53
|
for (const spec of specs) {
|
|
54
|
-
const relativePath = spec.path.
|
|
54
|
+
const relativePath = spec.path.replace(/^.*?releases\//, 'releases/')
|
|
55
55
|
const destRelative = relativePath
|
|
56
56
|
.replace(/^releases\//, 'src/releases/')
|
|
57
57
|
.replace('.spec.js', '.test.js')
|
|
58
|
-
const destPath = join(process.cwd(), destRelative)
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
if (db) {
|
|
60
|
+
const content = await db.loadDocument(relativePath, null)
|
|
61
|
+
if (content !== null) {
|
|
62
|
+
await db.saveDocument(destRelative, content)
|
|
63
|
+
await db.dropDocument(relativePath)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
62
66
|
movedCount++
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
yield show(`✅ Successfully closed ${movedCount} specs and moved to src/releases/`, 'success')
|
|
66
|
-
return result({})
|
|
70
|
+
return result({ movedCount })
|
|
67
71
|
}
|
|
68
72
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import DB from '@nan0web/db'
|
|
4
|
+
import { DashboardModel } from './DashboardModel.js'
|
|
5
|
+
|
|
6
|
+
describe('DashboardModel OLMUI Scenario', () => {
|
|
7
|
+
it('aggregates projects, computes summary metrics and RRS readiness', async () => {
|
|
8
|
+
const rawReleasesTxt = `# Registry
|
|
9
|
+
packages/sample-pkg
|
|
10
|
+
apps/sample-app
|
|
11
|
+
`
|
|
12
|
+
const samplePkgUserMd = `---
|
|
13
|
+
name: user-feedback
|
|
14
|
+
version: 1.0.0
|
|
15
|
+
---
|
|
16
|
+
## ⏱ 1. Метрики Виконання
|
|
17
|
+
- **Витрачений час розробки (Годин):** \`4.5h\`
|
|
18
|
+
- **Кількість ітерацій (Test Runs / Refactors):** \`10\`
|
|
19
|
+
- **Фінальний бал RRS (Release Readiness Score):** \`324\`
|
|
20
|
+
`
|
|
21
|
+
const sampleAppUserMd = `---
|
|
22
|
+
name: user-feedback
|
|
23
|
+
version: 0.1.0
|
|
24
|
+
---
|
|
25
|
+
## ⏱ 1. Метрики Виконання
|
|
26
|
+
- **Витрачений час розробки (Годин):** \`2.0h\`
|
|
27
|
+
- **Кількість ітерацій (Test Runs / Refactors):** \`5\`
|
|
28
|
+
- **Фінальний бал RRS (Release Readiness Score):** \`300\`
|
|
29
|
+
`
|
|
30
|
+
const samplePkgReleaseMd = `# v1.0.0 - 2026-09-01
|
|
31
|
+
## Tasks
|
|
32
|
+
### Done **Feature A** [feat.a]
|
|
33
|
+
### InProgress **Feature B** [feat.b]
|
|
34
|
+
`
|
|
35
|
+
const db = new DB({
|
|
36
|
+
predefined: [
|
|
37
|
+
['releases.txt', rawReleasesTxt],
|
|
38
|
+
['packages/sample-pkg/package.json', { name: '@nan0web/sample-pkg', version: '1.0.0' }],
|
|
39
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/user.md', samplePkgUserMd],
|
|
40
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/release.md', samplePkgReleaseMd],
|
|
41
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/task.spec.js', '/* spec contract */'],
|
|
42
|
+
['apps/sample-app/package.json', { name: 'sample-app', version: '0.1.0' }],
|
|
43
|
+
['apps/sample-app/releases/0/1/v0.1.0/user.md', sampleAppUserMd],
|
|
44
|
+
['apps/sample-app/releases/0/1/v0.1.0/task.test.js', '/* closed test */'],
|
|
45
|
+
],
|
|
46
|
+
})
|
|
47
|
+
await db.connect()
|
|
48
|
+
|
|
49
|
+
const dashboard = new DashboardModel({ registryFile: 'releases.txt' }, { db })
|
|
50
|
+
const data = await dashboard.aggregate()
|
|
51
|
+
|
|
52
|
+
assert.equal(data.projects.length, 2)
|
|
53
|
+
|
|
54
|
+
// First project check
|
|
55
|
+
const p1 = data.projects.find((p) => p.name === '@nan0web/sample-pkg')
|
|
56
|
+
assert.ok(p1)
|
|
57
|
+
assert.equal(p1.hours, 4.5)
|
|
58
|
+
assert.equal(p1.iterations, 10)
|
|
59
|
+
assert.equal(p1.rrs, 324)
|
|
60
|
+
assert.equal(p1.status, 'wip') // has task.spec.js
|
|
61
|
+
assert.equal(p1.tasks.length, 2)
|
|
62
|
+
assert.equal(p1.tasks[0].slug, 'feat.a')
|
|
63
|
+
|
|
64
|
+
// Second project check
|
|
65
|
+
const p2 = data.projects.find((p) => p.name === 'sample-app')
|
|
66
|
+
assert.ok(p2)
|
|
67
|
+
assert.equal(p2.hours, 2.0)
|
|
68
|
+
assert.equal(p2.iterations, 5)
|
|
69
|
+
assert.equal(p2.rrs, 300)
|
|
70
|
+
assert.equal(p2.status, 'closed') // only task.test.js
|
|
71
|
+
|
|
72
|
+
// Summary check
|
|
73
|
+
assert.equal(data.summary.totalProjects, 2)
|
|
74
|
+
assert.equal(data.summary.totalHours, 6.5)
|
|
75
|
+
assert.equal(data.summary.totalIterations, 15)
|
|
76
|
+
assert.equal(data.summary.wipCount, 1)
|
|
77
|
+
assert.equal(data.summary.closedCount, 1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('yields progress and show intents when executed via async *run() generator', async () => {
|
|
81
|
+
const rawReleasesTxt = `packages/sample-pkg\n`
|
|
82
|
+
const samplePkgUserMd = `---
|
|
83
|
+
name: user-feedback
|
|
84
|
+
version: 1.0.0
|
|
85
|
+
---
|
|
86
|
+
- **Витрачений час розробки (Годин):** \`3.0h\`
|
|
87
|
+
- **Кількість ітерацій (Test Runs / Refactors):** \`4\`
|
|
88
|
+
- **Фінальний бал RRS (Release Readiness Score):** \`200\`
|
|
89
|
+
`
|
|
90
|
+
const db = new DB({
|
|
91
|
+
predefined: [
|
|
92
|
+
['releases.txt', rawReleasesTxt],
|
|
93
|
+
['packages/sample-pkg/package.json', { name: '@nan0web/sample-pkg', version: '1.0.0' }],
|
|
94
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/user.md', samplePkgUserMd],
|
|
95
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/release.md', '# v1.0.0\n### Done Init [init]\n'],
|
|
96
|
+
['packages/sample-pkg/releases/1/0/v1.0.0/task.test.js', '/* closed */'],
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
await db.connect()
|
|
100
|
+
|
|
101
|
+
const dashboard = new DashboardModel({ registryFile: 'releases.txt' }, { db })
|
|
102
|
+
const intents = []
|
|
103
|
+
const gen = dashboard.run()
|
|
104
|
+
|
|
105
|
+
let next = await gen.next()
|
|
106
|
+
while (!next.done) {
|
|
107
|
+
intents.push(next.value)
|
|
108
|
+
next = await gen.next()
|
|
109
|
+
}
|
|
110
|
+
const finalResult = next.value
|
|
111
|
+
|
|
112
|
+
assert.ok(intents.length >= 2, 'Expected progress and show intents to be yielded')
|
|
113
|
+
assert.ok(intents.some((i) => i.type === 'progress'))
|
|
114
|
+
assert.ok(intents.some((i) => i.type === 'show'))
|
|
115
|
+
assert.equal(finalResult.type, 'result')
|
|
116
|
+
assert.equal(finalResult.data.projects.length, 1)
|
|
117
|
+
assert.equal(finalResult.data.projects[0].name, '@nan0web/sample-pkg')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('supports injected gitTelemetry adapter', async () => {
|
|
121
|
+
const rawReleasesTxt = `packages/no-rel\n`
|
|
122
|
+
const db = new DB({
|
|
123
|
+
predefined: [
|
|
124
|
+
['releases.txt', rawReleasesTxt],
|
|
125
|
+
['packages/no-rel/package.json', { name: '@nan0web/no-rel', version: '0.0.1' }],
|
|
126
|
+
],
|
|
127
|
+
})
|
|
128
|
+
await db.connect()
|
|
129
|
+
|
|
130
|
+
const mockGit = {
|
|
131
|
+
getGitInfo: () => ({ branch: 'feature/zen', gitStatus: 'dirty' }),
|
|
132
|
+
getGitCommitTelemetry: () => ({ hours: 0, iterations: 0 }),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dashboard = new DashboardModel(
|
|
136
|
+
{ registryFile: 'releases.txt' },
|
|
137
|
+
{ db, gitTelemetry: mockGit }
|
|
138
|
+
)
|
|
139
|
+
const data = await dashboard.aggregate()
|
|
140
|
+
assert.equal(data.projects[0].branch, 'feature/zen')
|
|
141
|
+
assert.equal(data.projects[0].gitStatus, 'dirty')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('parses user.md metrics correctly with static parseUserMetrics', () => {
|
|
145
|
+
const text = `
|
|
146
|
+
- **Витрачений час розробки (Годин):** \`12.5h\`
|
|
147
|
+
- **Кількість ітерацій (Test Runs / Refactors):** \`42\`
|
|
148
|
+
- **Фінальний бал RRS (Release Readiness Score):** \`350+\`
|
|
149
|
+
`
|
|
150
|
+
const metrics = DashboardModel.parseUserMetrics(text)
|
|
151
|
+
assert.equal(metrics.hours, 12.5)
|
|
152
|
+
assert.equal(metrics.iterations, 42)
|
|
153
|
+
assert.equal(metrics.rrs, 350)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { ModelAsApp, progress, show, result } from '@nan0web/ui'
|
|
2
|
+
import { RegistryModel } from '../Registry/RegistryModel.js'
|
|
3
|
+
import {
|
|
4
|
+
parseFrontmatter,
|
|
5
|
+
calculateDaysRemaining,
|
|
6
|
+
parseUserMetrics,
|
|
7
|
+
normalizePriority,
|
|
8
|
+
resolvePriority,
|
|
9
|
+
sortTasks,
|
|
10
|
+
determineReleaseStatus,
|
|
11
|
+
} from './ReleaseParser.js'
|
|
12
|
+
import {
|
|
13
|
+
versionToPath,
|
|
14
|
+
discoverReleases,
|
|
15
|
+
documentExists,
|
|
16
|
+
loadReleaseDoc,
|
|
17
|
+
} from './ReleaseDiscovery.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {import('./ReleaseParser.js').TaskItem} TaskItem
|
|
21
|
+
* @typedef {Object} ProjectDashboardEntry
|
|
22
|
+
* @property {string} path - Relative project path
|
|
23
|
+
* @property {string} name - Project name
|
|
24
|
+
* @property {string} version - Current release version
|
|
25
|
+
* @property {number} hours - Development hours spent
|
|
26
|
+
* @property {number} iterations - Test runs / refactor iterations
|
|
27
|
+
* @property {number} rrs - Release Readiness Score
|
|
28
|
+
* @property {'wip' | 'closed' | 'unknown'} status - Release state
|
|
29
|
+
* @property {TaskItem[]} tasks - Parsed tasks from release.md
|
|
30
|
+
* @property {string} branch - Git branch name
|
|
31
|
+
* @property {'clean' | 'dirty'} gitStatus - Git workspace status
|
|
32
|
+
* @property {string} userContent - Raw user.md content
|
|
33
|
+
* @property {string} releaseContent - Raw release.md content
|
|
34
|
+
* @property {string} sourceDoc - Relative path to the parsed release document
|
|
35
|
+
* @property {string} sourceUserDoc - Relative path to user.md
|
|
36
|
+
* @property {string?} deadline - Project release deadline
|
|
37
|
+
* @property {number?} daysRemaining - Days remaining until deadline
|
|
38
|
+
* @property {string?} priority - Priority (0 | 1 | 2 | 3)
|
|
39
|
+
* @property {number} [releasesCount] - Number of releases found
|
|
40
|
+
* @property {number} [totalHours] - Total hours across all releases
|
|
41
|
+
* @property {number} [totalIterations] - Total iterations across all releases
|
|
42
|
+
* @property {Array<any>} [allReleases] - List of all releases for project
|
|
43
|
+
*
|
|
44
|
+
* @typedef {Object} DashboardSummary
|
|
45
|
+
* @property {number} totalProjects - Total tracked projects
|
|
46
|
+
* @property {number} totalHours - Sum of hours across projects
|
|
47
|
+
* @property {number} totalIterations - Sum of iterations
|
|
48
|
+
* @property {number} wipCount - Active WIP releases
|
|
49
|
+
* @property {number} closedCount - Closed releases
|
|
50
|
+
* @property {number} [totalReleases] - Total releases counted
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* DashboardModel - Aggregates release telemetry, tasks and readiness across projects.
|
|
55
|
+
*/
|
|
56
|
+
export class DashboardModel extends ModelAsApp {
|
|
57
|
+
static registryFile = {
|
|
58
|
+
help: 'Path to releases.txt registry file',
|
|
59
|
+
default: 'releases.txt',
|
|
60
|
+
type: 'string',
|
|
61
|
+
}
|
|
62
|
+
static summary = {
|
|
63
|
+
help: 'Aggregated metrics summary',
|
|
64
|
+
default: { totalProjects: 0, totalHours: 0, totalIterations: 0, wipCount: 0, closedCount: 0 },
|
|
65
|
+
type: 'object',
|
|
66
|
+
}
|
|
67
|
+
static projects = { help: 'List of projects with metrics', default: () => [], type: 'array' }
|
|
68
|
+
static UI = {
|
|
69
|
+
aggregating: 'Aggregating release metrics for all projects...',
|
|
70
|
+
done: 'Dashboard aggregation complete: {$total} projects scanned',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {Partial<DashboardModel>} [data={}]
|
|
75
|
+
* @param {Partial<import('@nan0web/ui').ModelAsAppOptions & { gitTelemetry?: any }>} [options={}]
|
|
76
|
+
*/
|
|
77
|
+
constructor(data = {}, options = {}) {
|
|
78
|
+
super(data, options)
|
|
79
|
+
/** @type {string} Path to releases.txt registry file */ this.registryFile
|
|
80
|
+
/** @type {DashboardSummary} Aggregated metrics summary */ this.summary
|
|
81
|
+
/** @type {ProjectDashboardEntry[]} List of projects with metrics */ this.projects
|
|
82
|
+
/** @type {any} Optional git telemetry adapter */
|
|
83
|
+
this._gitTelemetry = options.gitTelemetry ?? null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static parseUserMetrics = parseUserMetrics
|
|
87
|
+
static normalizePriority = normalizePriority
|
|
88
|
+
static sortTasks = sortTasks
|
|
89
|
+
static versionToPath = versionToPath
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Canonical OLMUI Generator: Yields progress, show, and returns result.
|
|
93
|
+
* @returns {AsyncGenerator<import('@nan0web/ui').Intent, any, any>}
|
|
94
|
+
*/
|
|
95
|
+
async *run() {
|
|
96
|
+
yield progress(this.constructor.UI.aggregating)
|
|
97
|
+
for await (const p of this.aggregateStream()) {
|
|
98
|
+
yield progress(`${p.project} (${p.current}/${p.total})`)
|
|
99
|
+
}
|
|
100
|
+
const t = this._?.t
|
|
101
|
+
const doneMsg = t
|
|
102
|
+
? t(this.constructor.UI.done, { total: this.projects.length })
|
|
103
|
+
: this.constructor.UI.done.replace('{$total}', String(this.projects.length))
|
|
104
|
+
yield show(doneMsg, 'success')
|
|
105
|
+
return result({ summary: this.summary, projects: this.projects })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Programmatic aggregation helper
|
|
110
|
+
* @param {Function} [onProgress]
|
|
111
|
+
* @returns {Promise<{ summary: DashboardSummary, projects: ProjectDashboardEntry[] }>}
|
|
112
|
+
*/
|
|
113
|
+
async aggregate(onProgress) {
|
|
114
|
+
for await (const p of this.aggregateStream()) {
|
|
115
|
+
if (typeof onProgress === 'function') onProgress(p.current, p.total, p.project)
|
|
116
|
+
}
|
|
117
|
+
return { summary: this.summary, projects: this.projects }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async *aggregateStream() {
|
|
121
|
+
const db = this.$db
|
|
122
|
+
const gitTelem = this._gitTelemetry
|
|
123
|
+
|
|
124
|
+
const registry = new RegistryModel({ filePath: this.registryFile }, { db })
|
|
125
|
+
const registeredProjects = await registry.loadProjects()
|
|
126
|
+
|
|
127
|
+
const projectEntries = []
|
|
128
|
+
let totalHours = 0,
|
|
129
|
+
totalIterations = 0,
|
|
130
|
+
wipCount = 0,
|
|
131
|
+
closedCount = 0
|
|
132
|
+
const totalProjects = registeredProjects.length
|
|
133
|
+
let currentIdx = 0
|
|
134
|
+
|
|
135
|
+
for (const proj of registeredProjects) {
|
|
136
|
+
currentIdx++
|
|
137
|
+
yield { current: currentIdx, total: totalProjects, project: proj.name || proj.path }
|
|
138
|
+
|
|
139
|
+
const projectRoot = proj.path === '.' ? '' : `${proj.path.replace(/\/+$/, '')}/`
|
|
140
|
+
const discovered = await discoverReleases(db, projectRoot)
|
|
141
|
+
|
|
142
|
+
if (discovered.length === 0) {
|
|
143
|
+
const gitInfo = gitTelem?.getGitInfo ? gitTelem.getGitInfo(proj.path) : { branch: 'main', gitStatus: 'clean' }
|
|
144
|
+
projectEntries.push(this.#createEmptyEntry(proj, gitInfo))
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const primaryRel = discovered[0]
|
|
149
|
+
const effectiveVersion = primaryRel.version
|
|
150
|
+
const matchedReleaseDir = primaryRel.path
|
|
151
|
+
|
|
152
|
+
let userContent = ''
|
|
153
|
+
const matchedUserDoc = `${matchedReleaseDir}/user.md`
|
|
154
|
+
try {
|
|
155
|
+
userContent = (await db.loadDocument(matchedUserDoc, '')) || ''
|
|
156
|
+
} catch {}
|
|
157
|
+
|
|
158
|
+
const metrics = parseUserMetrics(String(userContent || ''))
|
|
159
|
+
if (metrics.hours === 0 && metrics.iterations === 0 && gitTelem?.getGitCommitTelemetry) {
|
|
160
|
+
const telem = gitTelem.getGitCommitTelemetry(proj.path, matchedReleaseDir)
|
|
161
|
+
if (telem?.hours > 0) {
|
|
162
|
+
metrics.hours = telem.hours
|
|
163
|
+
metrics.iterations = telem.iterations
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { releaseContent, sourceDoc, tasks } = await loadReleaseDoc(db, matchedReleaseDir)
|
|
168
|
+
const fm = parseFrontmatter(releaseContent || userContent)
|
|
169
|
+
const deadline = fm.deadline || null
|
|
170
|
+
const daysRemaining = calculateDaysRemaining(deadline)
|
|
171
|
+
const priority = resolvePriority(fm, daysRemaining)
|
|
172
|
+
|
|
173
|
+
const hasSpec = await documentExists(db, `${matchedReleaseDir}/task.spec.js`)
|
|
174
|
+
const hasTest = await documentExists(db, `${matchedReleaseDir}/task.test.js`)
|
|
175
|
+
const status = determineReleaseStatus({
|
|
176
|
+
fmStatus: fm.status,
|
|
177
|
+
hasSpec,
|
|
178
|
+
hasTest,
|
|
179
|
+
tasks,
|
|
180
|
+
userStr: String(userContent || ''),
|
|
181
|
+
metrics,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
if (status === 'wip') wipCount++
|
|
185
|
+
else if (status === 'closed') closedCount++
|
|
186
|
+
|
|
187
|
+
const { allReleases, projectTotalHours, projectTotalIterations } =
|
|
188
|
+
await this.#scanAllReleases(db, discovered, metrics)
|
|
189
|
+
totalHours += projectTotalHours
|
|
190
|
+
totalIterations += projectTotalIterations
|
|
191
|
+
|
|
192
|
+
const gitInfo = gitTelem?.getGitInfo ? gitTelem.getGitInfo(proj.path) : { branch: 'main', gitStatus: 'clean' }
|
|
193
|
+
projectEntries.push({
|
|
194
|
+
path: proj.path,
|
|
195
|
+
name: proj.name,
|
|
196
|
+
version: effectiveVersion,
|
|
197
|
+
hours: metrics.hours,
|
|
198
|
+
iterations: metrics.iterations,
|
|
199
|
+
totalHours: Math.round(projectTotalHours * 10) / 10,
|
|
200
|
+
totalIterations: projectTotalIterations,
|
|
201
|
+
releasesCount: Math.max(discovered.length, 1),
|
|
202
|
+
allReleases,
|
|
203
|
+
rrs: metrics.rrs,
|
|
204
|
+
status,
|
|
205
|
+
tasks,
|
|
206
|
+
branch: gitInfo.branch,
|
|
207
|
+
gitStatus: gitInfo.gitStatus,
|
|
208
|
+
userContent: String(userContent || ''),
|
|
209
|
+
releaseContent: String(releaseContent || ''),
|
|
210
|
+
sourceDoc:
|
|
211
|
+
sourceDoc || `${projectRoot}releases/${versionToPath(effectiveVersion)}/release.md`,
|
|
212
|
+
sourceUserDoc:
|
|
213
|
+
matchedUserDoc || `${projectRoot}releases/${versionToPath(effectiveVersion)}/user.md`,
|
|
214
|
+
deadline,
|
|
215
|
+
daysRemaining,
|
|
216
|
+
priority,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.summary = {
|
|
221
|
+
totalProjects: registeredProjects.length,
|
|
222
|
+
totalReleases: projectEntries.reduce((sum, p) => sum + (p.releasesCount || 1), 0),
|
|
223
|
+
totalHours: Math.round(totalHours * 10) / 10,
|
|
224
|
+
totalIterations,
|
|
225
|
+
wipCount,
|
|
226
|
+
closedCount,
|
|
227
|
+
}
|
|
228
|
+
this.projects = projectEntries
|
|
229
|
+
return { summary: this.summary, projects: projectEntries }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#createEmptyEntry(proj, gitInfo) {
|
|
233
|
+
return {
|
|
234
|
+
name: proj.name,
|
|
235
|
+
version: proj.version || '0.0.0',
|
|
236
|
+
path: proj.path,
|
|
237
|
+
status: 'unknown',
|
|
238
|
+
hours: 0,
|
|
239
|
+
iterations: 0,
|
|
240
|
+
rrs: 0,
|
|
241
|
+
branch: gitInfo.branch,
|
|
242
|
+
gitStatus: gitInfo.gitStatus,
|
|
243
|
+
tasks: [],
|
|
244
|
+
deadline: null,
|
|
245
|
+
daysRemaining: null,
|
|
246
|
+
priority: '1',
|
|
247
|
+
releasesCount: 0,
|
|
248
|
+
totalHours: 0,
|
|
249
|
+
totalIterations: 0,
|
|
250
|
+
allReleases: [],
|
|
251
|
+
sourceDoc: '',
|
|
252
|
+
sourceUserDoc: '',
|
|
253
|
+
userContent: '',
|
|
254
|
+
releaseContent: '',
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async #scanAllReleases(db, discovered, primaryMetrics) {
|
|
259
|
+
let projectTotalHours = 0,
|
|
260
|
+
projectTotalIterations = 0
|
|
261
|
+
const allReleases = []
|
|
262
|
+
|
|
263
|
+
for (const rel of discovered) {
|
|
264
|
+
let relUser = ''
|
|
265
|
+
try {
|
|
266
|
+
relUser = (await db.loadDocument(`${rel.path}/user.md`, '')) || ''
|
|
267
|
+
} catch {}
|
|
268
|
+
const rm = parseUserMetrics(String(relUser || ''))
|
|
269
|
+
projectTotalHours += rm.hours
|
|
270
|
+
projectTotalIterations += rm.iterations
|
|
271
|
+
|
|
272
|
+
const {
|
|
273
|
+
releaseContent: relDocContent,
|
|
274
|
+
sourceDoc: relDocPath,
|
|
275
|
+
tasks: relTasks,
|
|
276
|
+
} = await loadReleaseDoc(db, rel.path)
|
|
277
|
+
const relFm = parseFrontmatter(relDocContent || relUser)
|
|
278
|
+
const hasRelSpec = await documentExists(db, `${rel.path}/task.spec.js`)
|
|
279
|
+
const hasRelTest = await documentExists(db, `${rel.path}/task.test.js`)
|
|
280
|
+
|
|
281
|
+
const relStatus = determineReleaseStatus({
|
|
282
|
+
fmStatus: relFm.status,
|
|
283
|
+
hasSpec: hasRelSpec,
|
|
284
|
+
hasTest: hasRelTest,
|
|
285
|
+
tasks: relTasks,
|
|
286
|
+
userStr: String(relUser || ''),
|
|
287
|
+
metrics: rm,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
allReleases.push({
|
|
291
|
+
version: rel.version,
|
|
292
|
+
path: rel.path,
|
|
293
|
+
hours: rm.hours,
|
|
294
|
+
iterations: rm.iterations,
|
|
295
|
+
rrs: rm.rrs,
|
|
296
|
+
status: relStatus,
|
|
297
|
+
tasks: relTasks,
|
|
298
|
+
sourceDoc: relDocPath,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (projectTotalHours === 0 && primaryMetrics.hours > 0)
|
|
303
|
+
projectTotalHours = primaryMetrics.hours
|
|
304
|
+
if (projectTotalIterations === 0 && primaryMetrics.iterations > 0)
|
|
305
|
+
projectTotalIterations = primaryMetrics.iterations
|
|
306
|
+
return { allReleases, projectTotalHours, projectTotalIterations }
|
|
307
|
+
}
|
|
308
|
+
}
|