kaloko 0.5.1 → 0.6.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 +103 -0
- package/package.json +1 -1
- package/schemas/changelog.v1.json +83 -0
- package/schemas/config.v1.json +46 -0
- package/schemas/run.v1.json +27 -0
- package/schemas/scenario.v1.json +14 -1
- package/skills/kaloko/SKILL.md +39 -4
- package/skills/kaloko/reference/design.md +55 -0
- package/skills/kaloko/reference/docs.md +246 -0
- package/skills/kaloko/reference/integrations.md +67 -0
- package/skills/kaloko/reference/mcp.md +10 -1
- package/src/cli.mjs +304 -10
- package/src/lib/changelog-core.mjs +323 -0
- package/src/lib/changelog.mjs +399 -0
- package/src/lib/ci.mjs +71 -0
- package/src/lib/design-cli.mjs +22 -2
- package/src/lib/design-notes.mjs +191 -0
- package/src/lib/docs-core.mjs +450 -0
- package/src/lib/docs-export.mjs +271 -0
- package/src/lib/docs.mjs +221 -0
- package/src/lib/doctor.mjs +6 -1
- package/src/lib/figma.mjs +584 -0
- package/src/lib/git.mjs +23 -3
- package/src/lib/integrations-cli.mjs +211 -0
- package/src/lib/run.mjs +2 -2
- package/src/lib/screens-cli.mjs +36 -0
- package/src/lib/service.mjs +20 -1
- package/viewer/index.html +266 -30
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// The visual changelog (docs/DOCS-CHANGELOG.md, phase 1), the parts the CLI and the service share: step references
|
|
2
|
+
// in Markdown, Keep a Changelog parsing and editing, commit classification and the draft skeleton. No I/O here: the
|
|
3
|
+
// worker imports this file too (worker/src/changelog.js), so it stays plain JavaScript without Node built-ins.
|
|
4
|
+
|
|
5
|
+
// ---------- step references ----------
|
|
6
|
+
|
|
7
|
+
/** Scenario and step ids as the service accepts them (design.js SCENARIO_ID). */
|
|
8
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/
|
|
9
|
+
const PARAMS = {
|
|
10
|
+
locale: /^[A-Za-z0-9_-]{1,20}$/,
|
|
11
|
+
viewport: /^[A-Za-z0-9._-]{1,80}$/,
|
|
12
|
+
version: /^[A-Za-z0-9._+-]{1,80}$/,
|
|
13
|
+
state: /^[A-Za-z0-9._-]{1,60}$/
|
|
14
|
+
}
|
|
15
|
+
export const REF_SCHEME = 'kaloko:'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=]`, or the short `kaloko:<step>` that
|
|
19
|
+
* means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev` (the
|
|
20
|
+
* release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
|
|
21
|
+
* dimension values in the capture key). Returns null for anything else.
|
|
22
|
+
*/
|
|
23
|
+
export function parseStepRef (ref) {
|
|
24
|
+
const s = String(ref || '').trim()
|
|
25
|
+
if (!s.startsWith(REF_SCHEME)) return null
|
|
26
|
+
const rest = s.slice(REF_SCHEME.length)
|
|
27
|
+
const qi = rest.indexOf('?')
|
|
28
|
+
const path = qi < 0 ? rest : rest.slice(0, qi)
|
|
29
|
+
let parts
|
|
30
|
+
try { parts = path.split('/').map((p) => decodeURIComponent(p)) } catch { return null }
|
|
31
|
+
if (!parts.length || parts.length > 2 || parts.some((p) => !ID.test(p))) return null
|
|
32
|
+
const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null }
|
|
33
|
+
const q = new URLSearchParams(qi < 0 ? '' : rest.slice(qi + 1))
|
|
34
|
+
for (const [k, v] of q) {
|
|
35
|
+
if (!(k in PARAMS)) return null
|
|
36
|
+
if (!PARAMS[k].test(v)) return null
|
|
37
|
+
out[k] = v
|
|
38
|
+
}
|
|
39
|
+
if (out.version === 'latest') out.version = null
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The reference text of a parsed (or hand-built) reference; parameters in a fixed order. */
|
|
44
|
+
export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null }) {
|
|
45
|
+
const path = `${scenario ? `${encodeURIComponent(scenario)}/` : ''}${encodeURIComponent(step)}`
|
|
46
|
+
const q = Object.entries({ locale, viewport, version, state }).filter(([, v]) => v != null && v !== '')
|
|
47
|
+
return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every image with a step reference in a Markdown text: { alt, ref, parsed, index }; broken ones have parsed null. */
|
|
51
|
+
export function findStepRefs (markdown) {
|
|
52
|
+
const out = []
|
|
53
|
+
const re = /!\[([^\]]*)\]\((kaloko:[^)\s]+)\)/g
|
|
54
|
+
let m
|
|
55
|
+
while ((m = re.exec(String(markdown || '')))) out.push({ alt: m[1], ref: m[2], parsed: parseStepRef(m[2]), index: m.index })
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------- versions ----------
|
|
60
|
+
|
|
61
|
+
/** "v2.4.0" → "2.4.0"; anything else stays as it is (trimmed). */
|
|
62
|
+
export const cleanVersion = (v) => String(v ?? '').trim().replace(/^v(?=\d)/, '')
|
|
63
|
+
|
|
64
|
+
/** Semantic Versioning order (prereleases before their release); versions that are not semver sort by text. */
|
|
65
|
+
export function compareVersions (a, b) {
|
|
66
|
+
const p = (v) => { const m = cleanVersion(v).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/); return m ? { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null } : null }
|
|
67
|
+
const x = p(a); const y = p(b)
|
|
68
|
+
if (!x || !y) return String(a).localeCompare(String(b))
|
|
69
|
+
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] - y.n[i]
|
|
70
|
+
if (x.pre === y.pre) return 0
|
|
71
|
+
if (!x.pre) return 1
|
|
72
|
+
if (!y.pre) return -1
|
|
73
|
+
return x.pre.localeCompare(y.pre, 'en', { numeric: true })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The git tag of a version for a tag pattern ("v*" → "v2.4.0", "shop-v*" → "shop-v2.4.0"). */
|
|
77
|
+
export const tagFor = (pattern, version) => String(pattern || 'v*').replace('*', cleanVersion(version))
|
|
78
|
+
/** The version a tag names under a pattern, or null when the tag does not match it. */
|
|
79
|
+
export function versionOfTag (pattern, tag) {
|
|
80
|
+
const [pre, post = ''] = String(pattern || 'v*').split('*')
|
|
81
|
+
const t = String(tag || '')
|
|
82
|
+
if (!t.startsWith(pre) || !t.endsWith(post) || t.length <= pre.length + post.length) return null
|
|
83
|
+
return cleanVersion(t.slice(pre.length, t.length - post.length))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------- Keep a Changelog ----------
|
|
87
|
+
|
|
88
|
+
export const GROUPS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
|
|
89
|
+
const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed' }
|
|
90
|
+
/** A group heading as written by people or release tools ("Bug Fixes", "Minor Changes") in Keep a Changelog terms. */
|
|
91
|
+
export const groupName = (h) => GROUP_ALIASES[String(h || '').trim().toLowerCase()] || String(h || '').trim()
|
|
92
|
+
|
|
93
|
+
export const CHANGELOG_HEAD = `# Changelog
|
|
94
|
+
|
|
95
|
+
All notable changes for the people who use this product. The format follows
|
|
96
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow
|
|
97
|
+
[Semantic Versioning](https://semver.org/). Pictures are step references
|
|
98
|
+
(\`\`) that Kaloko shows from the accepted runs.
|
|
99
|
+
`
|
|
100
|
+
export const CHANGELOG_SKELETON = `${CHANGELOG_HEAD}
|
|
101
|
+
## [Unreleased]
|
|
102
|
+
`
|
|
103
|
+
|
|
104
|
+
const RELEASE_HEADING = /^##\s+(.+?)\s*$/
|
|
105
|
+
const DATE = /(\d{4}-\d{2}-\d{2})/
|
|
106
|
+
|
|
107
|
+
/** "## [2.4.0] - 2026-10-12", "## [1.2.0](https://…compare…) (2026-10-12)", "## 1.2.0", "## [Unreleased]". */
|
|
108
|
+
export function parseHeading (line) {
|
|
109
|
+
const m = String(line).match(RELEASE_HEADING)
|
|
110
|
+
if (!m || /^#/.test(m[1])) return null
|
|
111
|
+
const text = m[1]
|
|
112
|
+
const bracket = text.match(/^\[([^\]]+)\]/)
|
|
113
|
+
const label = (bracket ? bracket[1] : text.split(/\s+/)[0]).trim()
|
|
114
|
+
if (/^unreleased$/i.test(label)) return { unreleased: true, version: null, date: null }
|
|
115
|
+
const version = cleanVersion(label)
|
|
116
|
+
if (!/^\d/.test(version)) return null
|
|
117
|
+
const rest = text.slice(bracket ? bracket[0].length : label.length)
|
|
118
|
+
return { unreleased: false, version, date: (rest.match(DATE) || [])[1] || null }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List items of a group body: each item keeps its continuation lines (pictures under it), without the "- ". */
|
|
122
|
+
export function listItems (body) {
|
|
123
|
+
const items = []
|
|
124
|
+
let cur = null
|
|
125
|
+
for (const line of String(body || '').split('\n')) {
|
|
126
|
+
const m = line.match(/^[-*+]\s+(.*)$/)
|
|
127
|
+
if (m) { if (cur) items.push(cur.trimEnd()); cur = m[1]; continue }
|
|
128
|
+
if (cur != null && (/^\s+\S/.test(line) || line.trim() === '')) { cur += `\n${line.replace(/^ {1,4}/, '')}`; continue }
|
|
129
|
+
if (cur != null) { items.push(cur.trimEnd()); cur = null }
|
|
130
|
+
}
|
|
131
|
+
if (cur) items.push(cur.trimEnd())
|
|
132
|
+
return items.map((x) => x.replace(/\n+$/, '')).filter(Boolean)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Groups of a release body: [{ name, heading, items }] plus text before the first group (notes). */
|
|
136
|
+
export function parseEntry (body) {
|
|
137
|
+
const groups = []; const notes = []
|
|
138
|
+
let cur = null
|
|
139
|
+
for (const line of String(body || '').replace(/\r/g, '').split('\n')) {
|
|
140
|
+
const h = line.match(/^###\s+(.+?)\s*$/)
|
|
141
|
+
if (h) { cur = { name: groupName(h[1]), heading: h[1].trim(), lines: [] }; groups.push(cur); continue }
|
|
142
|
+
if (cur) cur.lines.push(line); else notes.push(line)
|
|
143
|
+
}
|
|
144
|
+
return { notes: notes.join('\n').trim(), groups: groups.map((g) => ({ name: g.name, heading: g.heading, items: listItems(g.lines.join('\n')) })).filter((g) => g.items.length || g.name) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A changelog file: { head (text before the first release heading), sections: [{ unreleased, version, date, heading,
|
|
149
|
+
* body, start, end }] (line numbers, end exclusive), links (the reference definitions at the bottom, by lower-case
|
|
150
|
+
* label), linksStart }.
|
|
151
|
+
*/
|
|
152
|
+
export function parseChangelog (text) {
|
|
153
|
+
const lines = String(text || '').replace(/\r/g, '').split('\n')
|
|
154
|
+
let linksStart = lines.length
|
|
155
|
+
while (linksStart > 0 && (/^\[[^\]]+\]:\s*\S+/.test(lines[linksStart - 1]) || lines[linksStart - 1].trim() === '')) linksStart--
|
|
156
|
+
if (!lines.slice(linksStart).some((l) => /^\[[^\]]+\]:/.test(l))) linksStart = lines.length
|
|
157
|
+
const links = {}
|
|
158
|
+
for (const l of lines.slice(linksStart)) { const m = l.match(/^\[([^\]]+)\]:\s*(\S+)/); if (m) links[m[1].toLowerCase()] = m[2] }
|
|
159
|
+
const sections = []
|
|
160
|
+
for (let i = 0; i < linksStart; i++) {
|
|
161
|
+
const h = /^##\s/.test(lines[i]) ? parseHeading(lines[i]) : null
|
|
162
|
+
if (!h) continue
|
|
163
|
+
if (sections.length) sections[sections.length - 1].end = i
|
|
164
|
+
sections.push({ ...h, heading: lines[i], start: i, end: linksStart })
|
|
165
|
+
}
|
|
166
|
+
for (const s of sections) s.body = lines.slice(s.start + 1, s.end).join('\n').trim()
|
|
167
|
+
const head = lines.slice(0, sections.length ? sections[0].start : linksStart).join('\n').trimEnd()
|
|
168
|
+
return { head, sections, links, linksStart, lines }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The section of a version (or the Unreleased one for null), or null. */
|
|
172
|
+
export function sectionOf (text, version) {
|
|
173
|
+
const { sections } = parseChangelog(text)
|
|
174
|
+
const v = version == null ? null : cleanVersion(version)
|
|
175
|
+
return sections.find((s) => v == null ? s.unreleased : !s.unreleased && s.version === v) || null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The newest released version written in the file (null when none). */
|
|
179
|
+
export function latestVersionIn (text) {
|
|
180
|
+
const v = parseChangelog(text).sections.filter((s) => !s.unreleased).map((s) => s.version)
|
|
181
|
+
return v.sort(compareVersions).pop() || null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const norm = (item) => String(item).split('\n')[0].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
185
|
+
|
|
186
|
+
/** Markdown of groups ({ Added: [items] } in Keep a Changelog order; other names after them). */
|
|
187
|
+
export function groupsMarkdown (groups) {
|
|
188
|
+
const names = [...GROUPS.filter((g) => groups[g]?.length), ...Object.keys(groups).filter((g) => !GROUPS.includes(g) && groups[g]?.length)]
|
|
189
|
+
return names.map((g) => `### ${g}\n\n${groups[g].map((it) => `- ${String(it).split('\n').map((l, i) => i && l ? ` ${l}` : l).join('\n')}`).join('\n')}`).join('\n\n')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Puts draft items into the Unreleased section of a changelog: items whose first line is already there are left
|
|
194
|
+
* out, groups that exist get the new items at their end, missing groups are added in Keep a Changelog order. A file
|
|
195
|
+
* without an Unreleased section gets one above the newest release; an empty file gets the skeleton.
|
|
196
|
+
* Returns { text, added } (the number of new items).
|
|
197
|
+
*/
|
|
198
|
+
export function applyDraft (text, groups) {
|
|
199
|
+
let src = String(text || '')
|
|
200
|
+
if (!src.trim()) src = CHANGELOG_SKELETON
|
|
201
|
+
let parsed = parseChangelog(src)
|
|
202
|
+
let un = parsed.sections.find((s) => s.unreleased)
|
|
203
|
+
if (!un) {
|
|
204
|
+
const lines = parsed.lines
|
|
205
|
+
const at = parsed.sections.length ? parsed.sections[0].start : parsed.linksStart
|
|
206
|
+
const before = lines.slice(0, at); while (before.length && before[before.length - 1].trim() === '') before.pop()
|
|
207
|
+
src = [...before, '', '## [Unreleased]', '', ...lines.slice(at)].join('\n')
|
|
208
|
+
parsed = parseChangelog(src)
|
|
209
|
+
un = parsed.sections.find((s) => s.unreleased)
|
|
210
|
+
}
|
|
211
|
+
const existing = parseEntry(un.body)
|
|
212
|
+
const seen = new Set(parsed.sections.flatMap((s) => parseEntry(s.body).groups.flatMap((g) => g.items.map(norm))))
|
|
213
|
+
const merged = new Map(existing.groups.map((g) => [g.name, { heading: g.heading, items: [...g.items] }]))
|
|
214
|
+
let added = 0
|
|
215
|
+
for (const [name, items] of Object.entries(groups)) {
|
|
216
|
+
for (const it of items || []) {
|
|
217
|
+
if (seen.has(norm(it))) continue
|
|
218
|
+
seen.add(norm(it))
|
|
219
|
+
if (!merged.has(name)) merged.set(name, { heading: name, items: [] })
|
|
220
|
+
merged.get(name).items.push(it); added++
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const order = [...GROUPS.filter((g) => merged.has(g)), ...[...merged.keys()].filter((g) => !GROUPS.includes(g))]
|
|
224
|
+
const body = [existing.notes, ...order.map((g) => { const x = merged.get(g); return groupsMarkdown({ [g]: x.items }).replace(/^### [^\n]+/, `### ${x.heading}`) })].filter(Boolean).join('\n\n')
|
|
225
|
+
const lines = parsed.lines
|
|
226
|
+
const out = [...lines.slice(0, un.start + 1), '', ...(body ? [body, ''] : []), ...lines.slice(un.end)]
|
|
227
|
+
return { text: out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '\n'), added }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Turns the Unreleased section into the release `version` of `date` and opens a new, empty Unreleased section above
|
|
232
|
+
* it. Compare links at the bottom ([unreleased]: …/compare/v1...HEAD) move along. Returns { text, changed, empty }.
|
|
233
|
+
*/
|
|
234
|
+
export function finalizeRelease (text, version, date, { tagPattern = 'v*' } = {}) {
|
|
235
|
+
const v = cleanVersion(version)
|
|
236
|
+
const parsed = parseChangelog(text)
|
|
237
|
+
if (parsed.sections.some((s) => !s.unreleased && s.version === v)) return { text, changed: false, empty: false }
|
|
238
|
+
const un = parsed.sections.find((s) => s.unreleased)
|
|
239
|
+
if (!un) return { text, changed: false, empty: true }
|
|
240
|
+
const lines = [...parsed.lines]
|
|
241
|
+
lines.splice(un.start, 1, '## [Unreleased]', '', `## [${v}] - ${date}`)
|
|
242
|
+
let out = lines.join('\n')
|
|
243
|
+
const link = parsed.links.unreleased
|
|
244
|
+
const m = link && link.match(/^(.*\/compare\/)(.+)\.\.\.HEAD$/)
|
|
245
|
+
if (m) {
|
|
246
|
+
const tag = tagFor(tagPattern, v)
|
|
247
|
+
out = out.replace(/^\[unreleased\]:\s*\S+$/im, `[unreleased]: ${m[1]}${tag}...HEAD\n[${v}]: ${m[1]}${m[2]}...${tag}`)
|
|
248
|
+
}
|
|
249
|
+
return { text: out, changed: true, empty: !un.body.trim() }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------- commits and the draft ----------
|
|
253
|
+
|
|
254
|
+
const CONVENTIONAL = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/
|
|
255
|
+
const INTERNAL = new Set(['docs', 'test', 'tests', 'chore', 'ci', 'build', 'style', 'refactor', 'release', 'wip', 'deps'])
|
|
256
|
+
const TYPE_GROUP = { feat: 'Added', feature: 'Added', add: 'Added', fix: 'Fixed', bugfix: 'Fixed', hotfix: 'Fixed', perf: 'Changed', revert: 'Changed', remove: 'Removed', deprecate: 'Deprecated', security: 'Security', sec: 'Security' }
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Where a commit or pull request title goes: { group, text, internal, breaking, pr }. Conventional Commits when the
|
|
260
|
+
* title uses them; otherwise the first verb decides (add → Added, fix → Fixed, remove → Removed, else Changed).
|
|
261
|
+
* Documentation, tests, chores, CI, refactors, merges and releases are internal.
|
|
262
|
+
*/
|
|
263
|
+
export function classifyCommit (subject, body = '') {
|
|
264
|
+
let s = String(subject || '').trim()
|
|
265
|
+
let pr = null
|
|
266
|
+
const sq = s.match(/\s*\(#(\d+)\)\s*$/)
|
|
267
|
+
if (sq) { pr = Number(sq[1]); s = s.slice(0, sq.index).trim() }
|
|
268
|
+
const breaking = /BREAKING[ -]CHANGE/.test(body)
|
|
269
|
+
if (/^Merge\b|^Revert "|^v?\d+\.\d+\.\d+$|\[skip changelog\]|\[no changelog\]/i.test(s)) return { group: null, text: s, internal: true, breaking, pr }
|
|
270
|
+
const m = s.match(CONVENTIONAL)
|
|
271
|
+
if (m) {
|
|
272
|
+
const type = m[1].toLowerCase()
|
|
273
|
+
const text = sentence(m[4])
|
|
274
|
+
if (INTERNAL.has(type) && !m[3] && !breaking) return { group: null, text, internal: true, breaking: false, pr, type, scope: m[2] || null }
|
|
275
|
+
const group = TYPE_GROUP[type] || (/^(remove|drop|delete)\b/i.test(m[4]) ? 'Removed' : 'Changed')
|
|
276
|
+
return { group: m[3] || breaking ? (group === 'Added' ? 'Added' : 'Changed') : group, text, internal: false, breaking: !!m[3] || breaking, pr, type, scope: m[2] || null }
|
|
277
|
+
}
|
|
278
|
+
const verb = s.toLowerCase()
|
|
279
|
+
const group = /^(add|adds|added|new|introduce|support|allow|enable)\b/.test(verb) ? 'Added'
|
|
280
|
+
: /^(fix|fixes|fixed|correct|repair|resolve|prevent)\b/.test(verb) ? 'Fixed'
|
|
281
|
+
: /^(remove|removes|removed|drop|delete)\b/.test(verb) ? 'Removed'
|
|
282
|
+
: /^(deprecate)\b/.test(verb) ? 'Deprecated' : 'Changed'
|
|
283
|
+
return { group, text: sentence(s), internal: false, breaking, pr }
|
|
284
|
+
}
|
|
285
|
+
const sentence = (t) => { const x = String(t || '').trim().replace(/\.$/, ''); return x ? x[0].toUpperCase() + x.slice(1) : x }
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The draft entry: items grouped Added / Changed / Fixed / Removed (Deprecated, Security when present).
|
|
289
|
+
*
|
|
290
|
+
* commits [{ subject, body, pr: { number, url } | null }] — merged pull requests or commits since the last release
|
|
291
|
+
* changes [{ scenario, title, steps: [{ step, title, reason: new|changed|removed, pr: number|null }] }] — what the
|
|
292
|
+
* accepted runs since the last release show differently (from the service)
|
|
293
|
+
* Pictures are step references: a changed step gets before and after (`?version=prev` is the previous release), a
|
|
294
|
+
* new step one picture. A step whose run came from a pull request goes under that pull request's item.
|
|
295
|
+
* Returns { groups, internal (titles left out), markdown }.
|
|
296
|
+
*/
|
|
297
|
+
export function draftEntry ({ commits = [], changes = [], scenarioLabel = (s) => s.title || s.scenario } = {}) {
|
|
298
|
+
const groups = {}
|
|
299
|
+
const internal = []
|
|
300
|
+
const byPr = new Map()
|
|
301
|
+
const add = (g, item) => { (groups[g] ||= []).push(item); return groups[g].length - 1 }
|
|
302
|
+
for (const c of commits) {
|
|
303
|
+
const k = classifyCommit(c.subject, c.body)
|
|
304
|
+
if (k.internal) { internal.push(c.subject); continue }
|
|
305
|
+
const number = c.pr?.number || k.pr
|
|
306
|
+
const url = c.pr?.url || null
|
|
307
|
+
const text = `${k.text}${k.breaking ? ' (breaking: say what people have to do)' : ''}${number ? ` (${url ? `[#${number}](${url})` : `#${number}`})` : ''}`
|
|
308
|
+
const i = add(k.group, text)
|
|
309
|
+
if (number && !byPr.has(number)) byPr.set(number, { group: k.group, i })
|
|
310
|
+
}
|
|
311
|
+
for (const sc of changes) {
|
|
312
|
+
for (const st of sc.steps || []) {
|
|
313
|
+
const ref = (version) => formatStepRef({ scenario: sc.scenario, step: st.step, version })
|
|
314
|
+
const title = st.title || st.step
|
|
315
|
+
const pics = st.reason === 'new' ? `})` : st.reason === 'removed' ? `})` : `}) })`
|
|
316
|
+
const home = st.pr && byPr.get(st.pr)
|
|
317
|
+
if (home) { groups[home.group][home.i] += `\n${pics}`; continue }
|
|
318
|
+
const g = st.reason === 'new' ? 'Added' : st.reason === 'removed' ? 'Removed' : 'Changed'
|
|
319
|
+
add(g, `**${title}** in ${scenarioLabel(sc)} <!-- say what a person notices here -->\n${pics}`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { groups, internal, markdown: groupsMarkdown(groups) }
|
|
323
|
+
}
|