|
| 1 | +#!/usr/bin/env node |
| 2 | +// objectui-range — summarize the frontend (objectui) delta bundled between two |
| 3 | +// framework revisions, ready to paste into a release page's Console section. |
| 4 | +// |
| 5 | +// The platform is one version-locked train and the Console UI is frozen into |
| 6 | +// @objectstack/console at the objectui commit pinned in .objectui-sha. To learn |
| 7 | +// "what did the frontend change between platform version A and B", you diff the |
| 8 | +// pinned SHA at two framework revisions and read objectui's log for that range. |
| 9 | +// This script does exactly that — the aggregation layer described in |
| 10 | +// docs/releases-maintenance.md. |
| 11 | +// |
| 12 | +// Usage: |
| 13 | +// node scripts/objectui-range.mjs <old-rev> [new-rev] |
| 14 | +// old-rev / new-rev are FRAMEWORK git refs (tags, branches, SHAs). |
| 15 | +// new-rev defaults to the working-tree .objectui-sha (the current pin). |
| 16 | +// node scripts/objectui-range.mjs --from <objectui-sha> --to <objectui-sha> |
| 17 | +// skip the framework lookup; use explicit objectui SHAs. |
| 18 | +// … --json emit structured JSON instead of markdown |
| 19 | +// … --all include every conventional-commit type (default: feat + fix) |
| 20 | +// |
| 21 | +// Env: |
| 22 | +// OBJECTUI_ROOT=/path/to/objectui (default: ../objectui, like bump-objectui.sh) |
| 23 | +import { execFileSync } from 'node:child_process'; |
| 24 | +import { fileURLToPath } from 'node:url'; |
| 25 | +import { dirname, join } from 'node:path'; |
| 26 | +import { existsSync, readFileSync } from 'node:fs'; |
| 27 | + |
| 28 | +const FRAMEWORK_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); |
| 29 | +const OBJECTUI_ROOT = |
| 30 | + process.env.OBJECTUI_ROOT || join(FRAMEWORK_ROOT, '..', 'objectui'); |
| 31 | + |
| 32 | +const argv = process.argv.slice(2); |
| 33 | +const has = (f) => argv.includes(f); |
| 34 | +const val = (f) => { |
| 35 | + const i = argv.indexOf(f); |
| 36 | + return i >= 0 ? argv[i + 1] : undefined; |
| 37 | +}; |
| 38 | +const positional = argv.filter( |
| 39 | + (a, i) => !a.startsWith('--') && argv[i - 1] !== '--from' && argv[i - 1] !== '--to', |
| 40 | +); |
| 41 | + |
| 42 | +const JSON_OUT = has('--json'); |
| 43 | +const ALL_TYPES = has('--all'); |
| 44 | + |
| 45 | +if (has('-h') || has('--help')) { |
| 46 | + console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n') |
| 47 | + .filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n')); |
| 48 | + process.exit(0); |
| 49 | +} |
| 50 | + |
| 51 | +function die(msg) { |
| 52 | + console.error(`✗ objectui-range: ${msg}`); |
| 53 | + process.exit(1); |
| 54 | +} |
| 55 | + |
| 56 | +function git(cwd, args) { |
| 57 | + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim(); |
| 58 | +} |
| 59 | + |
| 60 | +// Resolve the objectui SHA pinned at a given framework rev (or the working tree). |
| 61 | +function pinAt(rev) { |
| 62 | + if (rev === undefined) { |
| 63 | + const p = join(FRAMEWORK_ROOT, '.objectui-sha'); |
| 64 | + if (!existsSync(p)) die('.objectui-sha not found in the working tree'); |
| 65 | + return readFileSync(p, 'utf8').trim(); |
| 66 | + } |
| 67 | + try { |
| 68 | + return git(FRAMEWORK_ROOT, ['show', `${rev}:.objectui-sha`]).trim(); |
| 69 | + } catch { |
| 70 | + return die(`cannot read .objectui-sha at framework rev '${rev}'`); |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +let fromSha = val('--from'); |
| 75 | +let toSha = val('--to'); |
| 76 | +if (!fromSha || !toSha) { |
| 77 | + if (positional.length < 1) { |
| 78 | + die('need <old-rev> [new-rev], or --from <sha> --to <sha>. See --help/header.'); |
| 79 | + } |
| 80 | + fromSha = pinAt(positional[0]); |
| 81 | + toSha = pinAt(positional[1]); // undefined → working-tree pin |
| 82 | +} |
| 83 | + |
| 84 | +if (!existsSync(join(OBJECTUI_ROOT, '.git'))) { |
| 85 | + die( |
| 86 | + `no objectui checkout at ${OBJECTUI_ROOT}.\n` + |
| 87 | + ` Clone it as a sibling, or set OBJECTUI_ROOT=/path/to/objectui.\n` + |
| 88 | + ` Range to inspect once available: ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)}`, |
| 89 | + ); |
| 90 | +} |
| 91 | + |
| 92 | +if (fromSha === toSha) { |
| 93 | + console.log(`objectui unchanged (${fromSha.slice(0, 12)}) — no frontend delta in this range.`); |
| 94 | + process.exit(0); |
| 95 | +} |
| 96 | + |
| 97 | +let rawLog; |
| 98 | +try { |
| 99 | + rawLog = git(OBJECTUI_ROOT, [ |
| 100 | + 'log', '--no-merges', '--format=%H%x09%s', `${fromSha}..${toSha}`, |
| 101 | + ]); |
| 102 | +} catch { |
| 103 | + die( |
| 104 | + `cannot walk ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)} in ${OBJECTUI_ROOT}.\n` + |
| 105 | + ` Fetch it: git -C ${OBJECTUI_ROOT} fetch --all`, |
| 106 | + ); |
| 107 | +} |
| 108 | + |
| 109 | +const CC = /^(feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert)(?:\(([^)]+)\))?(!)?:\s*(.+)$/; |
| 110 | +const KEEP = ALL_TYPES ? null : new Set(['feat', 'fix']); |
| 111 | + |
| 112 | +const commits = []; |
| 113 | +for (const line of rawLog.split('\n').filter(Boolean)) { |
| 114 | + const tab = line.indexOf('\t'); |
| 115 | + const sha = line.slice(0, tab); |
| 116 | + const subject = line.slice(tab + 1); |
| 117 | + const m = subject.match(CC); |
| 118 | + const type = m ? m[1] : 'other'; |
| 119 | + const scope = m ? m[2] || '' : ''; |
| 120 | + const desc = m ? m[4] : subject; |
| 121 | + if (KEEP && !KEEP.has(type)) continue; |
| 122 | + commits.push({ sha, type, scope, desc, subject }); |
| 123 | +} |
| 124 | + |
| 125 | +// Top touched areas (by scope) across the kept commits — the "big picture" line. |
| 126 | +const areaCounts = {}; |
| 127 | +for (const c of commits) if (c.scope) areaCounts[c.scope] = (areaCounts[c.scope] || 0) + 1; |
| 128 | +const topAreas = Object.entries(areaCounts).sort((a, b) => b[1] - a[1]).slice(0, 10); |
| 129 | + |
| 130 | +const byType = { feat: [], fix: [] }; |
| 131 | +for (const c of commits) (byType[c.type] || (byType[c.type] = [])).push(c); |
| 132 | + |
| 133 | +if (JSON_OUT) { |
| 134 | + console.log(JSON.stringify( |
| 135 | + { from: fromSha, to: toSha, count: commits.length, topAreas, commits }, null, 2, |
| 136 | + )); |
| 137 | + process.exit(0); |
| 138 | +} |
| 139 | + |
| 140 | +const line = (c) => `- ${c.scope ? `**${c.scope}** — ` : ''}${c.desc}`; |
| 141 | +const out = []; |
| 142 | +out.push(`<!-- objectui ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)} — ${commits.length} commit(s) -->`); |
| 143 | +if (topAreas.length) { |
| 144 | + out.push('', `_Largest areas: ${topAreas.map(([a, n]) => `${a} (${n})`).join(', ')}_`); |
| 145 | +} |
| 146 | +if (byType.feat?.length) { |
| 147 | + out.push('', '### Features', ...byType.feat.map(line)); |
| 148 | +} |
| 149 | +if (byType.fix?.length) { |
| 150 | + out.push('', '### Fixes', ...byType.fix.map(line)); |
| 151 | +} |
| 152 | +const extra = Object.keys(byType).filter((t) => t !== 'feat' && t !== 'fix' && byType[t].length); |
| 153 | +for (const t of extra) out.push('', `### ${t}`, ...byType[t].map(line)); |
| 154 | +if (commits.length === 0) out.push('', '_No feat/fix commits in range (try --all)._'); |
| 155 | + |
| 156 | +console.log(out.join('\n')); |
0 commit comments