Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/scripts/form-preview-links.js
Original file line number Diff line number Diff line change
@@ -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 <base-sha> <head-sha> <output-file>
//
// 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 = '<!-- form-preview-links -->';

/** @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 <base-sha> <head-sha> <output-file>');
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('', `<sub>Generated for ${headSha.slice(0, 7)}</sub>`);
writeFileSync(outputFile, lines.join('\n') + '\n');
console.log(lines.join('\n'));
66 changes: 66 additions & 0 deletions .github/workflows/form-preview-links.yml
Original file line number Diff line number Diff line change
@@ -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 });
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading