kaloko 0.4.9 → 0.4.11

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.
Files changed (39) hide show
  1. package/README.md +88 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +35 -0
  4. package/schemas/scenario.v1.json +30 -1
  5. package/skills/kaloko/SKILL.md +22 -4
  6. package/skills/kaloko/reference/acceptance.md +16 -0
  7. package/skills/kaloko/reference/ci.md +70 -0
  8. package/skills/kaloko/reference/collaboration.md +9 -1
  9. package/skills/kaloko/reference/environments.md +5 -0
  10. package/skills/kaloko/reference/execution.md +56 -0
  11. package/skills/kaloko/reference/import.md +66 -0
  12. package/skills/kaloko/reference/mcp.md +2 -0
  13. package/skills/kaloko/reference/recording.md +95 -0
  14. package/src/cli.mjs +467 -100
  15. package/src/lib/affected-core.mjs +242 -0
  16. package/src/lib/affected.mjs +233 -0
  17. package/src/lib/anim.mjs +125 -0
  18. package/src/lib/apps/recording-native.mjs +212 -0
  19. package/src/lib/build.mjs +60 -4
  20. package/src/lib/ci.mjs +207 -0
  21. package/src/lib/config.mjs +2 -0
  22. package/src/lib/doctor.mjs +5 -1
  23. package/src/lib/evaluate.mjs +4 -2
  24. package/src/lib/git.mjs +6 -2
  25. package/src/lib/heal.mjs +207 -0
  26. package/src/lib/import.mjs +374 -0
  27. package/src/lib/init-detect.mjs +219 -0
  28. package/src/lib/onboarding.mjs +15 -8
  29. package/src/lib/pr.mjs +24 -2
  30. package/src/lib/recording.mjs +373 -0
  31. package/src/lib/scenario.mjs +2 -1
  32. package/src/lib/service.mjs +7 -1
  33. package/src/lib/trace.mjs +109 -0
  34. package/src/lib/unzip.mjs +31 -0
  35. package/src/lib/upload.mjs +114 -0
  36. package/src/lib/video-cli.mjs +209 -0
  37. package/src/lib/video.mjs +427 -0
  38. package/src/lib/webm.mjs +144 -0
  39. package/viewer/index.html +186 -7
@@ -0,0 +1,219 @@
1
+ // Smarter `kaloko init` (#26): what the project is (framework, dev server command and URL, locales from the i18n
2
+ // config, main routes) and a first scenario that walks on the first try. Reads files only; never runs the project.
3
+ import { readFile, readdir, stat } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { routeOf } from './affected-core.mjs'
6
+
7
+ const read = (root, f) => readFile(join(root, f), 'utf8').catch(() => null)
8
+ const exists = (root, f) => stat(join(root, f)).then(() => true, () => false)
9
+ const firstOf = async (root, files) => { for (const f of files) { const t = await read(root, f); if (t != null) return { file: f, text: t } } return null }
10
+
11
+ const FRAMEWORKS = [
12
+ ['next', 'Next.js', ['next'], 3000],
13
+ ['nuxt', 'Nuxt', ['nuxt', 'nuxt3'], 3000],
14
+ ['sveltekit', 'SvelteKit', ['@sveltejs/kit'], 5173],
15
+ ['remix', 'Remix / React Router', ['@remix-run/dev', '@remix-run/react', '@react-router/dev'], 5173],
16
+ ['astro', 'Astro', ['astro'], 4321],
17
+ ['gatsby', 'Gatsby', ['gatsby'], 8000],
18
+ ['angular', 'Angular', ['@angular/core'], 4200],
19
+ ['cra', 'Create React App', ['react-scripts'], 3000],
20
+ ['vite', 'Vite', ['vite'], 5173]
21
+ ]
22
+
23
+ /** Framework, package manager and dev server (command, port, URL). */
24
+ async function detectStack (root) {
25
+ const pkgText = await read(root, 'package.json')
26
+ let pkg = null
27
+ try { pkg = pkgText ? JSON.parse(pkgText) : null } catch {}
28
+ const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) }
29
+ const pm = (await exists(root, 'pnpm-lock.yaml')) ? 'pnpm' : (await exists(root, 'yarn.lock')) ? 'yarn' : (await exists(root, 'bun.lockb')) || (await exists(root, 'bun.lock')) ? 'bun' : 'npm'
30
+ const runCmd = (script) => (pm === 'npm' ? `npm run ${script}` : pm === 'bun' ? `bun run ${script}` : `${pm} ${script}`)
31
+ let framework = null
32
+ for (const [id, name, names, port] of FRAMEWORKS) if (names.some((n) => deps[n])) { framework = { id, name, port }; break }
33
+ const gem = await read(root, 'Gemfile')
34
+ if (!framework && gem && /^\s*gem\s+['"]rails['"]/m.test(gem)) framework = { id: 'rails', name: 'Rails', port: 3000 }
35
+ if (!framework && await exists(root, 'manage.py')) framework = { id: 'django', name: 'Django', port: 8000 }
36
+ if (!framework && await exists(root, 'artisan')) framework = { id: 'laravel', name: 'Laravel', port: 8000 }
37
+ let command = null; let port = framework?.port || 3000
38
+ if (pkg?.scripts) {
39
+ const script = ['dev', 'start', 'serve', 'develop'].find((s) => pkg.scripts[s])
40
+ if (script) {
41
+ command = runCmd(script)
42
+ const m = pkg.scripts[script].match(/(?:--port[ =]|-p\s+|PORT=)(\d{2,5})/)
43
+ if (m) port = Number(m[1])
44
+ }
45
+ }
46
+ if (framework?.id === 'rails') command = (await exists(root, 'bin/dev')) ? 'bin/dev' : 'bin/rails server'
47
+ if (framework?.id === 'django') command = 'python manage.py runserver'
48
+ if (framework?.id === 'laravel') command = 'php artisan serve'
49
+ // a port in the bundler config wins over the default
50
+ const vite = await firstOf(root, ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'])
51
+ const vp = vite?.text.match(/server\s*:\s*{[^}]*port\s*:\s*(\d{2,5})/)
52
+ if (vp && !/(?:--port|-p\s|PORT=)/.test(pkg?.scripts?.dev || '')) port = Number(vp[1])
53
+ const nuxtCfg = framework?.id === 'nuxt' ? await firstOf(root, ['nuxt.config.ts', 'nuxt.config.js']) : null
54
+ const np = nuxtCfg?.text.match(/devServer\s*:\s*{[^}]*port\s*:\s*(\d{2,5})/)
55
+ if (np) port = Number(np[1])
56
+ return { framework, pm, command, port, url: `http://localhost:${port}`, name: pkg?.name || null }
57
+ }
58
+
59
+ const LOCALE = /^[a-z]{2}(?:-[A-Za-z]{2,4})?$/
60
+ const listOf = (src) => [...String(src || '').matchAll(/['"`]([a-z]{2}(?:-[A-Za-z]{2,4})?)['"`]/g)].map((m) => m[1])
61
+ const codesOf = (src) => { const codes = [...String(src || '').matchAll(/code\s*:\s*['"`]([a-z]{2}(?:-[A-Za-z]{2,4})?)['"`]/g)].map((m) => m[1]); return codes.length ? codes : listOf(src) }
62
+ const ordered = (list, def) => { const u = [...new Set(list)]; return def && u.includes(def) ? [def, ...u.filter((l) => l !== def)] : u }
63
+
64
+ /** Locales from the i18n config: { list, default, source, prefix: 'always'|'except-default'|'none' } or null. */
65
+ async function detectLocales (root, framework) {
66
+ // Next.js pages router i18n
67
+ const next = await firstOf(root, ['next.config.js', 'next.config.mjs', 'next.config.ts'])
68
+ const ni = next?.text.match(/i18n\s*:\s*{([\s\S]*?)}/)
69
+ if (ni) {
70
+ const list = listOf((ni[1].match(/locales\s*:\s*\[([^\]]*)\]/) || [])[1])
71
+ const def = (ni[1].match(/defaultLocale\s*:\s*['"`]([^'"`]+)/) || [])[1]
72
+ if (list.length) return { list: ordered(list, def), default: def || list[0], source: next.file, prefix: 'except-default' }
73
+ }
74
+ // next-intl, i18next routing files
75
+ const routing = await firstOf(root, ['src/i18n/routing.ts', 'src/i18n/routing.js', 'i18n/routing.ts', 'i18n/routing.js', 'src/i18n.ts', 'i18n.ts', 'src/i18n/config.ts', 'i18n/config.ts', 'src/middleware.ts', 'middleware.ts'])
76
+ const rl = routing?.text.match(/locales\s*:\s*\[([^\]]*)\]/) || routing?.text.match(/(?:export\s+)?const\s+locales\s*=\s*\[([^\]]*)\]/)
77
+ if (rl && listOf(rl[1]).length) {
78
+ const def = (routing.text.match(/defaultLocale\s*[:=]\s*['"`]([^'"`]+)/) || [])[1]
79
+ const prefix = /localePrefix\s*:\s*['"`](as-needed)['"`]/.test(routing.text) ? 'except-default' : /localePrefix\s*:\s*['"`]never['"`]/.test(routing.text) ? 'none' : 'always'
80
+ const list = listOf(rl[1])
81
+ return { list: ordered(list, def), default: def || list[0], source: routing.file, prefix }
82
+ }
83
+ // Nuxt i18n
84
+ const nuxt = framework?.id === 'nuxt' ? await firstOf(root, ['nuxt.config.ts', 'nuxt.config.js']) : null
85
+ const nx = nuxt?.text.match(/i18n\s*:\s*{([\s\S]*?)\n\s{0,4}}/)
86
+ if (nx) {
87
+ const block = nx[1]
88
+ const list = codesOf((block.match(/locales\s*:\s*\[([\s\S]*?)\]/) || [])[1])
89
+ const def = (block.match(/defaultLocale\s*:\s*['"`]([^'"`]+)/) || [])[1]
90
+ const strategy = (block.match(/strategy\s*:\s*['"`]([^'"`]+)/) || [])[1] || 'prefix_except_default'
91
+ if (list.length) return { list: ordered(list, def), default: def || list[0], source: nuxt.file, prefix: strategy === 'prefix' ? 'always' : strategy === 'no_prefix' ? 'none' : 'except-default' }
92
+ }
93
+ // Paraglide / inlang (SvelteKit and others)
94
+ const inlang = await read(root, 'project.inlang/settings.json')
95
+ if (inlang) {
96
+ try {
97
+ const s = JSON.parse(inlang); const list = s.locales || s.languageTags || []; const def = s.baseLocale || s.sourceLanguageTag
98
+ if (list.length) return { list: ordered(list, def), default: def || list[0], source: 'project.inlang/settings.json', prefix: 'except-default' }
99
+ } catch {}
100
+ }
101
+ // Rails
102
+ if (framework?.id === 'rails') {
103
+ const app = await read(root, 'config/application.rb') || ''
104
+ const avail = (app.match(/available_locales\s*=\s*\[([^\]]*)\]/) || [])[1]
105
+ const def = (app.match(/default_locale\s*=\s*:?['"]?([a-z]{2}(?:-[A-Z]{2})?)/) || [])[1]
106
+ let list = avail ? [...avail.matchAll(/:?['"]?([a-z]{2}(?:-[A-Z]{2})?)['"]?/g)].map((m) => m[1]) : []
107
+ if (!list.length) list = (await readdir(join(root, 'config/locales')).catch(() => [])).map((f) => f.replace(/\.(ya?ml|rb)$/, '')).filter((f) => LOCALE.test(f))
108
+ const routes = await read(root, 'config/routes.rb') || ''
109
+ if (list.length) return { list: ordered(list, def), default: def || list[0], source: avail ? 'config/application.rb' : 'config/locales', prefix: /scope\s+['"]\(?:locale\)?['"]|scope\s+['"]\/?:locale['"]/.test(routes) ? (/\(:locale\)/.test(routes) ? 'except-default' : 'always') : 'none' }
110
+ }
111
+ // message folders: messages/en.json, locales/cs.json, public/locales/en/…, src/locales/de.ts
112
+ for (const dir of ['messages', 'locales', 'lang', 'src/locales', 'src/messages', 'src/lang', 'public/locales', 'i18n', 'src/i18n']) {
113
+ const names = (await readdir(join(root, dir)).catch(() => [])).map((f) => f.replace(/\.(json|ya?ml|ts|js)$/, '')).filter((f) => LOCALE.test(f))
114
+ if (names.length) {
115
+ const appDir = await firstOf(root, ['app/[locale]/page.tsx', 'src/app/[locale]/page.tsx', 'app/[locale]/layout.tsx', 'src/app/[locale]/layout.tsx', 'app/[lang]/page.tsx', 'src/app/[lang]/page.tsx'])
116
+ return { list: ordered(names, names.includes('en') ? 'en' : null), default: names.includes('en') ? 'en' : names[0], source: dir, prefix: appDir ? 'always' : 'none' }
117
+ }
118
+ }
119
+ return null
120
+ }
121
+
122
+ const SOURCE_DIRS = ['app', 'src/app', 'pages', 'src/pages', 'src/routes', 'app/routes', 'app/pages']
123
+
124
+ async function filesUnder (root, dir, out = [], depth = 0) {
125
+ if (depth > 8 || out.length > 5000) return out
126
+ let entries = []
127
+ try { entries = await readdir(join(root, dir), { withFileTypes: true }) } catch { return out }
128
+ for (const e of entries) {
129
+ const p = `${dir}/${e.name}`
130
+ if (e.isDirectory()) { if (!['node_modules', 'api', 'components', 'lib'].includes(e.name) && !e.name.startsWith('.')) await filesUnder(root, p, out, depth + 1) } else out.push(p)
131
+ }
132
+ return out
133
+ }
134
+
135
+ /** Static routes from route files (any locale segment removed) or Rails routes; '/' first, shallow before deep. */
136
+ async function detectRoutes (root, framework) {
137
+ const found = new Map()
138
+ if (framework?.id === 'rails') {
139
+ const text = await read(root, 'config/routes.rb') || ''
140
+ if (/^\s*root\b/m.test(text)) found.set('/', 'config/routes.rb')
141
+ for (const m of text.matchAll(/^\s*(?:get|resources|resource)\s+[:'"]\/?([a-z][\w/-]*)['"]?/gm)) found.set('/' + m[1].replace(/_/g, '-'), 'config/routes.rb')
142
+ } else {
143
+ for (const dir of SOURCE_DIRS) {
144
+ for (const f of await filesUnder(root, dir)) {
145
+ if (!/\.(m?[jt]sx?|vue|svelte|astro|mdx?)$/.test(f) || /\/(_|\[\.\.\.)/.test(f) && !/\/\[(locale|lang)\]\//.test(f)) continue
146
+ const r = routeOf(f)
147
+ if (!r || r.scope !== 'page' || /\/api(\/|$)/.test(r.route)) continue
148
+ const route = r.route.replace(/^\/:(locale|lang|lng)(?=\/|$)/, '') || '/'
149
+ if (/[:*]/.test(route)) continue
150
+ if (!found.has(route)) found.set(route, f)
151
+ }
152
+ }
153
+ }
154
+ const skip = /^\/(api|admin|_|auth|oauth|callback|404|500|error)(\/|$)/
155
+ return [...found].filter(([p]) => !skip.test(p)).map(([path, file]) => ({ path, file })).sort((a, b) => (a.path === '/' ? -1 : b.path === '/' ? 1 : a.path.split('/').length - b.path.split('/').length || a.path.localeCompare(b.path)))
156
+ }
157
+
158
+ /** Everything init can tell about the project. */
159
+ export async function detectProject (root) {
160
+ const stack = await detectStack(root)
161
+ const locales = await detectLocales(root, stack.framework)
162
+ const routes = await detectRoutes(root, stack.framework)
163
+ return { ...stack, locales, routes }
164
+ }
165
+
166
+ const words = (p) => p === '/' ? 'Home' : p.split('/').filter(Boolean).pop().replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase())
167
+ const slug = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
168
+
169
+ /** A path per locale for the locale strategy: prefix always, except the default locale, or none. */
170
+ export function localizedPath (path, locales) {
171
+ if (!locales || locales.prefix === 'none' || locales.list.length < 2) return path
172
+ return Object.fromEntries(locales.list.map((l) => [l, locales.prefix === 'except-default' && l === locales.default ? path : `/${l}${path === '/' ? '' : path}`]))
173
+ }
174
+
175
+ /** The first scenario: the main routes, one screen each, that a walk can open as they are. */
176
+ export function initScenario (d, { max = 6, project = null } = {}) {
177
+ const routes = (d.routes.length ? d.routes : [{ path: '/', file: null }]).slice(0, max)
178
+ const name = project || d.name || 'app'
179
+ const ids = new Set()
180
+ const nodes = routes.map((r, i) => {
181
+ let id = r.path === '/' ? 'home' : slug(r.path) || `page-${i + 1}`
182
+ while (ids.has(id)) id += `-${i + 1}`
183
+ ids.add(id)
184
+ const title = words(r.path)
185
+ return {
186
+ id, kind: 'screen', title, path: localizedPath(r.path, d.locales),
187
+ purpose: `A visitor sees the ${title.toLowerCase()} page and can tell what to do next.`,
188
+ execution: i === 0
189
+ ? { strategy: 'playwright', script: 'steps/consent.mjs', instructions: `Open ${r.path} as a new visitor and accept the consent dialog if there is one.` }
190
+ : { strategy: 'playwright', script: 'steps/open.mjs', instructions: `Open ${r.path}.` },
191
+ criteria: [
192
+ { id: 'AC1', text: 'No errors in the browser console', check: { type: 'deterministic', assert: 'no_console_errors' } },
193
+ { id: 'AC2', text: 'The page makes its purpose clear', check: { type: 'semantic', question: `Does the first screen make clear what the ${title.toLowerCase()} page offers and what the visitor can do next?`, scope: 'main, body' } }
194
+ ]
195
+ }
196
+ })
197
+ return {
198
+ schema: 'qawalk.scenario.v1',
199
+ id: `${slug(name).toUpperCase() || 'APP'}-MAIN`,
200
+ title: `Main pages of ${name}`,
201
+ kind: 'flow', version: 1,
202
+ locales: d.locales?.list?.slice(0, 6) || ['en'],
203
+ viewports: { desktop: [1280, 800], mobile: [390, 844] },
204
+ environments: ['local'],
205
+ lanes: ['Visitor'],
206
+ nodes,
207
+ edges: nodes.slice(1).map((n) => ({ from: nodes[0].id, to: n.id, label: 'visit', kind: 'alt' }))
208
+ }
209
+ }
210
+
211
+ /** One paragraph for the terminal. */
212
+ export function formatDetection (d, root) {
213
+ const lines = [`Detected ${d.framework ? d.framework.name : 'no known framework'}${d.command ? `; dev server: ${d.command}` : ''} → ${d.url}`]
214
+ if (d.locales) lines.push(` locales: ${d.locales.list.join(', ')} (from ${d.locales.source}; ${d.locales.prefix === 'none' ? 'no locale in the URL' : d.locales.prefix === 'always' ? 'every locale prefixes the URL' : `/${d.locales.list.find((l) => l !== d.locales.default) || 'xx'}/… except ${d.locales.default}`})`)
215
+ else lines.push(' locales: none found in an i18n config (the scenario uses en)')
216
+ lines.push(d.routes.length ? ` routes: ${d.routes.slice(0, 8).map((r) => r.path).join(', ')}${d.routes.length > 8 ? `, +${d.routes.length - 8} more` : ''}` : ' routes: none found in route files (the scenario starts at /)')
217
+ return lines.join('\n')
218
+ }
219
+
@@ -10,16 +10,23 @@ export const realOrg = (org) => (typeof org === 'string' && /^[a-z0-9][a-z0-9-]{
10
10
  /** Address of the organization's sample run on the service. */
11
11
  export const sampleUrl = (base, org) => `${String(base).replace(/\/$/, '')}/${org}/sample`
12
12
 
13
- /** Lines printed at the end of `kaloko init`. */
14
- export function initNextSteps ({ base, org, loggedIn = false }) {
13
+ /**
14
+ * Lines printed at the end of `kaloko init`. `devServer` ({ command, url }) when init found the app's dev server but it
15
+ * is not answering yet; `scenario` the path of the first scenario init drafted, which then is the first real flow.
16
+ */
17
+ export function initNextSteps ({ base, org, loggedIn = false, devServer = null, scenario = null }) {
15
18
  const o = realOrg(org)
16
- return [
17
- 'Next:',
18
- loggedIn ? ' 1. Signed in already (KALOKO_TOKEN found).' : ' 1. npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)',
19
- o ? ` 2. Try a review on the sample run: ${sampleUrl(base, o)}` : ' 2. Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.',
20
- ' 3. Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:',
21
- ` "${FIRST_FLOW_PROMPT}"`
19
+ const steps = [
20
+ loggedIn ? 'Signed in already (KALOKO_TOKEN found).' : 'npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)'
22
21
  ]
22
+ if (devServer) steps.push(`${devServer.command || 'start your app'} (the dev server is not answering on ${devServer.url} yet)`)
23
+ steps.push(o ? `Try a review on the sample run: ${sampleUrl(base, o)}` : 'Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.')
24
+ if (scenario) steps.push(`Your first real flow: npx kaloko start --scenario ${scenario} --env local && npx kaloko walk`)
25
+ else steps.push('Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:', ` "${FIRST_FLOW_PROMPT}"`)
26
+ const lines = ['Next:']
27
+ let n = 0
28
+ for (const s of steps) lines.push(s.startsWith(' "') ? ` ${s}` : ` ${++n}. ${s}`)
29
+ return lines
23
30
  }
24
31
 
25
32
  /** Lines printed after a successful `kaloko login`. */
package/src/lib/pr.mjs CHANGED
@@ -52,8 +52,11 @@ export function prCommentBody ({ manifest, url, at = new Date() }) {
52
52
  * Creates or updates the scenario's comment on the current branch's PR.
53
53
  * @returns {{ ok: boolean, action?: 'created'|'updated', url?: string, reason?: string }}
54
54
  */
55
- export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun }) {
56
- const view = await run('gh', ['pr', 'view', '--json', 'number,url,headRepository,headRepositoryOwner'], cwd)
55
+ export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun, vars = process.env, fetchImpl = fetch }) {
56
+ // GitLab merge request pipelines: a note on the merge request through the API
57
+ if (vars.GITLAB_CI && vars.CI_MERGE_REQUEST_IID) return upsertMrNote({ manifest, url, vars, fetchImpl })
58
+ // in CI the checkout is often a detached commit: KALOKO_PR (kaloko ci preview-url sets it) names the pull request
59
+ const view = await run('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,headRepository,headRepositoryOwner'], cwd)
57
60
  if (view == null) return { ok: false, reason: 'gh is not installed, not signed in, or this branch has no pull request' }
58
61
  let pr
59
62
  try { pr = JSON.parse(view) } catch { return { ok: false, reason: 'gh pr view returned no JSON' } }
@@ -73,6 +76,25 @@ export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun })
73
76
  return r == null ? { ok: false, reason: 'could not create the comment' } : { ok: true, action: 'created', url: r.trim() || pr.url }
74
77
  }
75
78
 
79
+ /** One note per scenario on a GitLab merge request (KALOKO_GITLAB_TOKEN: a project access token with the api scope). */
80
+ export async function upsertMrNote ({ manifest, url, vars = process.env, fetchImpl = fetch }) {
81
+ const token = vars.KALOKO_GITLAB_TOKEN || vars.GITLAB_TOKEN
82
+ if (!token) return { ok: false, reason: 'set KALOKO_GITLAB_TOKEN (a project access token with the api scope) to comment on the merge request' }
83
+ const notes = `${vars.CI_API_V4_URL}/projects/${encodeURIComponent(vars.CI_PROJECT_ID)}/merge_requests/${vars.CI_MERGE_REQUEST_IID}/notes`
84
+ const headers = { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' }
85
+ const body = prCommentBody({ manifest, url })
86
+ const link = (id) => `${vars.CI_MERGE_REQUEST_PROJECT_URL || vars.CI_PROJECT_URL}/-/merge_requests/${vars.CI_MERGE_REQUEST_IID}#note_${id}`
87
+ try {
88
+ const list = await fetchImpl(`${notes}?per_page=100&sort=desc`, { headers })
89
+ if (!list.ok) return { ok: false, reason: `GitLab answered ${list.status} listing notes` }
90
+ const mine = (await list.json()).find((n) => String(n.body || '').includes(marker(manifest.scenario.id)))
91
+ const res = await fetchImpl(mine ? `${notes}/${mine.id}` : notes, { method: mine ? 'PUT' : 'POST', headers, body: JSON.stringify({ body }) })
92
+ if (!res.ok) return { ok: false, reason: `GitLab answered ${res.status}` }
93
+ const note = await res.json()
94
+ return { ok: true, action: mine ? 'updated' : 'created', url: link(note.id || mine?.id) }
95
+ } catch (e) { return { ok: false, reason: e.message } }
96
+ }
97
+
76
98
  async function defaultRun (cmd, args, cwd) {
77
99
  try { const { stdout } = await exec(cmd, args, { cwd, timeout: 30000, maxBuffer: 4 * 1024 * 1024 }); return stdout } catch { return null }
78
100
  }