From d6b24ec6205a769be8d70966a5fbcc0fbd6e759e Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 15 Sep 2026 16:18:01 -0400 Subject: [PATCH 1/5] add lzstring to packages --- package.json | 1 + pnpm-lock.yaml | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/package.json b/package.json index a2149ea..63f1396 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/node": "^22.x", "eslint": "^9.22.0", "husky": "^9.1.7", + "lz-string": "^1.5.0", "prettier": "^3.8.3", "typescript": "6.0.x" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57f2134..858f97c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + lz-string: + specifier: ^1.5.0 + version: 1.5.0 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -1403,6 +1406,11 @@ packages: { integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== } hasBin: true + lz-string@1.5.0: + resolution: + { integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== } + hasBin: true + math-intrinsics@1.1.0: resolution: { integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== } @@ -3224,6 +3232,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lz-string@1.5.0: {} + math-intrinsics@1.1.0: {} merge2@1.4.1: {} From d2460b7a186c143228752a986fe85c848f12423d Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 15 Sep 2026 16:18:58 -0400 Subject: [PATCH 2/5] add js script to produce playground link --- .github/scripts/form-preview-links.js | 99 +++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/scripts/form-preview-links.js diff --git a/.github/scripts/form-preview-links.js b/.github/scripts/form-preview-links.js new file mode 100644 index 0000000..5f36b08 --- /dev/null +++ b/.github/scripts/form-preview-links.js @@ -0,0 +1,99 @@ +// Builds a Markdown comment with playground preview links for every form changed in a pull request. +// +// Usage: node .github/scripts/form-preview-links.js +// +// File contents are read from git objects (never executed), so this is safe to run +// against untrusted pull request commits. + +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import lz from 'lz-string'; + +const FORMS_DIR = 'lib/forms'; +const PLAYGROUND_URL = 'https://playground.opendatacapture.org'; +const TEXT_EXTENSIONS = new Set(['.css', '.html', '.js', '.json', '.jsx', '.ts', '.tsx']); + +// GitHub rejects comments longer than 65536 characters +const MAX_COMMENT_LENGTH = 65000; + +const COMMENT_MARKER = ''; + +/** @param {string[]} args */ +function git(args) { + return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); +} + +/** + * Runs a git command with NUL-separated output, so non-ASCII paths are not quoted + * @param {string[]} args + */ +function gitPaths(args) { + const [command, ...rest] = args; + return git([command, '-z', ...rest]) + .split('\0') + .filter(Boolean); +} + +/** @param {{ content: string, name: string }[]} files */ +function encodeFiles(files) { + return lz.compressToEncodedURIComponent(JSON.stringify(files)); +} + +/** @param {{ files: { content: string, name: string }[], label: string }} instrument */ +function encodeShareURL({ files, label }) { + const url = new URL(PLAYGROUND_URL); + url.searchParams.append('files', encodeFiles(files)); + url.searchParams.append('label', lz.compressToEncodedURIComponent(label)); + return url; +} + +const [baseSha, headSha, outputFile] = process.argv.slice(2); +if (!baseSha || !headSha || !outputFile) { + console.error('Usage: node form-preview-links.js '); + process.exit(1); +} + +// Three-dot diff: only changes introduced by the PR since it diverged from the base branch +const changedPaths = gitPaths(['diff', '--name-only', '--diff-filter=d', `${baseSha}...${headSha}`, '--', FORMS_DIR]); + +/** Names of form directories (e.g. "GAD_7") with at least one added or modified file */ +const changedForms = [...new Set(changedPaths.map((filepath) => filepath.split('/')[2]).filter(Boolean))].sort(); + +const lines = [COMMENT_MARKER, '### Form previews', '']; + +if (changedForms.length === 0) { + lines.push('This pull request does not add or modify any forms.'); +} else { + lines.push('Open the forms changed in this pull request in the Open Data Capture playground:', ''); + const skipped = []; + for (const formName of changedForms) { + const formDir = `${FORMS_DIR}/${formName}/`; + const files = []; + for (const filepath of gitPaths(['ls-tree', '-r', '--name-only', headSha, '--', formDir])) { + if (TEXT_EXTENSIONS.has(path.extname(filepath))) { + files.push({ content: git(['show', `${headSha}:${filepath}`]), name: filepath.slice(formDir.length) }); + } else { + console.warn(`Skipping non-text file: ${filepath}`); + } + } + if (!files.some((file) => /^index\.(js|jsx|ts|tsx)$/.test(file.name))) { + skipped.push(`- **${formName}**: no \`index\` entrypoint found`); + continue; + } + const entry = `- **${formName}**: [Open in playground](${encodeShareURL({ files, label: formName })})`; + if ([...lines, entry].join('\n').length > MAX_COMMENT_LENGTH) { + skipped.push(`- **${formName}**: link too long to include in a comment`); + continue; + } + lines.push(entry); + } + if (skipped.length > 0) { + lines.push('', 'No preview link could be generated for:', '', ...skipped); + } +} + +lines.push('', `Generated for ${headSha.slice(0, 7)}`); +writeFileSync(outputFile, lines.join('\n') + '\n'); +console.log(lines.join('\n')); From f2ee479d7ec25acf0125badf767bc584af1a3b9e Mon Sep 17 00:00:00 2001 From: David Roper Date: Tue, 15 Sep 2026 16:19:29 -0400 Subject: [PATCH 3/5] add github action yaml to run script and comment link on a pr --- .github/workflows/form-preview-links.yml | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/form-preview-links.yml diff --git a/.github/workflows/form-preview-links.yml b/.github/workflows/form-preview-links.yml new file mode 100644 index 0000000..6d80f32 --- /dev/null +++ b/.github/workflows/form-preview-links.yml @@ -0,0 +1,66 @@ +name: Form Preview Links + +# pull_request_target (rather than pull_request) gives the token write access for +# PRs opened from forks. The workflow and script always come from the base branch, +# and the PR's files are only read as data via git, never installed or executed. +on: + pull_request_target: + types: [opened, reopened, synchronize] + paths: + - 'lib/forms/**' + +permissions: + contents: read + pull-requests: write + +concurrency: + group: form-preview-links-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preview-links: + runs-on: ubuntu-latest + steps: + - name: Check out base branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Fetch pull request commits + run: git fetch --no-tags origin "+refs/pull/${{ github.event.pull_request.number }}/head" + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Generate preview links + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node .github/scripts/form-preview-links.js "$BASE_SHA" "$HEAD_SHA" "$RUNNER_TEMP/comment.md" + + - name: Post or update comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('node:fs'); + const body = fs.readFileSync(`${process.env.RUNNER_TEMP}/comment.md`, 'utf8'); + const marker = body.split('\n')[0]; + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number }); + const existing = comments.find((c) => c.user.type === 'Bot' && c.body.startsWith(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } From a707a4f3db83affc8a826322b24a143f9714f5b7 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 16 Sep 2026 14:15:09 -0400 Subject: [PATCH 4/5] chore: add playground-url generator package --- package.json | 2 +- pnpm-lock.yaml | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 63f1396..ae9404f 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,11 @@ "@douglasneuroinformatics/prettier-config": "^0.3.0", "@douglasneuroinformatics/tsconfig": "^2.0.0", "@opendatacapture/instrument-guidelines": "^2.0.0", + "@opendatacapture/playground-url": "^2.3.1", "@opendatacapture/serve-instrument": "^2.0.0", "@types/node": "^22.x", "eslint": "^9.22.0", "husky": "^9.1.7", - "lz-string": "^1.5.0", "prettier": "^3.8.3", "typescript": "6.0.x" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 858f97c..8ea74c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@opendatacapture/instrument-guidelines': specifier: ^2.0.0 version: 2.0.0 + '@opendatacapture/playground-url': + specifier: ^2.3.1 + version: 2.3.1 '@opendatacapture/serve-instrument': specifier: ^2.0.0 version: 2.0.0(@opendatacapture/runtime-v1@2.0.0) @@ -35,9 +38,6 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 - lz-string: - specifier: ^1.5.0 - version: 1.5.0 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -357,6 +357,11 @@ packages: { integrity: sha512-YkqIISuNHW3WOoA1Fqax5GUTg4imjhLlZvZkMaVj7ER88FATVmaCf1kYZKPVF0mdY3ty8XkOfjZJSI3BCbShPw== } hasBin: true + '@opendatacapture/playground-url@2.3.1': + resolution: + { integrity: sha512-TVi+3tJdp1rqqWEhoPCQZ32Q+Hs0FnavxjtnQiJkS21XeyJkC4bhKOgWoxH+7juENvaGyJyLPGzXzSkGrdZfxQ== } + hasBin: true + '@opendatacapture/runtime-v1@2.0.0': resolution: { integrity: sha512-A1H+3dSgGgoPJhhry853qHfXKX6RGDhs6Ev8ZcH16kUZwZmzJFmKteoB11/6OJRaSkdHeQ/H5NfRfsP1Mc6O4w== } @@ -2141,6 +2146,12 @@ snapshots: '@opendatacapture/instrument-guidelines@2.0.0': {} + '@opendatacapture/playground-url@2.3.1': + dependencies: + chalk: 5.6.2 + commander: 12.1.0 + lz-string: 1.5.0 + '@opendatacapture/runtime-v1@2.0.0': {} '@opendatacapture/serve-instrument@2.0.0(@opendatacapture/runtime-v1@2.0.0)': From 2d4cf56f5eddf4679faa42e3ca9567c4980544e9 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 16 Sep 2026 14:15:46 -0400 Subject: [PATCH 5/5] refactor: adjust form preview link creator to use playground-url package instead --- .github/scripts/form-preview-links.js | 56 ++++++++++++++++++++------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/scripts/form-preview-links.js b/.github/scripts/form-preview-links.js index 5f36b08..4696604 100644 --- a/.github/scripts/form-preview-links.js +++ b/.github/scripts/form-preview-links.js @@ -6,13 +6,21 @@ // against untrusted pull request commits. import { execFileSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; import path from 'node:path'; -import lz from 'lz-string'; +// @opendatacapture/playground-url publishes raw TypeScript as its import entry +// (and relies on a dependency it does not declare for consumers), so it cannot +// be imported here. Its compiled CLI is the supported standalone interface; +// resolve it relative to the package entry so it works whatever the install layout. +const PLAYGROUND_URL_CLI = path.resolve( + path.dirname(createRequire(import.meta.url).resolve('@opendatacapture/playground-url')), + '../dist/cli.js' +); const FORMS_DIR = 'lib/forms'; -const PLAYGROUND_URL = 'https://playground.opendatacapture.org'; const TEXT_EXTENSIONS = new Set(['.css', '.html', '.js', '.json', '.jsx', '.ts', '.tsx']); // GitHub rejects comments longer than 65536 characters @@ -36,17 +44,35 @@ function gitPaths(args) { .filter(Boolean); } -/** @param {{ content: string, name: string }[]} files */ -function encodeFiles(files) { - return lz.compressToEncodedURIComponent(JSON.stringify(files)); -} - -/** @param {{ files: { content: string, name: string }[], label: string }} instrument */ -function encodeShareURL({ files, label }) { - const url = new URL(PLAYGROUND_URL); - url.searchParams.append('files', encodeFiles(files)); - url.searchParams.append('label', lz.compressToEncodedURIComponent(label)); - return url; +/** + * Encode an instrument's source files into a playground share link. + * + * The CLI reads an instrument from a directory, so the files (already read from + * git objects) are written to a throwaway directory — as data, never executed — + * because only the pull request's base branch is checked out. The URL is printed + * to stdout; status and warnings go to stderr, which we forward to our own. + * @param {{ content: string, name: string }[]} files + * @param {string} label + */ +function generatePlaygroundURL(files, label) { + const dir = mkdtempSync(path.join(tmpdir(), 'odc-form-')); + try { + for (const file of files) { + const dest = path.resolve(dir, file.name); + // Defence in depth against a crafted path escaping the temp directory. + if (dest !== dir && !dest.startsWith(dir + path.sep)) { + throw new Error(`Refusing to write outside the temp directory: ${file.name}`); + } + mkdirSync(path.dirname(dest), { recursive: true }); + writeFileSync(dest, file.content); + } + return execFileSync(process.execPath, [PLAYGROUND_URL_CLI, dir, '--label', label], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'] + }).trim(); + } finally { + rmSync(dir, { force: true, recursive: true }); + } } const [baseSha, headSha, outputFile] = process.argv.slice(2); @@ -82,7 +108,7 @@ if (changedForms.length === 0) { skipped.push(`- **${formName}**: no \`index\` entrypoint found`); continue; } - const entry = `- **${formName}**: [Open in playground](${encodeShareURL({ files, label: formName })})`; + const entry = `- **${formName}**: [Open in playground](${generatePlaygroundURL(files, formName)})`; if ([...lines, entry].join('\n').length > MAX_COMMENT_LENGTH) { skipped.push(`- **${formName}**: link too long to include in a comment`); continue;