diff --git a/.github/scripts/form-preview-links.js b/.github/scripts/form-preview-links.js new file mode 100644 index 0000000..4696604 --- /dev/null +++ b/.github/scripts/form-preview-links.js @@ -0,0 +1,125 @@ +// 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 { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +// @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 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); +} + +/** + * 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); +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](${generatePlaygroundURL(files, 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')); 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 }); + } diff --git a/package.json b/package.json index a2149ea..ae9404f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57f2134..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) @@ -354,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== } @@ -1403,6 +1411,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== } @@ -2133,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)': @@ -3224,6 +3243,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lz-string@1.5.0: {} + math-intrinsics@1.1.0: {} merge2@1.4.1: {}