#!/usr/bin/env node // Scenario Book for Jira — pipeline sync. One file, no dependencies, Node 18 or newer. // // node scenario-book-sync.mjs pull Write Jira's feature files into the working tree // node scenario-book-sync.mjs push Send the repo's feature files to Jira // node scenario-book-sync.mjs results Upload a test report // node scenario-book-sync.mjs sync pull, then push (what most pipelines run) // // Two directions, two different channels, for a reason worth knowing before you read the code: // // Git → Jira goes to a Scenario Book web trigger, authenticated with a **sync key** you create in // Project settings → Apps → Scenario Book. The reply is a fixed "ok" or an error code and // carries no detail, because Forge static web triggers cannot return a body of their own. The // detail is written to a Jira project property, which this script reads straight afterwards // and prints. // Jira → Git does not call Scenario Book at all. The app publishes the files into Jira **project // properties**, and this script reads them through Jira's ordinary REST API with **your own** // Jira credentials. Nothing about your repository is sent anywhere except to your own Jira. // // Environment: // JIRA_BASE_URL https://your-site.atlassian.net // JIRA_EMAIL the account the API token belongs to // JIRA_API_TOKEN an Atlassian API token (id.atlassian.com → Security → API tokens) // JIRA_PROJECT the project key or id, e.g. PAY // SB_SYNC_KEY the sync key from Project settings (push and results only) // SB_PUSH_URL the push web trigger URL from Project settings // SB_RESULTS_URL the results web trigger URL from Project settings // SB_LABEL optional, names this pipeline in the results (default "default") // SB_DIR optional, where feature files live in the repo (default: whatever Jira says) // // The sync key is never printed, never written to a file, and never put in a URL. If you see it anywhere in // your build log, something else put it there. import { readFile, writeFile, mkdir, readdir, rename, unlink } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { dirname, join, relative, sep, posix } from 'node:path'; import { execFile } from 'node:child_process'; const FEATURE = /\.feature$/i; const INDEX = 'scenarioBook.index'; // What one push call may carry. These are Scenario Book's own limits, and a test checks they still match it: // at most this many files with their text (MAX_PUSH_FILES in the app), in a request body of at most this many // bytes (MAX_BODY_BYTES, 4 MB). A call over either is refused whole, so the push is split by both. const MAX_PUSH_FILES = 100; const MAX_PUSH_BYTES = 4194304; function env(name, { required = true } = {}) { const v = process.env[name]; if (!v && required) die(`${name} is not set. See the comment at the top of this script.`); return v ?? ''; } function die(message) { // To stderr, and exit 1, so a CI step fails rather than passing with a warning nobody reads. process.stderr.write(`scenario-book: ${message}\n`); process.exit(1); } const log = (...parts) => process.stdout.write(`scenario-book: ${parts.join(' ')}\n`); // ---------- Jira REST, as you ---------- function jiraAuth() { const email = env('JIRA_EMAIL'); const token = env('JIRA_API_TOKEN'); return `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`; } async function jiraGet(path) { const base = env('JIRA_BASE_URL').replace(/\/+$/, ''); const res = await fetch(`${base}${path}`, { headers: { Authorization: jiraAuth(), Accept: 'application/json' }, }); if (res.status === 404) return undefined; if (!res.ok) { // The body often says exactly which permission is missing, and it is your own Jira's words. const body = await res.text().catch(() => ''); die(`Jira answered ${res.status} for ${path}. ${body.slice(0, 400)}`); } return res.json(); } const property = async (key) => { const project = encodeURIComponent(env('JIRA_PROJECT')); const r = await jiraGet(`/rest/api/3/project/${project}/properties/${encodeURIComponent(key)}`); return r?.value; }; // ---------- pull: Jira → the working tree ---------- async function pull() { const root = await property(INDEX); if (!root) { die( `Jira has no ${INDEX} property for project ${env('JIRA_PROJECT')}. Turn pipeline sync on in ` + `Project settings → Apps → Scenario Book; the first publish starts when you save, so try again in a minute.`, ); } log(`Jira is at revision ${root.revision} with ${root.files} file(s) across ${root.pages} index page(s).`); const entries = []; for (let p = 0; p < root.pages; p++) { const page = await property(`${INDEX}.${p}`); // A page the root promised and Jira does not have means the publish is still running. Stopping is right: // carrying on would write a partial set of files and commit it as though it were the whole project. if (!page) die(`Index page ${p} of ${root.pages} is not there yet. The publish is still running; try again in a few seconds.`); entries.push(...page.files); } if (entries.length !== root.files) { die(`The index says ${root.files} files but the pages hold ${entries.length}. The publish is still running; try again in a few seconds.`); } const dir = process.env.SB_DIR ? process.env.SB_DIR.replace(/\/+$/, '') : ''; let same = 0; const seen = new Set(); /** Every file this pull will write, read and checked in full before a single one is written. */ const writes = []; // First, read and check everything. Any problem stops here, with the working tree exactly as it was — so // "Nothing has been written" is true of the whole pull, not only of the file that failed. Writing each file // as it arrived left the ones before a bad file written, and the deletions below undone: a partial set of // feature files that `--commit` would then commit as if it were the whole project. for (const e of entries) { const target = toLocal(dir, e.path); seen.add(target); const existing = await readIfPresent(target); // The hash Jira publishes is of the text it holds. Comparing it first is what keeps a pull from // rewriting, and the commit from touching, files nothing has changed. if (existing !== undefined && sha256(existing) === e.hash) { same += 1; continue; } const parts = []; for (let i = 0; i < e.parts; i++) { const part = await property(`scenarioBook.file.${e.id}.${i}`); if (!part) die(`Part ${i} of ${e.path} is missing. The publish is still running; try again in a few seconds. Nothing has been written.`); parts.push(part.text); } const text = parts.join(''); if (sha256(text) !== e.hash) { // The parts did not add up to the file Jira indexed. Never written: a feature file assembled wrong is // one that still parses and quietly says something else. die(`${e.path} did not reassemble to the hash Jira published. Nothing has been written. Try again; if it repeats, tell Scenario Book support.`); } writes.push({ target, text }); } // Files in the repo that Jira no longer has. Deleted, because a pull that only ever adds would leave a // scenario somebody removed in Jira running in your pipeline for ever. const removals = (await featureFiles(dir || '.')).filter((local) => !seen.has(local)); // Then write. Each file goes to a temporary name beside its target first, so a disk that fills up half way // leaves no feature file half-written; only when every one is on disk are they moved into place. const temps = []; try { for (const w of writes) { const temp = `${w.target}.sb-pull-${process.pid}`; await mkdir(dirname(w.target), { recursive: true }); await writeFile(temp, w.text, 'utf8'); temps.push({ temp, target: w.target }); } } catch (e) { for (const t of temps) await unlink(t.temp).catch(() => undefined); die(`Could not write the pulled files (${e?.message ?? e}). Nothing has been written.`); } for (const t of temps) await rename(t.temp, t.target); for (const local of removals) await unlink(local); log(`pull: ${writes.length} written, ${same} already up to date, ${removals.length} removed.`); return { written: writes.length, removed: removals.length }; } /** Jira's path, placed under SB_DIR if you set one. Never above the working tree. */ function toLocal(dir, jiraPath) { const clean = posix .normalize(String(jiraPath).replace(/\\/g, '/')) .replace(/^(\.\.\/)+/, '') .replace(/^\/+/, ''); if (clean === '' || clean.startsWith('..')) die(`Refusing a path from Jira that points outside the repository: ${jiraPath}`); return join(process.cwd(), dir, clean); } // ---------- push: the repo → Jira ---------- async function push() { const url = env('SB_PUSH_URL'); const dir = process.env.SB_DIR ? process.env.SB_DIR.replace(/\/+$/, '') : ''; const locals = await featureFiles(dir || '.'); if (!locals.length) { // Not an error, and not a push either. An empty `allPaths` tells Scenario Book the repo holds no feature // files, which would delete every one of them in Jira. Nobody's first run should be able to do that by // pointing SB_DIR at the wrong folder. log('push: no .feature files found. Nothing sent. Check SB_DIR if you expected some.'); return { sent: 0 }; } const all = []; const files = []; for (const local of locals) { const repoPath = posix.join(...relative(join(process.cwd(), dir), local).split(sep)); const text = await readFile(local, 'utf8'); all.push(repoPath); files.push({ path: repoPath, text }); } const commit = (await git(['rev-parse', 'HEAD']).catch(() => '')).trim(); // In batches, because one call carries at most MAX_PUSH_FILES files in MAX_PUSH_BYTES. `allPaths` goes with // every batch: it is the complete list of what the repo holds, and it is what tells Scenario Book which // files have been deleted. Leaving it off a batch would make that batch look like a repo with 100 files in it. const calls = batches(files, all, commit); let sent = 0; for (const [n, batch] of calls.entries()) { await trigger(url, { commit, files: batch, allPaths: all }, 'push'); sent += batch.length; const answer = await property('scenarioBook.lastPush'); report(answer, 'push'); if (n + 1 < calls.length) await sleep(5500); } log(`push: ${sent} file(s) sent in ${calls.length} call(s).`); return { sent }; } /** * Split the files into calls that each fit both limits: count, and the bytes of the whole request body. * * Measured as the JSON that is actually sent, because that is what the trigger measures: a feature file's * newlines and quotes grow when escaped, and `allPaths` rides along on every call. Worked out before anything * is sent, so a file too big for any call stops the push with its name rather than half-way through. */ function batches(files, all, commit) { const empty = Buffer.byteLength(JSON.stringify({ commit, files: [], allPaths: all }), 'utf8'); const out = []; let batch = []; let bytes = empty; for (const f of files) { // One more for the comma between entries; the first entry has none, so this errs on the safe side. const size = Buffer.byteLength(JSON.stringify(f), 'utf8') + 1; if (empty + size > MAX_PUSH_BYTES) { die(`${f.path} is too big to send: one call carries at most 4 MB, and this file alone is more. Nothing was sent.`); } if (batch.length && (batch.length >= MAX_PUSH_FILES || bytes + size > MAX_PUSH_BYTES)) { out.push(batch); batch = []; bytes = empty; } batch.push(f); bytes += size; } if (batch.length) out.push(batch); return out; } // ---------- results ---------- async function results(file) { if (!file) die('results needs the path of a report file: node scenario-book-sync.mjs results reports/cucumber.json'); // `text`, not `report`: a local called `report` shadows the report() function below, and the line that // calls it only runs when the answer property is missing — which is every customer's FIRST upload, before // the app has written one. The upload succeeded and the script then died with "report is not a function" // and exit 1, failing their build over a call that had worked. const text = await readFile(file, 'utf8').catch(() => die(`Could not read ${file}.`)); const commit = (await git(['rev-parse', 'HEAD']).catch(() => '')).trim(); await trigger(env('SB_RESULTS_URL'), { label: process.env.SB_LABEL || 'default', commit, report: text }, 'results'); const answer = await property('scenarioBook.lastResults'); if (answer?.ok) { log(`results: ${answer.matched} scenario(s) matched, ${answer.unmatched} unmatched. Run ${answer.runId}.`); // Named, not just counted. An unmatched case is a tag or a name that does not line up, and the only way // to fix it is to know which one. for (const name of answer.unmatchedNames ?? []) log(` unmatched: ${name}`); if (answer.unmatchedNamesMore) log(` …and ${answer.unmatchedNamesMore} more`); } else { report(answer, 'results'); } } // ---------- the web triggers ---------- async function trigger(url, body, what) { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', // The one place the key is used. Never in a URL, never in a log line. 'x-scenario-book-key': env('SB_SYNC_KEY'), }, body: JSON.stringify(body), }); const text = await res.text().catch(() => ''); if (res.ok) return; // Each of these is a different thing to go and do, so each gets its own sentence rather than one "failed". const reason = { 403: 'The sync key is wrong, revoked, or belongs to another project — or the Scenario Book license for the site is not active.', 409: 'Pipeline sync is off for this project. Turn it on in Project settings → Apps → Scenario Book.', 413: 'That call was too big. Send fewer files, or a smaller report.', 429: 'Too many calls. This script waits between batches; if you are running two pipelines on one key, give each its own.', 400: 'Scenario Book could not read that request.', }[res.status] ?? `Scenario Book answered ${res.status}.`; process.stderr.write(`scenario-book: ${what} refused. ${reason} ${text.slice(0, 300)}\n`); // The detail is in the project property, because a static web trigger cannot put it in this reply. const answer = await property(what === 'push' ? 'scenarioBook.lastPush' : 'scenarioBook.lastResults').catch(() => undefined); report(answer, what); process.exit(1); } /** Print what Jira recorded about the last call. The only place the real detail comes from. */ function report(answer, what) { if (!answer) return; if (answer.ok === false) { process.stderr.write(`scenario-book: ${what}: ${answer.error ?? 'refused'}\n`); return; } if (what !== 'push') return; const lines = []; if (answer.written) lines.push(`${answer.written} written`); if (answer.deleted) lines.push(`${answer.deleted} deleted`); if (answer.unchanged) lines.push(`${answer.unchanged} unchanged`); if (lines.length) log(`push: ${lines.join(', ')}.`); // Each of these is something only this answer can tell you, so none of them is folded into a count. for (const [field, say] of [ ['conflicts', 'conflict, resolve it on the story'], ['unreadable', 'Jira could not read this file, so it was not stored'], ['needed', 'Jira has never seen this file; the next push sends it'], ['refused', 'not a path Scenario Book will store'], ['taken', 'somebody saved this file in Jira first; nothing was overwritten'], ]) { for (const item of answer[field] ?? []) log(` ${typeof item === 'string' ? item : `${item.path} (${item.name})`} — ${say}`); if (answer[`${field}More`]) log(` …and ${answer[`${field}More`]} more ${field}`); } for (const path of answer.commit ?? []) log(` ${path} — Jira's version differs; run pull to get it`); } // ---------- odds and ends ---------- const sha256 = (s) => createHash('sha256').update(s, 'utf8').digest('hex'); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const readIfPresent = (p) => readFile(p, 'utf8').then((t) => t, () => undefined); async function featureFiles(dir) { const out = []; const walk = async (d) => { let names; try { names = await readdir(d, { withFileTypes: true }); } catch { return; } for (const n of names) { if (n.name === '.git' || n.name === 'node_modules') continue; const p = join(d, n.name); if (n.isDirectory()) await walk(p); else if (FEATURE.test(n.name)) out.push(p); } }; await walk(dir === '.' ? process.cwd() : join(process.cwd(), dir)); out.sort(); return out; } const git = (args) => new Promise((resolve, reject) => execFile('git', args, { cwd: process.cwd() }, (err, stdout) => (err ? reject(err) : resolve(String(stdout)))), ); /** Commit what a pull wrote, if anything, on the branch the pipeline is already on. */ async function commitPulled(message) { const status = (await git(['status', '--porcelain'])).trim(); if (status === '') { log('commit: nothing changed.'); return false; } // Name and email from the pipeline's own config if it has one; these are only a fallback so `git commit` // does not stop and ask. const name = process.env.GIT_AUTHOR_NAME || 'Scenario Book sync'; const email = process.env.GIT_AUTHOR_EMAIL || 'scenario-book-sync@users.noreply.localhost'; await git(['-c', `user.name=${name}`, '-c', `user.email=${email}`, 'add', '-A', '--', '.']); await git(['-c', `user.name=${name}`, '-c', `user.email=${email}`, 'commit', '-m', message]); log('commit: made.'); return true; } // ---------- main ---------- const [command, ...rest] = process.argv.slice(2); try { if (command === 'pull') { const r = await pull(); if ((r.written || r.removed) && rest.includes('--commit')) await commitPulled('Scenario Book: feature files from Jira'); } else if (command === 'push') { await push(); } else if (command === 'results') { await results(rest[0]); } else if (command === 'sync') { // Pull first, always. Pushing first would send Jira a repo that has not caught up yet, and every file // Jira had changed would come back as a disagreement for somebody to resolve by hand. const r = await pull(); if ((r.written || r.removed) && rest.includes('--commit')) await commitPulled('Scenario Book: feature files from Jira'); await push(); } else { die('Usage: scenario-book-sync.mjs pull [--commit] | push | results | sync [--commit]'); } } catch (e) { die(e?.message ? String(e.message) : String(e)); }