@nan0web/release 3.3.0 → 3.5.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/README.md +0 -35
- package/bin/nan0release.js +33 -2
- package/bin/serve.js +73 -0
- package/package.json +46 -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 +33 -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/PMaCParser.js +418 -0
- package/src/domain/PMaCParser.story.js +152 -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/ValidateCommand.js +426 -0
- package/src/domain/ValidateCommand.story.js +92 -0
- 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/models/ReleaseIssueModel.js +27 -0
- package/src/domain/models/ReleaseMemberModel.js +21 -0
- package/src/domain/models/ReleaseModel.js +191 -0
- package/src/domain/models/ReleaseParityModel.js +27 -0
- package/src/domain/models/ReleaseTaskModel.js +33 -0
- package/src/domain/models/index.js +6 -0
- package/src/domain/templates/release.html +34 -0
- package/src/domain/templates/release.html.template.js +34 -0
- package/src/index.js +15 -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 +25 -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/PMaCParser.d.ts +64 -0
- package/types/domain/PMaCParser.story.d.ts +1 -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/ValidateCommand.d.ts +69 -0
- package/types/domain/ValidateCommand.story.d.ts +1 -0
- 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/models/ReleaseIssueModel.d.ts +35 -0
- package/types/domain/models/ReleaseMemberModel.d.ts +25 -0
- package/types/domain/models/ReleaseModel.d.ts +122 -0
- package/types/domain/models/ReleaseParityModel.d.ts +37 -0
- package/types/domain/models/ReleaseTaskModel.d.ts +46 -0
- package/types/domain/models/index.d.ts +5 -0
- package/types/domain/templates/release.html.template.d.ts +2 -0
- package/types/index.d.ts +21 -10
- package/types/inspectors/inspect-js.d.ts +2 -0
- package/types/utils/nodejs/GitTelemetry.d.ts +23 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import Markdown, {
|
|
2
|
+
MDHeading1,
|
|
3
|
+
MDHeading2,
|
|
4
|
+
MDHeading3,
|
|
5
|
+
MDTaskList,
|
|
6
|
+
MDTable,
|
|
7
|
+
MDTableRow,
|
|
8
|
+
MDList,
|
|
9
|
+
MDParagraph,
|
|
10
|
+
} from '@nan0web/markdown'
|
|
11
|
+
import {
|
|
12
|
+
ReleaseModel,
|
|
13
|
+
ReleaseMemberModel,
|
|
14
|
+
ReleaseTaskModel,
|
|
15
|
+
ReleaseParityModel,
|
|
16
|
+
ReleaseIssueModel,
|
|
17
|
+
} from './models/index.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Clean text from emoji, symbols, markdown formatting and trim.
|
|
21
|
+
* @param {string} text
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function cleanSectionTitle(text) {
|
|
25
|
+
return String(text || '')
|
|
26
|
+
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '')
|
|
27
|
+
.replace(/^[#\s*_-]+|[#\s*_-]+$/g, '')
|
|
28
|
+
.trim()
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Match section title against known synonyms in ReleaseModel.SECTIONS.
|
|
34
|
+
* @param {string} headingText
|
|
35
|
+
* @param {typeof ReleaseModel} ModelClass
|
|
36
|
+
* @returns {string | null} Recognized section key or null
|
|
37
|
+
*/
|
|
38
|
+
export function identifySection(headingText, ModelClass = ReleaseModel) {
|
|
39
|
+
const cleaned = cleanSectionTitle(headingText)
|
|
40
|
+
if (!cleaned) return null
|
|
41
|
+
|
|
42
|
+
for (const [sectionKey, synonyms] of Object.entries(ModelClass.SECTIONS)) {
|
|
43
|
+
for (const synonym of synonyms) {
|
|
44
|
+
const synCleaned = synonym.trim().toLowerCase()
|
|
45
|
+
if (cleaned === synCleaned || cleaned.startsWith(synCleaned) || cleaned.endsWith(synCleaned)) {
|
|
46
|
+
return sectionKey
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve external tracker URL for a task or issue ID using template URL.
|
|
55
|
+
* Example: trackerUrl = "https://github.com/org/repo/issues/{id}", rawId = "[#142]" -> "https://github.com/org/repo/issues/142"
|
|
56
|
+
* @param {string} id
|
|
57
|
+
* @param {string} trackerTemplate
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function resolveTrackerUrl(id, trackerTemplate) {
|
|
61
|
+
if (!trackerTemplate || !id) return ''
|
|
62
|
+
const cleanId = String(id).replace(/^\[#?|\]$/g, '').trim()
|
|
63
|
+
return trackerTemplate.replace(/\{id\}/g, cleanId)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* PMaCParser - Parses PMaC Release/Task specification documents into ReleaseModel instances.
|
|
68
|
+
*/
|
|
69
|
+
export class PMaCParser {
|
|
70
|
+
/**
|
|
71
|
+
* @param {typeof ReleaseModel} [modelClass]
|
|
72
|
+
*/
|
|
73
|
+
constructor(modelClass = ReleaseModel) {
|
|
74
|
+
this.ModelClass = modelClass
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse markdown content into a ReleaseModel instance.
|
|
79
|
+
* @param {string} markdownContent
|
|
80
|
+
* @returns {ReleaseModel}
|
|
81
|
+
*/
|
|
82
|
+
parse(markdownContent) {
|
|
83
|
+
const md = new Markdown(markdownContent)
|
|
84
|
+
const frontmatter = md.vars || {}
|
|
85
|
+
const elements = md.document?.children || []
|
|
86
|
+
|
|
87
|
+
// 1. Identify H1 for version and title
|
|
88
|
+
let version = ''
|
|
89
|
+
let title = ''
|
|
90
|
+
|
|
91
|
+
const h1 = elements.find((el) => el instanceof MDHeading1)
|
|
92
|
+
if (h1) {
|
|
93
|
+
const rawTitle = h1.content?.trim() || ''
|
|
94
|
+
// Matches formats like: "# v0.1.0 — Core Release Engine" or "# v0.1.0 - Core Release" or "# v1.0.0"
|
|
95
|
+
const h1Match = rawTitle.match(/^(v\d+\.\d+\.\d+(?:-[\w.]+)?)\s*(?:[—–-]\s*(.+))?$/i)
|
|
96
|
+
if (h1Match) {
|
|
97
|
+
version = h1Match[1]
|
|
98
|
+
title = (h1Match[2] || '').trim()
|
|
99
|
+
} else {
|
|
100
|
+
// Fallback: check if starts with version
|
|
101
|
+
const vMatch = rawTitle.match(/^(v\d+\.\d+\.\d+(?:-[\w.]+)?)/i)
|
|
102
|
+
if (vMatch) {
|
|
103
|
+
version = vMatch[1]
|
|
104
|
+
title = rawTitle.slice(vMatch[0].length).replace(/^[—–-\s]+/, '').trim()
|
|
105
|
+
} else {
|
|
106
|
+
title = rawTitle
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const trackerTemplate = frontmatter.tracker || ''
|
|
112
|
+
|
|
113
|
+
// Build release data
|
|
114
|
+
const releaseData = {
|
|
115
|
+
version: frontmatter.version || version,
|
|
116
|
+
title: frontmatter.title || title,
|
|
117
|
+
status: frontmatter.status || 'todo',
|
|
118
|
+
type: frontmatter.type || 'minor',
|
|
119
|
+
epic: frontmatter.epic,
|
|
120
|
+
sprint: frontmatter.sprint,
|
|
121
|
+
tracker: trackerTemplate,
|
|
122
|
+
summary: '',
|
|
123
|
+
team: [],
|
|
124
|
+
tasks: [],
|
|
125
|
+
criteria: [],
|
|
126
|
+
parity: [],
|
|
127
|
+
issues: [],
|
|
128
|
+
files: [],
|
|
129
|
+
meta: { ...frontmatter },
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Iterate through sections delimited by MDHeading2
|
|
133
|
+
let currentSection = null
|
|
134
|
+
let currentTaskGroup = ''
|
|
135
|
+
|
|
136
|
+
for (const el of elements) {
|
|
137
|
+
if (el instanceof MDHeading2) {
|
|
138
|
+
currentSection = identifySection(el.content, this.ModelClass)
|
|
139
|
+
currentTaskGroup = ''
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (el instanceof MDHeading3) {
|
|
144
|
+
currentTaskGroup = el.content?.trim() || ''
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!currentSection) continue
|
|
149
|
+
|
|
150
|
+
if (currentSection === 'summary') {
|
|
151
|
+
if (el instanceof MDParagraph) {
|
|
152
|
+
const text = el.content?.trim()
|
|
153
|
+
if (text) {
|
|
154
|
+
releaseData.summary = releaseData.summary
|
|
155
|
+
? `${releaseData.summary}\n\n${text}`
|
|
156
|
+
: text
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} else if (currentSection === 'team') {
|
|
160
|
+
this._parseTeamSection(el, releaseData.team)
|
|
161
|
+
} else if (currentSection === 'tasks') {
|
|
162
|
+
this._parseTasksSection(el, releaseData.tasks, currentTaskGroup, trackerTemplate)
|
|
163
|
+
} else if (currentSection === 'criteria') {
|
|
164
|
+
this._parseTasksSection(el, releaseData.criteria, currentTaskGroup, trackerTemplate)
|
|
165
|
+
} else if (currentSection === 'parity') {
|
|
166
|
+
this._parseParitySection(el, releaseData.parity)
|
|
167
|
+
} else if (currentSection === 'issues') {
|
|
168
|
+
this._parseIssuesSection(el, releaseData.issues)
|
|
169
|
+
} else if (currentSection === 'artifacts') {
|
|
170
|
+
this._parseArtifactsSection(el, releaseData.files)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return new this.ModelClass(releaseData)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @private
|
|
179
|
+
*/
|
|
180
|
+
_parseTeamSection(el, teamList) {
|
|
181
|
+
const items = []
|
|
182
|
+
if (el instanceof MDList) {
|
|
183
|
+
for (const child of el.children) {
|
|
184
|
+
items.push(child.content?.trim() || '')
|
|
185
|
+
}
|
|
186
|
+
} else if (el.content) {
|
|
187
|
+
const lines = el.content.split('\n')
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
const trimmed = line.trim()
|
|
190
|
+
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
|
191
|
+
items.push(trimmed.slice(2).trim())
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const item of items) {
|
|
197
|
+
// Matches: "**Lead / Architect:** Олександр Снігірьов (@snigirev)"
|
|
198
|
+
// or "**Посада:** Ім'я (@handle)"
|
|
199
|
+
const match = item.match(/^\*\*([^*]+)\*\*\s*(.*)$/)
|
|
200
|
+
if (match) {
|
|
201
|
+
const role = match[1].replace(/:$/, '').trim()
|
|
202
|
+
const rest = match[2].trim()
|
|
203
|
+
const handleMatch = rest.match(/\(@([^)]+)\)/)
|
|
204
|
+
const handle = handleMatch ? handleMatch[1].trim() : ''
|
|
205
|
+
const name = rest.replace(/\(@[^)]+\)/, '').trim()
|
|
206
|
+
teamList.push(new ReleaseMemberModel({ role, name, handle }))
|
|
207
|
+
} else {
|
|
208
|
+
teamList.push(new ReleaseMemberModel({ role: '', name: item, handle: '' }))
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @private
|
|
215
|
+
*/
|
|
216
|
+
_parseTasksSection(el, tasksList, taskGroup, trackerTemplate) {
|
|
217
|
+
const items = []
|
|
218
|
+
if (el instanceof MDTaskList || el instanceof MDList) {
|
|
219
|
+
for (const child of el.children) {
|
|
220
|
+
items.push(child.content?.trim() || '')
|
|
221
|
+
}
|
|
222
|
+
} else if (el.content && el.content.includes('- [')) {
|
|
223
|
+
const lines = el.content.split('\n')
|
|
224
|
+
for (const line of lines) {
|
|
225
|
+
const trimmed = line.trim()
|
|
226
|
+
if (trimmed.startsWith('- [')) {
|
|
227
|
+
items.push(trimmed.slice(2).trim())
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const rawItem of items) {
|
|
233
|
+
// e.g. "[x] [#101-1] Винести базову модель ReleaseModel у пакет @nan0web/release"
|
|
234
|
+
// or "- [x] [#101-1] ..." or "[ ] [#101-3] ..."
|
|
235
|
+
let line = rawItem
|
|
236
|
+
if (line.startsWith('- ')) line = line.slice(2).trim()
|
|
237
|
+
|
|
238
|
+
const boxMatch = line.match(/^\[([ xX])\]\s*(.*)$/)
|
|
239
|
+
let isDone = false
|
|
240
|
+
let text = line
|
|
241
|
+
|
|
242
|
+
if (boxMatch) {
|
|
243
|
+
isDone = boxMatch[1].toLowerCase() === 'x'
|
|
244
|
+
text = boxMatch[2].trim()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Extract task ID like [#101-1] or [101-1] or [CORE-101]
|
|
248
|
+
let id = ''
|
|
249
|
+
const idMatch = text.match(/^\[#?([A-Za-z0-9_-]+)\]\s*(.*)$/)
|
|
250
|
+
if (idMatch) {
|
|
251
|
+
id = idMatch[1]
|
|
252
|
+
text = idMatch[2].trim()
|
|
253
|
+
} else {
|
|
254
|
+
// Fallback: derive ID from group or random
|
|
255
|
+
id = `task-${tasksList.length + 1}`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check for notes or assignee or hours in parens/brackets
|
|
259
|
+
let notes = ''
|
|
260
|
+
if (taskGroup) {
|
|
261
|
+
notes = taskGroup
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
tasksList.push(
|
|
265
|
+
new ReleaseTaskModel({
|
|
266
|
+
id,
|
|
267
|
+
title: text,
|
|
268
|
+
status: isDone ? 'done' : 'todo',
|
|
269
|
+
notes,
|
|
270
|
+
})
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @private
|
|
277
|
+
*/
|
|
278
|
+
_parseParitySection(el, parityList) {
|
|
279
|
+
let rows = []
|
|
280
|
+
if (el instanceof MDTable) {
|
|
281
|
+
rows = el.children || []
|
|
282
|
+
} else if (el instanceof MDTableRow) {
|
|
283
|
+
rows = [el]
|
|
284
|
+
}
|
|
285
|
+
if (rows.length === 0) return
|
|
286
|
+
|
|
287
|
+
for (const row of rows) {
|
|
288
|
+
const rowContent = row.content?.trim() || ''
|
|
289
|
+
// Skip separator rows like | :--- | :---: |
|
|
290
|
+
if (/^\|[\s|:-]+\|$/.test(rowContent)) continue
|
|
291
|
+
|
|
292
|
+
const cells = rowContent
|
|
293
|
+
.split('|')
|
|
294
|
+
.map((c) => c.trim())
|
|
295
|
+
.filter((c, idx, arr) => idx > 0 && idx < arr.length - 1)
|
|
296
|
+
|
|
297
|
+
// Skip header row
|
|
298
|
+
if (cells.length >= 3) {
|
|
299
|
+
if (cells[0].toLowerCase() === 'колекція' || cells[0].toLowerCase() === 'collection') {
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
const collection = cells[0].replace(/`/g, '')
|
|
303
|
+
const expected = parseInt(cells[1], 10) || 0
|
|
304
|
+
const actual = parseInt(cells[2], 10) || 0
|
|
305
|
+
const statusText = cells[3] || ''
|
|
306
|
+
const comment = cells[4] || ''
|
|
307
|
+
const synced = statusText.includes('OK') || statusText.includes('✅') || expected === actual
|
|
308
|
+
|
|
309
|
+
parityList.push(
|
|
310
|
+
new ReleaseParityModel({
|
|
311
|
+
collection,
|
|
312
|
+
expected,
|
|
313
|
+
actual,
|
|
314
|
+
synced,
|
|
315
|
+
comment,
|
|
316
|
+
})
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
_parseIssuesSection(el, issuesList) {
|
|
326
|
+
const items = []
|
|
327
|
+
if (el instanceof MDList) {
|
|
328
|
+
for (const child of el.children) {
|
|
329
|
+
items.push(child.content?.trim() || '')
|
|
330
|
+
}
|
|
331
|
+
} else if (el.content) {
|
|
332
|
+
const lines = el.content.split('\n')
|
|
333
|
+
for (const line of lines) {
|
|
334
|
+
const trimmed = line.trim()
|
|
335
|
+
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
|
336
|
+
items.push(trimmed.slice(2).trim())
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const item of items) {
|
|
342
|
+
// Check if this is a sub-detail line (e.g. "Джерело: `@nan0web/payload-release/admin.jsx:L85`" or "Source: ...")
|
|
343
|
+
const sourceMatch = item.match(/^(?:Джерело|Source|File):\s*`?([^`]+)`?/i)
|
|
344
|
+
if (sourceMatch && issuesList.length > 0) {
|
|
345
|
+
const lastIssue = issuesList[issuesList.length - 1]
|
|
346
|
+
lastIssue.source = sourceMatch[1].trim()
|
|
347
|
+
lastIssue.file = sourceMatch[1].split(':')[0].trim()
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Example: "**[WARN-001]** (Performance) Файли розміром > 2MB не повинні відкриватися"
|
|
352
|
+
const codeMatch = item.match(/^\*\*\[?([A-Za-z0-9_-]+)\]?\*\*\s*(.*)$/)
|
|
353
|
+
let code = ''
|
|
354
|
+
let rest = item
|
|
355
|
+
if (codeMatch) {
|
|
356
|
+
code = codeMatch[1]
|
|
357
|
+
rest = codeMatch[2].trim()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let level = 'warning'
|
|
361
|
+
const upperCode = code.toUpperCase()
|
|
362
|
+
if (upperCode.includes('ERR') || upperCode.includes('BLOCK')) level = 'error'
|
|
363
|
+
else if (upperCode.includes('INFO')) level = 'info'
|
|
364
|
+
|
|
365
|
+
issuesList.push(
|
|
366
|
+
new ReleaseIssueModel({
|
|
367
|
+
code,
|
|
368
|
+
message: rest,
|
|
369
|
+
level,
|
|
370
|
+
})
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* @private
|
|
377
|
+
*/
|
|
378
|
+
_parseArtifactsSection(el, filesList) {
|
|
379
|
+
let rows = []
|
|
380
|
+
if (el instanceof MDTable) {
|
|
381
|
+
rows = el.children || []
|
|
382
|
+
} else if (el instanceof MDTableRow) {
|
|
383
|
+
rows = [el]
|
|
384
|
+
}
|
|
385
|
+
if (rows.length === 0) return
|
|
386
|
+
|
|
387
|
+
for (const row of rows) {
|
|
388
|
+
const rowContent = row.content?.trim() || ''
|
|
389
|
+
if (/^\|[\s|:-]+\|$/.test(rowContent)) continue
|
|
390
|
+
|
|
391
|
+
const cells = rowContent
|
|
392
|
+
.split('|')
|
|
393
|
+
.map((c) => c.trim())
|
|
394
|
+
.filter((c, idx, arr) => idx > 0 && idx < arr.length - 1)
|
|
395
|
+
|
|
396
|
+
if (cells.length >= 1) {
|
|
397
|
+
if (cells[0].toLowerCase() === 'файл' || cells[0].toLowerCase() === 'file') {
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
const file = cells[0].replace(/`/g, '')
|
|
401
|
+
const size = cells[1] || ''
|
|
402
|
+
const hash = cells[2] ? cells[2].replace(/`/g, '') : ''
|
|
403
|
+
const type = cells[3] || ''
|
|
404
|
+
filesList.push({ file, size, hash, type })
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Static shortcut to parse specification
|
|
411
|
+
* @param {string} markdownContent
|
|
412
|
+
* @returns {ReleaseModel}
|
|
413
|
+
*/
|
|
414
|
+
static parse(markdownContent) {
|
|
415
|
+
return new PMaCParser().parse(markdownContent)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import {
|
|
4
|
+
ReleaseModel,
|
|
5
|
+
ReleaseTaskModel,
|
|
6
|
+
ReleaseParityModel,
|
|
7
|
+
ReleaseIssueModel,
|
|
8
|
+
} from './models/index.js'
|
|
9
|
+
import { PMaCParser, resolveTrackerUrl } from './PMaCParser.js'
|
|
10
|
+
|
|
11
|
+
describe('PMaC Parser & Release Models Contract', () => {
|
|
12
|
+
const sampleMarkdown = `---
|
|
13
|
+
status: work
|
|
14
|
+
type: minor
|
|
15
|
+
epic: EPIC-CORE-42
|
|
16
|
+
sprint: Sprint 42
|
|
17
|
+
tracker: https://github.com/nan0web/nan.web/issues/{id}
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# v0.1.0 — Core Release Engine & Payload CMS Plugin Integration
|
|
21
|
+
|
|
22
|
+
## Опис та Мета
|
|
23
|
+
Плагін @nan0web/payload-release для Payload CMS 3.x, що забезпечує автоматичну фіксацію запусків релізів.
|
|
24
|
+
|
|
25
|
+
## Команда
|
|
26
|
+
- **Lead / Architect:** Олександр Снігірьов (@snigirev)
|
|
27
|
+
- **AI Lead Developer:** Agent Antigravity (@agy)
|
|
28
|
+
|
|
29
|
+
## Робочий План та Задачі
|
|
30
|
+
|
|
31
|
+
### 1. Архітектурна ізоляція моделей [#101]
|
|
32
|
+
- [x] [#101-1] Винести базову модель ReleaseModel у пакет @nan0web/release
|
|
33
|
+
- [x] [#101-2] Реалізувати уніфікований AST-парсер специфікацій на базі @nan0web/markdown
|
|
34
|
+
- [ ] [#101-3] Забезпечити 100% покриття контрактними тестами
|
|
35
|
+
|
|
36
|
+
### 2. Payload CMS Адаптер [#102]
|
|
37
|
+
- [x] [#102-1] Реалізувати колекцію releases з табами
|
|
38
|
+
- [ ] [#102-3] Перегляд вмісту специфікацій через модальне вікно
|
|
39
|
+
|
|
40
|
+
## Аудит та Паритет Даних
|
|
41
|
+
| Колекція | Очікувано | Фактично | Статус | Коментар |
|
|
42
|
+
| :--- | :---: | :---: | :---: | :--- |
|
|
43
|
+
| \`users\` | 12 | 12 | ✅ OK | Синхронізовано |
|
|
44
|
+
| \`media\` | 45 | 44 | ⚠️ Mismatch | Відсутній аватар для demo |
|
|
45
|
+
|
|
46
|
+
## Зауваження та Ризики
|
|
47
|
+
- **[WARN-001]** (Performance) Файли розміром > 2MB не повинні відкриватися без пагінації.
|
|
48
|
+
- **[INFO-002]** (Environment) Node.js v20.x є мінімально підтримуваною версією.
|
|
49
|
+
|
|
50
|
+
## Артефакти
|
|
51
|
+
| Файл | Розмір | SHA-256 | Тип |
|
|
52
|
+
| :--- | :---: | :--- | :--- |
|
|
53
|
+
| \`task.md\` | 6.0 KB | \`e3b0c442...\` | ТЗ / Специфікація |
|
|
54
|
+
`
|
|
55
|
+
|
|
56
|
+
it('ReleaseModel instantiates with schema defaults and validates', () => {
|
|
57
|
+
const release = new ReleaseModel({
|
|
58
|
+
version: 'v0.1.0',
|
|
59
|
+
title: 'Core Release Engine',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
assert.strictEqual(release.version, 'v0.1.0')
|
|
63
|
+
assert.strictEqual(release.title, 'Core Release Engine')
|
|
64
|
+
assert.strictEqual(release.status, 'todo')
|
|
65
|
+
assert.strictEqual(release.type, 'minor')
|
|
66
|
+
assert.deepStrictEqual(release.tasks, [])
|
|
67
|
+
assert.deepStrictEqual(release.parity, [])
|
|
68
|
+
assert.deepStrictEqual(release.issues, [])
|
|
69
|
+
assert.strictEqual(release.validate(), true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('PMaCParser extracts version, title and metadata from H1 and frontmatter', () => {
|
|
73
|
+
const release = PMaCParser.parse(sampleMarkdown)
|
|
74
|
+
|
|
75
|
+
assert.strictEqual(release.version, 'v0.1.0')
|
|
76
|
+
assert.strictEqual(release.title, 'Core Release Engine & Payload CMS Plugin Integration')
|
|
77
|
+
assert.strictEqual(release.status, 'work')
|
|
78
|
+
assert.strictEqual(release.type, 'minor')
|
|
79
|
+
assert.strictEqual(release.epic, 'EPIC-CORE-42')
|
|
80
|
+
assert.strictEqual(release.sprint, 'Sprint 42')
|
|
81
|
+
assert.strictEqual(release.tracker, 'https://github.com/nan0web/nan.web/issues/{id}')
|
|
82
|
+
assert.ok(release.summary.includes('Плагін @nan0web/payload-release'))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('PMaCParser parses tasks with status and IDs', () => {
|
|
86
|
+
const release = PMaCParser.parse(sampleMarkdown)
|
|
87
|
+
|
|
88
|
+
assert.strictEqual(release.tasks.length, 5)
|
|
89
|
+
const task1 = release.tasks[0]
|
|
90
|
+
assert.strictEqual(task1.id, '101-1')
|
|
91
|
+
assert.strictEqual(task1.title, 'Винести базову модель ReleaseModel у пакет @nan0web/release')
|
|
92
|
+
assert.strictEqual(task1.status, 'done')
|
|
93
|
+
|
|
94
|
+
const task3 = release.tasks[2]
|
|
95
|
+
assert.strictEqual(task3.id, '101-3')
|
|
96
|
+
assert.strictEqual(task3.status, 'todo')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('PMaCParser parses data parity table into ReleaseParityModel items', () => {
|
|
100
|
+
const release = PMaCParser.parse(sampleMarkdown)
|
|
101
|
+
|
|
102
|
+
assert.strictEqual(release.parity.length, 2)
|
|
103
|
+
const p1 = release.parity[0]
|
|
104
|
+
assert.strictEqual(p1.collection, 'users')
|
|
105
|
+
assert.strictEqual(p1.expected, 12)
|
|
106
|
+
assert.strictEqual(p1.actual, 12)
|
|
107
|
+
assert.strictEqual(p1.synced, true)
|
|
108
|
+
|
|
109
|
+
const p2 = release.parity[1]
|
|
110
|
+
assert.strictEqual(p2.collection, 'media')
|
|
111
|
+
assert.strictEqual(p2.expected, 45)
|
|
112
|
+
assert.strictEqual(p2.actual, 44)
|
|
113
|
+
assert.strictEqual(p2.synced, false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('PMaCParser parses issues and risks into ReleaseIssueModel items', () => {
|
|
117
|
+
const release = PMaCParser.parse(sampleMarkdown)
|
|
118
|
+
|
|
119
|
+
assert.strictEqual(release.issues.length, 2)
|
|
120
|
+
const issue1 = release.issues[0]
|
|
121
|
+
assert.strictEqual(issue1.code, 'WARN-001')
|
|
122
|
+
assert.strictEqual(issue1.level, 'warning')
|
|
123
|
+
assert.ok(issue1.message.includes('(Performance)'))
|
|
124
|
+
|
|
125
|
+
const issue2 = release.issues[1]
|
|
126
|
+
assert.strictEqual(issue2.code, 'INFO-002')
|
|
127
|
+
assert.strictEqual(issue2.level, 'info')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('PMaCParser parses artifacts table into files array', () => {
|
|
131
|
+
const release = PMaCParser.parse(sampleMarkdown)
|
|
132
|
+
|
|
133
|
+
assert.strictEqual(release.files.length, 1)
|
|
134
|
+
assert.strictEqual(release.files[0].file, 'task.md')
|
|
135
|
+
assert.strictEqual(release.files[0].size, '6.0 KB')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('resolveTrackerUrl resolves templated ticket URLs', () => {
|
|
139
|
+
const url = resolveTrackerUrl('[#142]', 'https://github.com/nan0web/nan.web/issues/{id}')
|
|
140
|
+
assert.strictEqual(url, 'https://github.com/nan0web/nan.web/issues/142')
|
|
141
|
+
|
|
142
|
+
const url2 = resolveTrackerUrl('PROJ-99', 'https://jira.company.com/browse/{id}')
|
|
143
|
+
assert.strictEqual(url2, 'https://jira.company.com/browse/PROJ-99')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('ReleaseModel.addLocaleSections extends recognized synonyms', () => {
|
|
147
|
+
ReleaseModel.addLocaleSections({
|
|
148
|
+
tasks: ['aufgaben', 'arbeitsplan'],
|
|
149
|
+
})
|
|
150
|
+
assert.ok(ReleaseModel.SECTIONS.tasks.includes('aufgaben'))
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -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
|
/**
|