kaloko 0.5.2 → 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.
@@ -0,0 +1,584 @@
1
+ // Figma import (batch G, docs/BACKLOG-IDEAS.md idea 1): frames of a Figma file become the steps of a design folder,
2
+ // so a design version made from them is the design reference implementation runs are checked against (pixels with
3
+ // design_pixels, styles and tokens with kaloko drift). One way: Figma → Kaloko; prototype links, components and
4
+ // auto layout are not carried over. Each step page shows the frame's PNG (1× and 2×) and, under it, the frame's text
5
+ // layers as real text with Figma's typography and colors (var(--token) where the layer uses a variable or style),
6
+ // so the step spec has text, roles and tokens to compare. Variables (or, without them, the color and text styles the
7
+ // frames use) become tokens.json (W3C DTCG). Teams without an API token import a folder exported from Figma (PNG/SVG).
8
+ import { readFile, writeFile, mkdir, readdir, rm, stat } from 'node:fs/promises'
9
+ import { join, relative, extname, basename } from 'node:path'
10
+ import yaml from 'js-yaml'
11
+ import { UserError } from './util.mjs'
12
+ import { validateTokens, flattenTokens } from './tokens.mjs'
13
+ import { TOKENS_FILE } from './design-core.mjs'
14
+
15
+ export const GENERATOR = 'kaloko figma import'
16
+ export const figmaApi = () => (process.env.KALOKO_FIGMA_API || 'https://api.figma.com').replace(/\/+$/, '')
17
+ const FRAME_TYPES = new Set(['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE', 'GROUP'])
18
+ const IMAGE_RE = /^figma(-[A-Za-z0-9_-]+)?(\.[23]x)?\.(png|svg|jpg|jpeg|webp)$/
19
+
20
+ // ---------------------------------------------------------------- references and names
21
+
22
+ /** A Figma file reference: { key, nodeIds } from a file URL (design/file/proto, ?node-id=1-2) or a bare key. */
23
+ export function parseFigmaRef (input) {
24
+ const s = String(input || '').trim()
25
+ let m = s.match(/^https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto|board)\/([A-Za-z0-9]{10,})(?:\/[^?#]*)?(?:\?([^#]*))?/)
26
+ if (m) {
27
+ const params = new URLSearchParams(m[2] || '')
28
+ const node = params.get('node-id')
29
+ return { key: m[1], nodeIds: node ? node.split(',').map((n) => n.replace(/-/g, ':')) : [] }
30
+ }
31
+ m = s.match(/^[A-Za-z0-9]{10,}$/)
32
+ return m ? { key: s, nodeIds: [] } : null
33
+ }
34
+
35
+ /** A name as a slug: lower case, diacritics folded, words joined by dashes. */
36
+ export const slug = (s) => String(s || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
37
+ /** A token path segment: as in the name, without the characters DTCG names may not have. */
38
+ const tokenSeg = (s) => String(s).trim().toLowerCase().replace(/[{}.$]/g, '').replace(/\s+/g, '-') || '_'
39
+ const segmentsOf = (name) => String(name || '').split(/\s*[/|@·—–]\s*|\s+-\s+|\s*[[\]()]\s*/).map(slug).filter(Boolean)
40
+
41
+ // ---------------------------------------------------------------- the REST API
42
+
43
+ /** A small client of the Figma REST API (X-Figma-Token); `fetch` can be replaced in tests. */
44
+ export function figmaClient ({ token, fetch: f = globalThis.fetch, base = figmaApi(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
45
+ if (!token) throw new UserError('Figma needs a personal access token: create one in Figma (Settings → Security → Personal access tokens, scope file content read-only) and put it in FIGMA_TOKEN, or name the variable with --token-env. Without a token, export the frames as PNG and import the folder.')
46
+ const get = async (path, { optional = false } = {}) => {
47
+ for (let attempt = 0; ; attempt++) {
48
+ const res = await f(`${base}${path}`, { headers: { 'X-Figma-Token': token } })
49
+ if (res.status === 429 && attempt < 3) { await sleep(Math.min(30, Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000); continue }
50
+ if (res.ok) return res.json()
51
+ if (optional && (res.status === 403 || res.status === 404)) return null
52
+ const body = await res.text().catch(() => '')
53
+ if (res.status === 403) throw new UserError(`Figma refused the token (403)${/scope|permission/i.test(body) ? `: ${body.slice(0, 160)}` : ''}. Check that it may read this file.`)
54
+ if (res.status === 404) throw new UserError('Figma file not found (404): check the link or key and that the token\'s account can open it.')
55
+ throw new UserError(`Figma API ${path.split('?')[0]}: HTTP ${res.status} ${body.slice(0, 160)}`)
56
+ }
57
+ }
58
+ const ids = (list) => list.map(encodeURIComponent).join(',')
59
+ return {
60
+ file: (key, depth = 3) => get(`/v1/files/${key}?depth=${depth}`),
61
+ nodes: (key, list) => get(`/v1/files/${key}/nodes?ids=${ids(list)}`),
62
+ images: (key, list, scale = 1, format = 'png') => get(`/v1/images/${key}?ids=${ids(list)}&scale=${scale}&format=${format}`),
63
+ variables: (key) => get(`/v1/files/${key}/variables/local`, { optional: true }),
64
+ async download (url) {
65
+ const res = await f(url)
66
+ if (!res.ok) throw new UserError(`Downloading a frame image failed: HTTP ${res.status}.`)
67
+ return Buffer.from(await res.arrayBuffer())
68
+ }
69
+ }
70
+ }
71
+
72
+ // ---------------------------------------------------------------- frames and steps
73
+
74
+ /** Top-level frames of the file's pages (frames inside sections too): { id, name, page, width, height }. */
75
+ export function framesOf (document, { page = null } = {}) {
76
+ const out = []
77
+ const box = (n) => n.absoluteBoundingBox || n.absoluteRenderBounds || { width: n.size?.x, height: n.size?.y }
78
+ const add = (n, pageName) => {
79
+ if (n.visible === false) return
80
+ if (n.type === 'SECTION') { for (const c of n.children || []) add(c, pageName); return }
81
+ if (!FRAME_TYPES.has(n.type) || n.type === 'GROUP') return
82
+ const b = box(n)
83
+ out.push({ id: n.id, name: n.name, page: pageName, width: Math.round(b?.width || 0), height: Math.round(b?.height || 0) })
84
+ }
85
+ for (const p of document?.children || []) {
86
+ if (p.type !== 'CANVAS') { add(p, null); continue }
87
+ if (page && slug(p.name) !== slug(page) && p.id !== page) continue
88
+ for (const n of p.children || []) add(n, p.name)
89
+ }
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * Which frame is which step: an explicit map (frame name or id → step, or { step, viewport, locale }) first, then the
95
+ * name: a segment of it ("Checkout / Payment — mobile", "payment@mobile", "Payment [cs]") that is a step id or title,
96
+ * a viewport name and a locale. A step with several frames and no viewport in their names takes them by width.
97
+ * Returns { assigned: [{ frame, step, viewport, locale }], unmatched: [frame], missing: [step id], duplicates: [frame] }.
98
+ */
99
+ export function mapFrames (frames, { steps, viewports = {}, locales = [], map = null }) {
100
+ const bySlug = new Map()
101
+ for (const s of steps) { bySlug.set(slug(s.id), s.id); const t = typeof s.title === 'string' ? s.title : s.title && typeof s.title === 'object' ? Object.values(s.title) : []; for (const x of [].concat(t)) if (x && !bySlug.has(slug(x))) bySlug.set(slug(x), s.id) }
102
+ const vpNames = Object.keys(viewports)
103
+ const assigned = [], unmatched = [], duplicates = []
104
+ const explicit = (f) => { const e = map?.[f.id] ?? map?.[f.name]; return e == null ? null : typeof e === 'string' ? { step: e } : e }
105
+ for (const f of frames) {
106
+ const e = explicit(f)
107
+ let step = null, viewport = null, locale = null
108
+ if (e) {
109
+ if (e.skip || e.step === null || e.step === false) continue
110
+ step = steps.some((s) => s.id === e.step) ? e.step : null
111
+ viewport = e.viewport || null
112
+ locale = e.locale || null
113
+ if (!step) { unmatched.push({ ...f, reason: `map names step ${e.step}, which the scenario does not have` }); continue }
114
+ } else {
115
+ const segs = segmentsOf(f.name)
116
+ const words = slug(f.name).split('-')
117
+ step = bySlug.get(slug(f.name)) || segs.map((s) => bySlug.get(s)).find(Boolean) || null
118
+ if (!step) { const hit = [...bySlug.keys()].filter((k) => slug(f.name).startsWith(`${k}-`) || slug(f.name).endsWith(`-${k}`)).sort((a, b) => b.length - a.length)[0]; step = hit ? bySlug.get(hit) : null }
119
+ viewport = vpNames.find((v) => segs.includes(slug(v)) || words.includes(slug(v))) || null
120
+ locale = locales.find((l) => l !== 'default' && (segs.includes(slug(l)) || words.includes(slug(l)))) || null
121
+ if (!step) { unmatched.push(f); continue }
122
+ }
123
+ if (viewport && !viewports[viewport]) viewport = null
124
+ if (locale && !locales.includes(locale)) locale = null
125
+ assigned.push({ frame: f, step, viewport, locale })
126
+ }
127
+ // several frames of one step without a viewport in their names: each takes the free viewport of its width
128
+ const groups = new Map()
129
+ for (const a of assigned) { const k = `${a.step}|${a.locale || ''}`; groups.set(k, [...(groups.get(k) || []), a]) }
130
+ for (const list of groups.values()) {
131
+ if (list.length < 2) continue
132
+ for (const a of list.filter((x) => !x.viewport)) {
133
+ const taken = new Set(list.map((x) => x.viewport).filter(Boolean))
134
+ const fit = vpNames.filter((v) => !taken.has(v)).sort((x, y) => Math.abs(viewports[x][0] - a.frame.width) - Math.abs(viewports[y][0] - a.frame.width))[0]
135
+ if (fit && Math.abs(viewports[fit][0] - a.frame.width) <= Math.max(8, viewports[fit][0] * 0.05)) a.viewport = fit
136
+ }
137
+ }
138
+ // one frame per step, locale and viewport: later ones are left out
139
+ const seen = new Set()
140
+ const out = []
141
+ for (const a of assigned) { const k = `${a.step}|${a.locale || ''}|${a.viewport || ''}`; if (seen.has(k)) { duplicates.push(a.frame); continue } seen.add(k); out.push(a) }
142
+ const missing = steps.map((s) => s.id).filter((id) => !out.some((a) => a.step === id))
143
+ return { assigned: out, unmatched, missing, duplicates }
144
+ }
145
+
146
+ // ---------------------------------------------------------------- colors, texts, tokens
147
+
148
+ const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n * 255))).toString(16).padStart(2, '0')
149
+ /** A Figma color ({ r, g, b, a } in 0..1, times the paint's opacity) as #rrggbb or #rrggbbaa. */
150
+ export function colorHex (c, opacity = 1) {
151
+ if (!c) return null
152
+ const a = (c.a ?? 1) * (opacity ?? 1)
153
+ return `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}${a < 0.999 ? hex2(a) : ''}`
154
+ }
155
+ const solid = (paints) => (paints || []).find((p) => p.type === 'SOLID' && p.visible !== false)
156
+ export const fillOf = (node) => { const p = solid(node?.fills); return p ? colorHex(p.color, p.opacity) : null }
157
+
158
+ const WEIGHT_NAMES = { thin: 100, hairline: 100, extralight: 200, ultralight: 200, light: 300, regular: 400, normal: 400, book: 400, medium: 500, semibold: 600, demibold: 600, bold: 700, extrabold: 800, ultrabold: 800, black: 900, heavy: 900 }
159
+ const weightOf = (st) => st?.fontWeight || WEIGHT_NAMES[String(st?.fontPostScriptName || st?.fontStyle || '').toLowerCase().replace(/[^a-z]/g, '').replace(/italic$/, '')] || 400
160
+ const px = (n) => `${Math.round(n * 100) / 100}px`
161
+
162
+ /** A text style as DTCG typography: family, size, weight, line height (a ratio) and letter spacing. */
163
+ function typographyOf (st) {
164
+ if (!st?.fontFamily) return null
165
+ const size = st.fontSize || 16
166
+ const lh = st.lineHeightPx ? Math.round((st.lineHeightPx / size) * 1000) / 1000 : st.lineHeightPercentFontSize ? Math.round(st.lineHeightPercentFontSize * 10) / 1000 : null
167
+ return { fontFamily: st.fontFamily, fontSize: px(size), fontWeight: weightOf(st), ...(lh ? { lineHeight: lh } : {}), letterSpacing: px(st.letterSpacing || 0) }
168
+ }
169
+
170
+ /** Where a token lives in tokens.json: the variable or style name split on "/", a kind group for styles. */
171
+ function tokenPath (name, kind = null) {
172
+ const segs = String(name).split('/').map(tokenSeg).filter(Boolean)
173
+ if (kind && segs.length && !(kind === 'color' ? ['color', 'colors', 'colour'] : ['typography', 'text', 'type', 'font', 'fonts']).includes(segs[0])) segs.unshift(kind)
174
+ else if (kind && segs.length) segs[0] = kind
175
+ return segs
176
+ }
177
+ const setAt = (doc, path, token) => { let o = doc; for (const k of path.slice(0, -1)) { if (!o[k] || typeof o[k] !== 'object' || '$value' in o[k]) o[k] = {}; o = o[k] } o[path[path.length - 1]] = token }
178
+
179
+ /**
180
+ * DTCG tokens from the file's local variables: colors, numbers as px dimensions (sizes, radii, spacing, font sizes)
181
+ * or plain numbers, font families and weights; aliases as {references}. A collection with several modes adds a group
182
+ * per extra mode (dark, …) named in $extensions.qawalk.modes. Returns { doc, names: Map variable id → token name }.
183
+ */
184
+ export function tokensFromVariables (meta) {
185
+ const vars = meta?.meta?.variables || meta?.variables || {}
186
+ const cols = meta?.meta?.variableCollections || meta?.variableCollections || {}
187
+ const doc = {}
188
+ const names = new Map()
189
+ const extra = new Map() // mode name → overrides
190
+ for (const v of Object.values(vars)) names.set(v.id, tokenPath(v.name).join('.'))
191
+ const valueOf = (v, raw) => {
192
+ if (raw && typeof raw === 'object' && raw.type === 'VARIABLE_ALIAS') return names.has(raw.id) ? `{${names.get(raw.id)}}` : null
193
+ const scopes = v.scopes || []
194
+ if (v.resolvedType === 'COLOR') return { $type: 'color', $value: colorHex(raw) }
195
+ if (v.resolvedType === 'FLOAT') {
196
+ if (scopes.includes('FONT_WEIGHT')) return { $type: 'fontWeight', $value: raw }
197
+ if (scopes.some((s) => /RADIUS|GAP|WIDTH_HEIGHT|FONT_SIZE|LINE_HEIGHT|LETTER_SPACING|STROKE|EFFECT_FLOAT|PARAGRAPH/.test(s)) || scopes.includes('ALL_SCOPES') || /space|spacing|size|radius|gap|width|height|padding|margin/i.test(v.name)) return { $type: 'dimension', $value: px(raw) }
198
+ return { $type: 'number', $value: raw }
199
+ }
200
+ if (v.resolvedType === 'STRING' && (scopes.includes('FONT_FAMILY') || /font|family/i.test(v.name))) return { $type: 'fontFamily', $value: raw }
201
+ return null
202
+ }
203
+ for (const v of Object.values(vars)) {
204
+ const col = cols[v.variableCollectionId]
205
+ const modes = col?.modes || []
206
+ const def = col?.defaultModeId || modes[0]?.modeId || Object.keys(v.valuesByMode || {})[0]
207
+ const path = names.get(v.id).split('.')
208
+ const base = v.valuesByMode?.[def]
209
+ const t = valueOf(v, base)
210
+ if (!t) continue
211
+ const tok = typeof t === 'string' ? { $type: { COLOR: 'color', FLOAT: 'dimension', STRING: 'fontFamily' }[v.resolvedType] || undefined, $value: t } : t
212
+ if (v.description) tok.$description = v.description
213
+ setAt(doc, path, tok)
214
+ for (const m of modes) {
215
+ if (m.modeId === def || !(m.modeId in (v.valuesByMode || {}))) continue
216
+ const mt = valueOf(v, v.valuesByMode[m.modeId])
217
+ if (!mt) continue
218
+ const mname = tokenSeg(m.name)
219
+ const group = extra.get(mname) || extra.set(mname, {}).get(mname)
220
+ setAt(group, path, typeof mt === 'string' ? { $value: mt } : mt)
221
+ }
222
+ }
223
+ if (!Object.keys(doc).length) return null
224
+ const modeNames = [...extra.keys()].filter((m) => !(m in doc))
225
+ if (modeNames.length) {
226
+ const defName = tokenSeg(Object.values(cols).find((c) => (c.modes || []).length > 1)?.modes?.find((m) => m.modeId === Object.values(cols).find((c) => (c.modes || []).length > 1)?.defaultModeId)?.name || 'default')
227
+ for (const m of modeNames) doc[m] = extra.get(m)
228
+ if (!doc[defName] && !modeNames.includes(defName)) doc[defName] = {}
229
+ doc.$extensions = { 'qawalk.modes': [defName, ...modeNames].filter((m) => doc[m]) }
230
+ }
231
+ return { doc, names }
232
+ }
233
+
234
+ /**
235
+ * Tokens from the color and text styles the imported frames use (files without variables, or plans without the
236
+ * variables API): a style's value is read from a node that uses it. Returns { doc, names: Map style id → token name }.
237
+ */
238
+ export function tokensFromStyles (styles, roots) {
239
+ const doc = {}
240
+ const names = new Map()
241
+ const seen = new Set()
242
+ const visit = (n) => {
243
+ for (const [kind, id] of Object.entries(n.styles || {})) {
244
+ const st = styles?.[id]
245
+ if (!st || seen.has(id)) continue
246
+ if ((kind === 'fill' || kind === 'fills') && st.styleType === 'FILL') { const c = fillOf(n); if (c) { const p = tokenPath(st.name, 'color'); setAt(doc, p, { $type: 'color', $value: c, ...(st.description ? { $description: st.description } : {}) }); names.set(id, p.join('.')); seen.add(id) } }
247
+ if (kind === 'text' && st.styleType === 'TEXT') { const t = typographyOf(n.style); if (t) { const p = tokenPath(st.name, 'typography'); setAt(doc, p, { $type: 'typography', $value: t, ...(st.description ? { $description: st.description } : {}) }); names.set(id, p.join('.')); seen.add(id) } }
248
+ }
249
+ for (const c of n.children || []) visit(c)
250
+ }
251
+ for (const r of roots) visit(r)
252
+ return Object.keys(doc).length ? { doc, names } : null
253
+ }
254
+
255
+ const HEADING = /^h([1-6])\b|heading|headline|title/i
256
+ /**
257
+ * The visible text layers of a frame, relative to it: text, box, typography, color, and the role to give them
258
+ * (heading, button, link or paragraph) from the layer and its parents' names and the sizes on the frame.
259
+ */
260
+ export function textsOfFrame (frame, { tokenNames = new Map() } = {}) {
261
+ const origin = frame.absoluteBoundingBox || { x: 0, y: 0 }
262
+ const out = []
263
+ const walk = (n, trail) => {
264
+ if (n.visible === false || n.opacity === 0) return
265
+ const names = [...trail, n.name || '']
266
+ if (n.type === 'TEXT' && String(n.characters || '').trim()) {
267
+ const b = n.absoluteBoundingBox || { x: origin.x, y: origin.y, width: 0, height: 0 }
268
+ const st = n.style || {}
269
+ const fillPaint = solid(n.fills)
270
+ const varId = n.boundVariables?.fills?.[0]?.id
271
+ const colorToken = (varId && tokenNames.get(varId)) || (n.styles?.fill && tokenNames.get(n.styles.fill)) || null
272
+ const typeToken = (n.styles?.text && tokenNames.get(n.styles.text)) || null
273
+ const sizeVar = n.boundVariables?.fontSize?.[0]?.id || n.boundVariables?.fontSize?.id
274
+ const context = names.slice(-3).join(' ')
275
+ const role = /\b(button|btn|cta)\b/i.test(context) ? 'button' : /\blink\b/i.test(context) ? 'link' : (n.name || '').match(/^h([1-6])\b/i) ? `h${(n.name.match(/^h([1-6])\b/i))[1]}` : null
276
+ out.push({
277
+ text: String(n.characters).slice(0, 2000), x: Math.round(b.x - origin.x), y: Math.round(b.y - origin.y), w: Math.round(b.width), h: Math.round(b.height),
278
+ style: { family: st.fontFamily || null, size: st.fontSize || null, weight: weightOf(st), lineHeight: st.lineHeightPx || null, letterSpacing: st.letterSpacing || 0, italic: !!st.italic, align: (st.textAlignHorizontal || 'LEFT').toLowerCase(), transform: st.textCase === 'UPPER' ? 'uppercase' : st.textCase === 'LOWER' ? 'lowercase' : null },
279
+ color: fillPaint ? colorHex(fillPaint.color, fillPaint.opacity) : null, colorToken, typeToken, sizeToken: sizeVar ? tokenNames.get(sizeVar) || null : null, role, layer: n.name || ''
280
+ })
281
+ }
282
+ for (const c of n.children || []) walk(c, names)
283
+ }
284
+ for (const c of frame.children || []) walk(c, [frame.name || ''])
285
+ // headings: layers named like one, else the largest text on the frame when it stands out (24 px and up)
286
+ const sizes = out.map((t) => t.style.size || 0)
287
+ const top = Math.max(0, ...sizes)
288
+ for (const t of out) {
289
+ if (t.role) continue
290
+ if (HEADING.test(t.layer) && !/subtitle/i.test(t.layer)) t.role = 'h2'
291
+ if (t.style.size === top && top >= 24 && sizes.filter((s) => s === top).length <= 2) t.role = 'h1'
292
+ }
293
+ return out
294
+ }
295
+
296
+ // ---------------------------------------------------------------- the step page
297
+
298
+ const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
299
+ const cssVar = (name) => `--${name.replace(/\./g, '-')}`
300
+ const cssFont = (f) => (/^[A-Za-z0-9 -]+$/.test(f) ? `"${f}"` : `"${String(f).replace(/["\\]/g, '')}"`)
301
+
302
+ /**
303
+ * The HTML of one step: each frame (default, or for one viewport) as a block of its size, its image on top and its
304
+ * text layers underneath. `frames`: [{ viewport | null, width, height, src, src2x, background, texts }].
305
+ * `vars`: Map token name → CSS value for the custom properties the texts use.
306
+ */
307
+ export function stepHtml ({ title, lang = 'en', frames, viewports = {}, vars = new Map(), source = '' }) {
308
+ const css = [
309
+ 'html,body{margin:0;padding:0}body{background:#fff}',
310
+ '.kf{position:relative;margin:0 auto;display:none;overflow:hidden}',
311
+ '.kf>img{position:absolute;left:0;top:0;width:100%;height:100%;display:block;z-index:1}',
312
+ '.kf>.t{position:absolute;margin:0;padding:0;white-space:pre-wrap;overflow:hidden;z-index:0}',
313
+ '.kf--default{display:block}'
314
+ ]
315
+ if (vars.size) css.unshift(`:root{${[...vars].map(([n, v]) => `${cssVar(n)}:${v}`).join(';')}}`)
316
+ for (const f of frames.filter((x) => x.viewport && viewports[x.viewport])) css.push(`@media (width: ${viewports[f.viewport][0]}px){.kf--default{display:none}.kf--vp-${f.viewport.replace(/[^A-Za-z0-9_-]/g, '_')}{display:block}}`)
317
+ const block = (f) => {
318
+ const cls = f.viewport ? `kf${f.alsoDefault ? ' kf--default' : ''} kf--vp-${f.viewport.replace(/[^A-Za-z0-9_-]/g, '_')}` : 'kf kf--default'
319
+ const texts = (f.texts || []).map((t) => {
320
+ const tag = /^h[1-6]$/.test(t.role || '') ? t.role : 'p'
321
+ const role = t.role === 'button' ? ' role="button"' : t.role === 'link' ? ' role="link"' : ''
322
+ const st = t.style || {}
323
+ const color = t.colorToken && t.color ? `var(${cssVar(t.colorToken)}, ${t.color})` : t.color
324
+ const style = [`left:${t.x}px`, `top:${t.y}px`, `width:${Math.max(1, t.w)}px`, `min-height:${Math.max(1, t.h)}px`,
325
+ st.family && `font-family:${cssFont(st.family)}, sans-serif`, st.size && `font-size:${t.sizeToken ? `var(${cssVar(t.sizeToken)}, ${px(st.size)})` : px(st.size)}`, `font-weight:${st.weight || 400}`,
326
+ st.lineHeight && `line-height:${px(st.lineHeight)}`, st.letterSpacing && `letter-spacing:${px(st.letterSpacing)}`, st.italic && 'font-style:italic', st.align && st.align !== 'left' && `text-align:${st.align === 'justified' ? 'justify' : st.align}`,
327
+ st.transform && `text-transform:${st.transform}`, color && `color:${color}`].filter(Boolean).join(';')
328
+ return `<${tag} class="t"${role} style="${escHtml(style)}">${escHtml(t.text)}</${tag}>`
329
+ }).join('\n ')
330
+ const srcset = f.src2x && f.src !== f.src2x ? ` srcset="${escHtml(f.src)} 1x, ${escHtml(f.src2x)} 2x"` : ''
331
+ return ` <div class="${cls}" data-kaloko="figma-frame" style="width:${f.width}px;height:${f.height}px${f.background ? `;background:${f.background}` : ''}">
332
+ <img src="${escHtml(f.src)}"${srcset} alt="" width="${f.width}" height="${f.height}">
333
+ ${texts}
334
+ </div>`
335
+ }
336
+ // without a frame for every viewport, the widest one with a viewport also stands for the viewports without one
337
+ const fallback = frames.some((f) => !f.viewport) ? null : [...frames].sort((a, b) => b.width - a.width)[0]
338
+ const ordered = [...frames.filter((f) => !f.viewport), ...frames.filter((f) => f.viewport)].map((f) => (f === fallback ? { ...f, alsoDefault: true } : f))
339
+ return `<!doctype html>
340
+ <html lang="${escHtml(lang)}">
341
+ <head>
342
+ <meta charset="utf-8">
343
+ <meta name="viewport" content="width=device-width, initial-scale=1">
344
+ <meta name="generator" content="${GENERATOR}">
345
+ <title>${escHtml(title)}</title>
346
+ <style>
347
+ ${css.join('\n')}
348
+ </style>
349
+ </head>
350
+ <body>
351
+ <!-- ${escHtml(source)}: the frame image on top, its text layers underneath (for the step spec and drift). Re-import to update; edits here are overwritten. -->
352
+ ${ordered.map(block).join('\n')}
353
+ </body>
354
+ </html>
355
+ `
356
+ }
357
+
358
+ // ---------------------------------------------------------------- local export (no API token)
359
+
360
+ /** Width and height of a PNG, JPEG or SVG file (pixels; an SVG's width/height or viewBox). */
361
+ export function imageSize (buf, ext) {
362
+ if (ext === 'png' && buf.length > 24 && buf.readUInt32BE(12) === 0x49484452) return [buf.readUInt32BE(16), buf.readUInt32BE(20)]
363
+ if (ext === 'jpg' || ext === 'jpeg') {
364
+ for (let i = 2; i < buf.length - 9;) {
365
+ if (buf[i] !== 0xff) { i++; continue }
366
+ const marker = buf[i + 1]; const len = buf.readUInt16BE(i + 2)
367
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return [buf.readUInt16BE(i + 7), buf.readUInt16BE(i + 5)]
368
+ i += 2 + len
369
+ }
370
+ return null
371
+ }
372
+ if (ext === 'svg') {
373
+ const head = buf.toString('utf8', 0, 4000)
374
+ const tag = head.match(/<svg\b[^>]*>/i)?.[0] || ''
375
+ const num = (a) => { const m = tag.match(new RegExp(`\\s${a}\\s*=\\s*["']\\s*([\\d.]+)(px)?\\s*["']`, 'i')); return m ? Number(m[1]) : null }
376
+ const vb = tag.match(/viewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i)
377
+ const w = num('width') ?? (vb ? Number(vb[1]) : null), h = num('height') ?? (vb ? Number(vb[2]) : null)
378
+ return w && h ? [Math.round(w), Math.round(h)] : null
379
+ }
380
+ return null
381
+ }
382
+
383
+ /**
384
+ * Frames of a folder exported from Figma (File → Export, or the export panel): PNG, JPEG or SVG files named after
385
+ * their frames, @2x/@3x suffixes for scales. Returns { frames: [{ id, name, width, height, files: { 1?, 2? } }], tokens }.
386
+ */
387
+ export async function readExportFolder (dir) {
388
+ const list = []
389
+ const walk = async (d) => { for (const e of await readdir(d, { withFileTypes: true })) { if (e.name.startsWith('.')) continue; const p = join(d, e.name); if (e.isDirectory()) await walk(p); else list.push(p) } }
390
+ await walk(dir)
391
+ const byName = new Map()
392
+ let tokens = null
393
+ for (const p of list.sort()) {
394
+ const ext = extname(p).slice(1).toLowerCase()
395
+ if (ext === 'json' && /(^|[.-])tokens\.json$/i.test(basename(p))) {
396
+ try { const doc = JSON.parse(await readFile(p, 'utf8')); if (!validateTokens(doc).length) tokens = doc } catch {}
397
+ continue
398
+ }
399
+ if (!['png', 'svg', 'jpg', 'jpeg'].includes(ext)) continue
400
+ const rel = relative(dir, p).slice(0, -(ext.length + 1))
401
+ const m = rel.match(/^(.*?)(?:@([23])x)?$/)
402
+ const name = m[1].split(/[\\/]/).join(' / ')
403
+ const scale = Number(m[2] || 1)
404
+ const buf = await readFile(p)
405
+ const size = imageSize(buf, ext)
406
+ if (!size) continue
407
+ const f = byName.get(name) || byName.set(name, { id: `file:${name}`, name, width: 0, height: 0, files: {} }).get(name)
408
+ if (!f.files[scale] || ext === 'png') f.files[scale] = { path: p, ext, buf }
409
+ if (!f.width || scale === 1) { f.width = Math.round(size[0] / (ext === 'svg' ? 1 : scale)); f.height = Math.round(size[1] / (ext === 'svg' ? 1 : scale)) }
410
+ }
411
+ return { frames: [...byName.values()], tokens }
412
+ }
413
+
414
+ // ---------------------------------------------------------------- the import
415
+
416
+ const exists = async (p) => { try { await stat(p); return true } catch { return false } }
417
+ const RECORD = (dir) => join(dir, '.qawalk', 'figma.json')
418
+
419
+ /** Reads a frame map file (JSON or YAML): { "<frame name or id>": "step" | { step, viewport, locale } | { skip: true } }. */
420
+ export async function readMap (file) {
421
+ const text = await readFile(file, 'utf8').catch(() => { throw new UserError(`--map: cannot read ${file}.`) })
422
+ const doc = /\.ya?ml$/i.test(file) ? yaml.load(text) : JSON.parse(text)
423
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw new UserError('--map: an object of frame name or id → step id (or { step, viewport, locale }).')
424
+ return doc
425
+ }
426
+
427
+ /**
428
+ * Imports Figma frames into a design folder. `source`: a Figma URL, a file key or a local export folder. Writes per
429
+ * step `<step>/index.html` (or `<step>/<locale>/index.html` for locale frames), the frame images and figma.json, and
430
+ * tokens.json when the file has variables or styles (unless the folder has its own set: --tokens write overwrites,
431
+ * --tokens skip never writes). Returns a summary.
432
+ */
433
+ export async function importFigma ({ source, scenario, viewports, locales, dir, token = null, fetch = globalThis.fetch, map = null, only = null, page = null, tokensMode = 'auto', force = false, dryRun = false, say = () => {} }) {
434
+ const steps = scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external')
435
+ const local = !parseFigmaRef(source) && await exists(source) && (await stat(source)).isDirectory()
436
+ const ref = local ? null : parseFigmaRef(source)
437
+ if (!local && !ref) throw new UserError(`Not a Figma file link, key or export folder: ${source}`)
438
+ const prev = JSON.parse(await readFile(RECORD(dir), 'utf8').catch(() => 'null'))
439
+ let frames, meta = {}, tokens = null
440
+ const nodes = new Map(), images = new Map()
441
+ const client = local ? null : figmaClient({ token, fetch })
442
+ const pick = (list) => (only?.length ? list.filter((f) => only.includes(f.id) || only.includes(f.name) || only.some((o) => slug(o) === slug(f.name))) : list)
443
+ if (local) {
444
+ const exp = await readExportFolder(source)
445
+ frames = pick(exp.frames)
446
+ if (exp.tokens) tokens = { doc: exp.tokens, names: new Map(), from: 'folder' }
447
+ meta = { source: 'folder', folder: source }
448
+ } else {
449
+ const file = await client.file(ref.key)
450
+ meta = { source: 'figma', key: ref.key, name: file.name, version: file.version || null, last_modified: file.lastModified || null }
451
+ let all = framesOf(file.document, { page })
452
+ if (ref.nodeIds.length) {
453
+ const picked = await client.nodes(ref.key, ref.nodeIds)
454
+ all = []
455
+ // a link to a page, a section or one frame: the frames it names
456
+ for (const [id, n] of Object.entries(picked.nodes || {})) {
457
+ const doc = n?.document
458
+ if (!doc) continue
459
+ if (doc.type === 'CANVAS') all.push(...framesOf({ children: [doc] }))
460
+ else if (doc.type === 'SECTION') all.push(...framesOf({ children: [{ type: 'CANVAS', name: '', children: [doc] }] }))
461
+ else if (FRAME_TYPES.has(doc.type)) { const b = doc.absoluteBoundingBox || {}; all.push({ id, name: doc.name, page: null, width: Math.round(b.width || 0), height: Math.round(b.height || 0) }) }
462
+ }
463
+ }
464
+ frames = pick(all)
465
+ meta.styles = file.styles || {}
466
+ }
467
+ const mapping = mapFrames(frames, { steps, viewports, locales, map })
468
+ if (!mapping.assigned.length) throw new UserError(`No frame matches a step of ${scenario.id}. Frames: ${frames.slice(0, 12).map((f) => `"${f.name}"`).join(', ')}${frames.length > 12 ? ', …' : ''}. Name frames after step ids or titles (with a viewport like "payment / mobile"), or pass --map frames.json.`)
469
+ // hand-written steps are not overwritten without --force
470
+ const touched = [...new Set(mapping.assigned.map((a) => a.step))]
471
+ const mine = []
472
+ for (const s of touched) {
473
+ const file = join(dir, s, 'index.html')
474
+ if (await exists(file) && !(await readFile(file, 'utf8')).includes(`content="${GENERATOR}"`)) mine.push(s)
475
+ }
476
+ if (mine.length && !force) throw new UserError(`These steps already have a design that was not imported from Figma: ${mine.join(', ')}. Pass --force to replace them, or --frames to import others.`)
477
+ if (!local) {
478
+ const ids = mapping.assigned.map((a) => a.frame.id)
479
+ for (let i = 0; i < ids.length; i += 50) {
480
+ const chunk = ids.slice(i, i + 50)
481
+ const full = await client.nodes(ref.key, chunk)
482
+ for (const [id, n] of Object.entries(full.nodes || {})) if (n?.document) { nodes.set(id, n.document); Object.assign(meta.styles, n.styles || {}) }
483
+ for (const scale of [1, 2]) {
484
+ const r = await client.images(ref.key, chunk, scale)
485
+ if (r.err) throw new UserError(`Figma could not render the frames: ${r.err}`)
486
+ for (const [id, url] of Object.entries(r.images || {})) if (url) images.set(`${id}@${scale}`, url)
487
+ }
488
+ }
489
+ const vars = tokensMode === 'skip' ? null : await client.variables(ref.key)
490
+ tokens = vars ? tokensFromVariables(vars) : null
491
+ if (tokens) tokens.from = 'variables'
492
+ else if (tokensMode !== 'skip') { const st = tokensFromStyles(meta.styles, [...nodes.values()]); if (st) tokens = { ...st, from: 'styles' } }
493
+ }
494
+ // the token set: written when the folder has none or the last one came from Figma
495
+ const tokFile = join(dir, TOKENS_FILE)
496
+ const ownSet = await exists(tokFile) && !prev?.tokens_written
497
+ const writeTokens = tokens?.doc && tokensMode !== 'skip' && (tokensMode === 'write' || !ownSet)
498
+ if (tokens?.doc) { const problems = validateTokens(tokens.doc); if (problems.length) { say(`! The token set from Figma has problems and is not written: ${problems.slice(0, 3).join('; ')}`); tokens = null } }
499
+ const tokenValues = new Map()
500
+ const known = tokens?.doc ? new Map(flattenTokens(tokens.doc).map((t) => [t.name, t.value])) : (await exists(tokFile) ? new Map(flattenTokens(JSON.parse(await readFile(tokFile, 'utf8'))).map((t) => [t.name, t.value])) : new Map())
501
+ // pages per step and locale (null = the step's default page)
502
+ const written = []
503
+ const warnings = []
504
+ const byStep = new Map()
505
+ for (const a of mapping.assigned) { const k = `${a.step}|${a.locale || ''}`; byStep.set(k, [...(byStep.get(k) || []), a]) }
506
+ // a step drawn only per locale: its first locale's frames are the page of the other locales too
507
+ for (const s of touched) if (!byStep.has(`${s}|`)) { const first = locales.map((l) => byStep.get(`${s}|${l}`)).find(Boolean); if (first) byStep.set(`${s}|`, first.map((a) => ({ ...a, locale: null }))) }
508
+ const lang = (l) => (l && l !== 'default' ? l : scenario.locales?.[0] && scenario.locales[0] !== 'default' ? scenario.locales[0] : 'en')
509
+ if (!dryRun) await mkdir(dir, { recursive: true })
510
+ for (const s of touched) {
511
+ if (dryRun) continue
512
+ const sdir = join(dir, s)
513
+ await mkdir(sdir, { recursive: true })
514
+ // images from an earlier import go first: a frame removed from Figma leaves nothing behind
515
+ for (const d of [sdir, ...locales.map((l) => join(sdir, l))]) for (const f of await readdir(d).catch(() => [])) if (IMAGE_RE.test(f) || f === 'figma.json') await rm(join(d, f), { force: true })
516
+ }
517
+ for (const [k, list] of byStep) {
518
+ const [s, loc] = k.split('|')
519
+ const node = steps.find((n) => n.id === s)
520
+ const pageDir = loc ? join(dir, s, loc) : join(dir, s)
521
+ const frameOut = []
522
+ const metaOut = []
523
+ for (const a of list) {
524
+ const f = a.frame
525
+ const name = a.viewport ? `figma-${a.viewport.replace(/[^A-Za-z0-9_-]/g, '_')}` : 'figma'
526
+ if (!dryRun) await mkdir(pageDir, { recursive: true })
527
+ const outs = {}
528
+ if (local) {
529
+ for (const [scale, file] of Object.entries(f.files)) {
530
+ const out = `${name}${scale === '1' ? '' : `.${scale}x`}.${file.ext}`
531
+ if (!dryRun) await writeFile(join(pageDir, out), file.buf)
532
+ outs[scale] = out
533
+ }
534
+ } else {
535
+ for (const scale of [1, 2]) {
536
+ const url = images.get(`${f.id}@${scale}`)
537
+ if (!url) continue
538
+ const out = `${name}${scale === 1 ? '' : '.2x'}.png`
539
+ if (!dryRun) await writeFile(join(pageDir, out), await client.download(url))
540
+ outs[scale] = out
541
+ }
542
+ }
543
+ const src = outs[1] || outs[2] || outs[3] || null
544
+ const src2x = outs[1] ? outs[2] || null : null
545
+ if (!src) { warnings.push(`${f.name}: no image (an empty or hidden frame?)`); continue }
546
+ const tree = nodes.get(f.id)
547
+ const texts = tree ? textsOfFrame(tree, { tokenNames: tokens?.names || new Map() }) : []
548
+ const vpSize = a.viewport ? viewports[a.viewport] : null
549
+ if (vpSize && Math.abs(vpSize[0] - f.width) > 2) warnings.push(`${f.name}: ${f.width} px wide, viewport ${a.viewport} is ${vpSize[0]} px; the pixel comparison will differ at the edges`)
550
+ if (!a.viewport && list.length === 1) { const off = Object.entries(viewports).filter(([, [w]]) => Math.abs(w - f.width) > 2).map(([v]) => v); if (off.length && off.length === Object.keys(viewports).length) warnings.push(`${f.name}: ${f.width} px wide matches no viewport (${Object.entries(viewports).map(([v, [w]]) => `${v} ${w}`).join(', ')}); add a frame per viewport ("${s} / ${Object.keys(viewports)[0]}") for a pixel comparison`) }
551
+ frameOut.push({ viewport: a.viewport, width: f.width, height: f.height, src, src2x, background: tree ? fillOf(tree) : null, texts })
552
+ metaOut.push({ id: f.id, name: f.name, page: f.page ?? null, viewport: a.viewport, locale: a.locale, width: f.width, height: f.height, texts: texts.length, ...(ref ? { url: `https://www.figma.com/design/${ref.key}?node-id=${encodeURIComponent(f.id.replace(/:/g, '-'))}` } : {}) })
553
+ }
554
+ if (!frameOut.length) continue
555
+ // the custom properties the texts use, with their values from the token set
556
+ const vars = new Map()
557
+ for (const fr of frameOut) for (const t of fr.texts) for (const n of [t.colorToken, t.sizeToken]) if (n && known.has(n) && typeof known.get(n) !== 'object') vars.set(n, known.get(n))
558
+ const title = (node && (typeof node.title === 'string' ? node.title : node.title?.[lang(loc)] || node.title?.en)) || s
559
+ const html = stepHtml({ title, lang: lang(loc), frames: frameOut, viewports, vars, source: local ? `Imported from the folder ${basename(source)}` : `Imported from Figma: ${meta.name || ref.key}` })
560
+ if (!dryRun) {
561
+ await mkdir(pageDir, { recursive: true })
562
+ await writeFile(join(pageDir, 'index.html'), html)
563
+ await writeFile(join(pageDir, 'figma.json'), JSON.stringify({ source: local ? 'folder' : 'figma', ...(ref ? { file: ref.key, name: meta.name || null } : {}), frames: metaOut }, null, 2) + '\n')
564
+ }
565
+ written.push({ step: s, locale: loc || null, frames: metaOut.length, viewports: metaOut.map((m) => m.viewport || 'all'), texts: frameOut.reduce((n, f) => n + f.texts.length, 0) })
566
+ }
567
+ if (writeTokens && !dryRun) await writeFile(tokFile, JSON.stringify(tokens.doc, null, 2) + '\n')
568
+ if (tokens?.doc && !writeTokens && tokensMode !== 'skip') warnings.push(`${TOKENS_FILE} is your own set, so the tokens from Figma were not written (--tokens write replaces it)`)
569
+ const record = { ...(ref ? { key: ref.key, name: meta.name, version: meta.version, last_modified: meta.last_modified } : { folder: source }), imported_at: new Date().toISOString(), tokens_written: writeTokens ? true : (prev?.tokens_written && !ownSet) || false, steps: written.map((w) => w.step) }
570
+ if (!dryRun) { await mkdir(join(dir, '.qawalk'), { recursive: true }); await writeFile(RECORD(dir), JSON.stringify(record, null, 2) + '\n'); await writeFile(join(dir, '.qawalk', '.gitignore'), '*\n').catch(() => {}) }
571
+ return { source: meta.source, file: ref ? { key: ref.key, name: meta.name, version: meta.version } : { folder: source }, written, unmatched: mapping.unmatched, duplicates: mapping.duplicates, missing: mapping.missing, tokens: tokens?.doc ? { from: tokens.from, count: flattenTokens(tokens.doc).length, written: !!writeTokens } : null, warnings }
572
+ }
573
+
574
+ /** The import summary for the terminal. */
575
+ export function formatImport (r, { dir }) {
576
+ const lines = [`Imported ${r.written.reduce((n, w) => n + w.frames, 0)} frame(s) from ${r.file.name ? `Figma "${r.file.name}"` : r.file.key ? `Figma ${r.file.key}` : r.file.folder} into ${dir}:`]
577
+ for (const w of r.written) lines.push(` ✓ ${w.step}${w.locale ? ` (${w.locale})` : ''} ${w.viewports.join(', ')}${w.texts ? ` ${w.texts} text layer(s)` : ''}`)
578
+ if (r.tokens) lines.push(` tokens: ${r.tokens.count} from ${r.tokens.from}${r.tokens.written ? ` → ${TOKENS_FILE}` : ' (not written)'}`)
579
+ if (r.missing.length) lines.push(`Steps without a frame: ${r.missing.join(', ')}`)
580
+ if (r.unmatched.length) lines.push(`Frames that match no step (name them after a step, or map them with --map): ${r.unmatched.slice(0, 15).map((f) => `"${f.name}"${f.reason ? ` (${f.reason})` : ''}`).join(', ')}${r.unmatched.length > 15 ? ', …' : ''}`)
581
+ if (r.duplicates.length) lines.push(`Left out (a frame of the same step, locale and viewport came first): ${r.duplicates.map((f) => `"${f.name}"`).join(', ')}`)
582
+ for (const w of r.warnings) lines.push(`! ${w}`)
583
+ return lines.join('\n')
584
+ }
package/src/lib/git.mjs CHANGED
@@ -24,7 +24,24 @@ export async function repoSlug (cwd) {
24
24
  * Project = config.project, else the repository name. Issue = scenario.issue,
25
25
  * else a number found in the scenario id (TASK-1273) or the branch (feat/1273-…).
26
26
  */
27
- export async function projectAndIssue ({ cwd, config, scenario, branch }) {
27
+ /**
28
+ * A Jira or Linear issue key (WEB-12, eng-42 in Linear's branch names) in the scenario's issue:, the branch or the
29
+ * pull request title; upper-cased, or null. Branch words that only look like keys (fix-12, release-3) are skipped.
30
+ */
31
+ export function issueKeyOf ({ scenario = {}, branch = null, title = null } = {}) {
32
+ const given = scenario.issue?.key
33
+ if (typeof given === 'string' && /^[A-Z][A-Z0-9_]{0,19}-[1-9][0-9]{0,8}$/.test(given)) return given
34
+ const NOT = new Set(['FEAT', 'FEATURE', 'FIX', 'BUGFIX', 'HOTFIX', 'CHORE', 'DOCS', 'TEST', 'TESTS', 'REFACTOR', 'RELEASE', 'RELEASES', 'V', 'VERSION', 'ISSUE', 'ISSUES', 'PR', 'MR', 'PULL', 'BUG', 'TASK', 'STORY', 'WIP', 'UPDATE', 'DEPS', 'BUMP', 'BUILD', 'CI', 'PERF', 'STYLE', 'REVERT', 'MAIN', 'DEV', 'STAGING', 'PROD', 'SPRINT', 'WEEK', 'PHASE', 'STEP', 'PART', 'ROUND', 'BATCH', 'DEPENDABOT', 'RENOVATE'])
35
+ for (const text of [title, branch]) {
36
+ for (const m of String(text || '').matchAll(/(?:^|[^A-Za-z0-9])([A-Za-z][A-Za-z0-9_]{0,19})-([1-9][0-9]{0,8})(?=$|[^0-9])/g)) {
37
+ const key = m[1].toUpperCase()
38
+ if (!NOT.has(key) && /^[A-Z]/.test(key)) return `${key}-${m[2]}`
39
+ }
40
+ }
41
+ return null
42
+ }
43
+
44
+ export async function projectAndIssue ({ cwd, config, scenario, branch, title = null }) {
28
45
  const repo = await repoSlug(cwd)
29
46
  // the scenario may sort itself into a project; otherwise the config, then the repository name
30
47
  const project = scenario.project
@@ -40,6 +57,9 @@ export async function projectAndIssue ({ cwd, config, scenario, branch }) {
40
57
  const url = given?.url || (slug && repo?.host === 'github' ? `https://github.com/${slug}/issues/${num}` : slug && repo?.host === 'gitlab' ? `https://gitlab.com/${slug}/-/issues/${num}` : null)
41
58
  issue = { repo: slug, number: num, url, title: given?.title || null }
42
59
  }
60
+ // a Jira or Linear key travels beside a GitHub number: the service links the run to that issue (trackers)
61
+ const key = issueKeyOf({ scenario, branch, title })
62
+ if (key) issue = { ...(issue || { repo: null, url: given?.url || null, title: given?.title || null }), key }
43
63
  return { project, issue }
44
64
  }
45
65
 
@@ -63,11 +83,11 @@ export async function gitMeta (cwd) {
63
83
  // CI checks out a detached commit: the branch and pull request come from the environment
64
84
  const ciBranch = vars.KALOKO_BRANCH || vars.GITHUB_HEAD_REF || vars.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || vars.CI_COMMIT_REF_NAME || null
65
85
  if (vars.GITLAB_CI && vars.CI_MERGE_REQUEST_IID) pr = { number: Number(vars.CI_MERGE_REQUEST_IID), url: `${vars.CI_MERGE_REQUEST_PROJECT_URL || vars.CI_PROJECT_URL}/-/merge_requests/${vars.CI_MERGE_REQUEST_IID}`, state: 'open' }
66
- const json = pr ? null : await out('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,state'], cwd)
86
+ const json = pr ? null : await out('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,state,title'], cwd)
67
87
  if (json) {
68
88
  try {
69
89
  const p = JSON.parse(json)
70
- pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase() }
90
+ pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase(), ...(p.title ? { title: String(p.title).slice(0, 300) } : {}) }
71
91
  } catch {}
72
92
  }
73
93
  return { branch: branch === 'HEAD' && ciBranch ? ciBranch : branch, commit, sha, message, author, pr }