|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * Preview commits that would be included in the next web release. |
| 5 | + * |
| 6 | + * Shows the commit list between a base release tag and the current HEAD, |
| 7 | + * grouped into merged PRs and direct commits. Defaults the base to the |
| 8 | + * most recent v* (web) tag; pass --base to override, or --path to filter to |
| 9 | + * a specific pathspec (e.g. `--path apps/jetstream/`). |
| 10 | + * |
| 11 | + * Usage: |
| 12 | + * node scripts/release-preview.mjs [--base <tag>] [--path <pathspec>] |
| 13 | + */ |
| 14 | + |
| 15 | +import { select } from '@inquirer/prompts'; |
| 16 | +import { $, chalk } from 'zx'; |
| 17 | + |
| 18 | +$.verbose = false; |
| 19 | + |
| 20 | +const REPO_SLUG = 'jetstreamapp/jetstream'; |
| 21 | +const REMOTE = 'origin'; |
| 22 | +const MERGE_PR_REGEX = /^Merge pull request #(\d+) from (\S+)/; |
| 23 | + |
| 24 | +// ── Args ─────────────────────────────────────────────────────────────────── |
| 25 | +const options = parseArgs(process.argv.slice(2)); |
| 26 | + |
| 27 | +console.log('\n' + chalk.bold.cyan(' Release Preview') + '\n'); |
| 28 | + |
| 29 | +// ── Fetch tags ───────────────────────────────────────────────────────────── |
| 30 | +process.stdout.write(chalk.dim('Fetching tags... ')); |
| 31 | +try { |
| 32 | + await $`git fetch ${REMOTE} --tags --prune --quiet`; |
| 33 | + console.log(chalk.green('done')); |
| 34 | +} catch { |
| 35 | + console.log(chalk.yellow('skipped (offline?)')); |
| 36 | +} |
| 37 | + |
| 38 | +// ── Determine base tag ───────────────────────────────────────────────────── |
| 39 | +let baseTag = options.base; |
| 40 | + |
| 41 | +if (!baseTag) { |
| 42 | + const webTagsRaw = (await $`git tag --list 'v[0-9]*' --sort=-v:refname`).stdout.trim(); |
| 43 | + const webTags = webTagsRaw.split('\n').filter(Boolean); |
| 44 | + if (webTags.length === 0) { |
| 45 | + console.error(chalk.red('\nNo v* tags found in this repo.\n')); |
| 46 | + process.exit(1); |
| 47 | + } |
| 48 | + baseTag = await select({ |
| 49 | + message: 'Base release tag', |
| 50 | + choices: webTags.slice(0, 10).map((tag) => ({ value: tag, name: tag })), |
| 51 | + default: webTags[0], |
| 52 | + }); |
| 53 | +} |
| 54 | + |
| 55 | +// ── Validate base tag ────────────────────────────────────────────────────── |
| 56 | +try { |
| 57 | + await $`git rev-parse --verify ${baseTag}`.quiet(); |
| 58 | +} catch { |
| 59 | + console.error(chalk.red(`\nBase tag "${baseTag}" not found.\n`)); |
| 60 | + process.exit(1); |
| 61 | +} |
| 62 | + |
| 63 | +const baseSha = (await $`git rev-parse ${baseTag}`).stdout.trim(); |
| 64 | +const currentBranch = (await $`git rev-parse --abbrev-ref HEAD`).stdout.trim(); |
| 65 | +const headSha = (await $`git rev-parse HEAD`).stdout.trim(); |
| 66 | +const range = `${baseTag}..HEAD`; |
| 67 | + |
| 68 | +// ── Gather commits ───────────────────────────────────────────────────────── |
| 69 | +// --first-parent walks only the main-line history, so we see each merge |
| 70 | +// commit once and any commits pushed directly (without a PR). Without this, |
| 71 | +// every commit inside every merged PR branch would also show up. |
| 72 | +const pathArgs = options.path ? ['--', options.path] : []; |
| 73 | +const logOutput = ( |
| 74 | + await $`git log --first-parent --format=%H%x09%s ${range} ${pathArgs}` |
| 75 | +).stdout.trim(); |
| 76 | + |
| 77 | +const mainlineCommits = logOutput |
| 78 | + ? logOutput.split('\n').map((line) => { |
| 79 | + const tabIndex = line.indexOf('\t'); |
| 80 | + const sha = line.slice(0, tabIndex); |
| 81 | + const subject = line.slice(tabIndex + 1); |
| 82 | + return { sha, short: sha.slice(0, 9), subject }; |
| 83 | + }) |
| 84 | + : []; |
| 85 | + |
| 86 | +const prs = []; |
| 87 | +const directCommits = []; |
| 88 | +for (const commit of mainlineCommits) { |
| 89 | + const match = commit.subject.match(MERGE_PR_REGEX); |
| 90 | + if (match) { |
| 91 | + const number = match[1]; |
| 92 | + const label = await resolvePrLabel(commit.sha, match[2]); |
| 93 | + prs.push({ ...commit, number, label }); |
| 94 | + } else { |
| 95 | + directCommits.push(commit); |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +// Total commit count includes every commit reachable from HEAD but not from |
| 100 | +// base — useful context separate from the mainline view. |
| 101 | +const totalCommitsOutput = ( |
| 102 | + await $`git log --format=%H ${range} ${pathArgs}` |
| 103 | +).stdout.trim(); |
| 104 | +const totalCommits = totalCommitsOutput ? totalCommitsOutput.split('\n').length : 0; |
| 105 | + |
| 106 | +// ── Summary ──────────────────────────────────────────────────────────────── |
| 107 | +console.log(''); |
| 108 | +console.log(chalk.bold(' Summary')); |
| 109 | +console.log(chalk.dim(' ─────────────────────────')); |
| 110 | +console.log(` ${chalk.dim('base')} ${chalk.cyan(baseTag)} ${chalk.dim(`(${baseSha.slice(0, 9)})`)}`); |
| 111 | +console.log(` ${chalk.dim('head')} ${chalk.cyan(currentBranch)} ${chalk.dim(`(${headSha.slice(0, 9)})`)}`); |
| 112 | +if (options.path) { |
| 113 | + console.log(` ${chalk.dim('path')} ${chalk.cyan(options.path)}`); |
| 114 | +} |
| 115 | +console.log(` ${chalk.dim('commits')} ${chalk.cyan(String(totalCommits))} ${chalk.dim(`(${mainlineCommits.length} on mainline)`)}`); |
| 116 | +console.log(` ${chalk.dim('PRs')} ${chalk.cyan(String(prs.length))}`); |
| 117 | +console.log(''); |
| 118 | + |
| 119 | +if (totalCommits === 0) { |
| 120 | + console.log(chalk.yellow(' No commits between base and HEAD.')); |
| 121 | + if (options.path) { |
| 122 | + console.log(chalk.dim(' (Try running without --path filter.)')); |
| 123 | + } |
| 124 | + console.log(''); |
| 125 | + process.exit(0); |
| 126 | +} |
| 127 | + |
| 128 | +// ── PR list ──────────────────────────────────────────────────────────────── |
| 129 | +if (prs.length > 0) { |
| 130 | + console.log(chalk.bold(' Merged PRs')); |
| 131 | + console.log(chalk.dim(' ─────────────────────────')); |
| 132 | + const numberWidth = Math.max(...prs.map((pr) => pr.number.length)); |
| 133 | + for (const pr of prs) { |
| 134 | + const padded = `#${pr.number}`.padEnd(numberWidth + 1); |
| 135 | + console.log(` ${chalk.cyan(padded)} ${pr.label}`); |
| 136 | + } |
| 137 | + console.log(''); |
| 138 | +} |
| 139 | + |
| 140 | +// ── Direct commits ───────────────────────────────────────────────────────── |
| 141 | +if (directCommits.length > 0) { |
| 142 | + console.log(chalk.bold(' Direct commits')); |
| 143 | + console.log(chalk.dim(' ─────────────────────────')); |
| 144 | + for (const commit of directCommits) { |
| 145 | + console.log(` ${chalk.dim(commit.short)} ${commit.subject}`); |
| 146 | + } |
| 147 | + console.log(''); |
| 148 | +} |
| 149 | + |
| 150 | +// ── GitHub compare link ──────────────────────────────────────────────────── |
| 151 | +const compareUrl = `https://github.com/${REPO_SLUG}/compare/${baseTag}...${headSha}`; |
| 152 | +console.log(` ${chalk.dim('compare')} ${chalk.underline.blue(compareUrl)}`); |
| 153 | +console.log(''); |
| 154 | + |
| 155 | +// ── Helpers ──────────────────────────────────────────────────────────────── |
| 156 | + |
| 157 | +/** |
| 158 | + * Pulls the real commit subject for a merged PR. The merge commit's own |
| 159 | + * subject is just "Merge pull request #X from ..." which isn't useful — the |
| 160 | + * actual change description lives in the merge commit body, or falls back to |
| 161 | + * the source branch name. |
| 162 | + */ |
| 163 | +async function resolvePrLabel(mergeSha, sourceRef) { |
| 164 | + try { |
| 165 | + const body = (await $`git log -1 --format=%b ${mergeSha}`).stdout.trim(); |
| 166 | + const firstLine = body.split('\n').find((line) => line.trim()); |
| 167 | + if (firstLine) { |
| 168 | + return firstLine.trim(); |
| 169 | + } |
| 170 | + } catch { |
| 171 | + // fall through |
| 172 | + } |
| 173 | + // Fallback: the source branch name from the merge subject |
| 174 | + return sourceRef.replace(/^[^/]+\//, ''); |
| 175 | +} |
| 176 | + |
| 177 | +function parseArgs(args) { |
| 178 | + const result = { base: null, path: null }; |
| 179 | + for (let i = 0; i < args.length; i++) { |
| 180 | + const arg = args[i]; |
| 181 | + if (arg === '--base' || arg === '-b') { |
| 182 | + result.base = args[++i]; |
| 183 | + } else if (arg === '--path' || arg === '-p') { |
| 184 | + result.path = args[++i]; |
| 185 | + } else if (arg === '--help' || arg === '-h') { |
| 186 | + console.log(` |
| 187 | +Usage: node scripts/release-preview.mjs [options] |
| 188 | +
|
| 189 | +Options: |
| 190 | + --base, -b <tag> Base release tag to compare against (default: prompt with latest v* tags) |
| 191 | + --path, -p <spec> Path filter (e.g. "apps/jetstream/" to scope to the web app) |
| 192 | + --help, -h Show this help |
| 193 | +`); |
| 194 | + process.exit(0); |
| 195 | + } else { |
| 196 | + console.error(chalk.red(`Unknown argument: ${arg}`)); |
| 197 | + process.exit(1); |
| 198 | + } |
| 199 | + } |
| 200 | + return result; |
| 201 | +} |
0 commit comments