mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
281 lines
10 KiB
JavaScript
Executable File
281 lines
10 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Compute the set of stories affected by a set of changed files using the
|
|
* module-graph.json emitted by the Storybook build (see the ModuleGraphPlugin
|
|
* in `common/storybook/.storybook/plugins/vite-module-graph-plugin.ts`).
|
|
*
|
|
* Called from `.github/workflows/ci-storybook.yml`'s story-selection job to
|
|
* narrow visual-regression runs to stories affected by a pull request.
|
|
*
|
|
* Usage:
|
|
* bin/find-affected-stories \
|
|
* --graph common/storybook/dist/module-graph.json \
|
|
* --base-ref origin/master
|
|
*
|
|
* The script computes `git diff --name-only BASE_REF...HEAD` itself. The
|
|
* `...` resolves against the merge-base, so files that came in via a merge
|
|
* of BASE_REF into the branch aren't counted — avoids false full-runs when
|
|
* `pull_request.base.sha` is stale relative to the master the branch merged.
|
|
*
|
|
* Output (JSON to stdout):
|
|
* { mode: "selective", affected_stories: [...], affected_story_count,
|
|
* total_story_count, suggested_shards, affected_duration_seconds,
|
|
* total_duration_seconds, unresolved_changed_files }
|
|
* OR
|
|
* { mode: "full", reason: "<why>", total_story_count }
|
|
*/
|
|
|
|
import { execFileSync } from 'child_process'
|
|
import * as fs from 'fs'
|
|
import { pathToFileURL } from 'url'
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Invalidators the module graph can't see
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// Changes to any file matching these substrings force a full-run: the effect
|
|
// isn't captured by the static import graph (global CSS side effects, theme
|
|
// providers, MSW mocks, the storybook harness itself, generated schema files).
|
|
const FULL_RUN_PATTERNS = [
|
|
'.storybook/',
|
|
'common/storybook/tailwind.config.js',
|
|
'common/storybook/package.json',
|
|
'frontend/src/lib/',
|
|
'frontend/src/styles/',
|
|
'frontend/src/mocks/',
|
|
'common/tailwind/',
|
|
'tailwind.config.js',
|
|
'tailwind.config.ts',
|
|
'frontend/src/queries/schema/',
|
|
'frontend/src/queries/schema.json',
|
|
'frontend/src/products.tsx',
|
|
'frontend/src/products.json',
|
|
'manifest.tsx',
|
|
'playwright.config.ts',
|
|
'.github/workflows/ci-storybook.yml',
|
|
'pnpm-lock.yaml',
|
|
'package.json',
|
|
'.nvmrc',
|
|
]
|
|
|
|
const STORYBOOK_RELEVANT_FILE_RE =
|
|
/^(frontend\/|products\/.*\/(frontend|mcp\/apps)\/|common\/(esbuilder|mosaic|storybook|tailwind)\/|packages\/quill\/|ee\/frontend\/|services\/mcp\/src\/ui-apps\/)/
|
|
const NON_RUNTIME_FILE_RE =
|
|
/(^|\/)(__tests__|tests?)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)README\.md$|^(frontend|playwright)\/snapshots\.yml$/
|
|
|
|
const MAX_CHANGED_FILES = 100
|
|
// Rough per-story render cost (see ci-storybook.yml matrix); used only for
|
|
// the savings-estimate row in the job summary.
|
|
const AVG_STORY_DURATION_SECONDS = 6
|
|
const TARGET_SHARD_SECONDS = 10 * 60
|
|
const STORY_RE = /\.stories\.(tsx?|mdx|jsx?)$/
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// CLI
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseArgs(argv) {
|
|
const args = { graph: null, baseRef: null }
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i]
|
|
if (a === '--graph') args.graph = argv[++i]
|
|
else if (a === '--base-ref') args.baseRef = argv[++i]
|
|
else if (a === '--help' || a === '-h') {
|
|
process.stderr.write('Usage: find-affected-stories --graph <path> --base-ref <git-ref>\n')
|
|
process.exit(0)
|
|
} else {
|
|
process.stderr.write(`Unknown argument: ${a}\n`)
|
|
process.exit(2)
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
function changedFilesFromGit(baseRef) {
|
|
// `...` resolves against the merge-base so files pulled in via a merge of
|
|
// baseRef into the branch aren't counted (avoids inflated diffs when
|
|
// pull_request.base.sha is stale).
|
|
const stdout = execFileSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], {
|
|
encoding: 'utf-8',
|
|
})
|
|
return stdout
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Output helpers
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function emitFull(reason, totalStories) {
|
|
process.stdout.write(JSON.stringify({ mode: 'full', reason, total_story_count: totalStories }) + '\n')
|
|
}
|
|
|
|
function emitSelective(affected, totalStories, unresolved) {
|
|
const affectedDuration = affected.length * AVG_STORY_DURATION_SECONDS
|
|
const totalDuration = totalStories * AVG_STORY_DURATION_SECONDS
|
|
// 2x safety factor keeps selective shards below the target under variance.
|
|
const suggestedShards = Math.max(1, Math.floor((affectedDuration * 2) / TARGET_SHARD_SECONDS) + 1)
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
mode: 'selective',
|
|
affected_stories: affected,
|
|
affected_story_count: affected.length,
|
|
total_story_count: totalStories,
|
|
suggested_shards: suggestedShards,
|
|
affected_duration_seconds: Math.round(affectedDuration),
|
|
total_duration_seconds: Math.round(totalDuration),
|
|
unresolved_changed_files: unresolved,
|
|
}) + '\n'
|
|
)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Core logic
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export function requiresFullRun(changedFile) {
|
|
for (const pattern of FULL_RUN_PATTERNS) {
|
|
if (changedFile.includes(pattern)) {
|
|
return `changed file matches full-run pattern '${pattern}': ${changedFile}`
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function normalize(p) {
|
|
if (!p) return p
|
|
// Strip any leading ./ or / so equality works against module names from the
|
|
// module graph plugin (which emits repo-relative names).
|
|
return p.replace(/^\.\//, '').replace(/^\/+/, '')
|
|
}
|
|
|
|
function loadGraph(graphPath) {
|
|
const raw = fs.readFileSync(graphPath, 'utf-8')
|
|
const data = JSON.parse(raw)
|
|
if (!data || !Array.isArray(data.modules)) {
|
|
throw new Error('module-graph.json is missing `modules[]`')
|
|
}
|
|
const byName = new Map()
|
|
for (const m of data.modules) {
|
|
if (!m || typeof m.name !== 'string') continue
|
|
byName.set(normalize(m.name), m)
|
|
}
|
|
return { modules: data.modules, byName }
|
|
}
|
|
|
|
function countStories(modules) {
|
|
let n = 0
|
|
for (const m of modules) if (STORY_RE.test(m.name)) n++
|
|
return n
|
|
}
|
|
|
|
export function affectedStoriesFor(changedFiles, graph) {
|
|
const { byName } = graph
|
|
const unresolved = []
|
|
const start = new Set()
|
|
for (const raw of changedFiles) {
|
|
const normalized = normalize(raw)
|
|
if (byName.has(normalized)) {
|
|
start.add(normalized)
|
|
} else {
|
|
unresolved.push(raw)
|
|
}
|
|
}
|
|
|
|
// BFS upward via `reasons` (which are the importers — things that depend on
|
|
// the current module). Any story we pass through is collected.
|
|
const visited = new Set(start)
|
|
const queue = [...start]
|
|
const stories = new Set()
|
|
while (queue.length > 0) {
|
|
const cur = queue.shift()
|
|
const mod = byName.get(cur)
|
|
if (!mod || !Array.isArray(mod.reasons)) continue
|
|
for (const importerRaw of mod.reasons) {
|
|
const importer = normalize(importerRaw)
|
|
if (visited.has(importer)) continue
|
|
visited.add(importer)
|
|
queue.push(importer)
|
|
if (STORY_RE.test(importer)) stories.add(importer)
|
|
}
|
|
}
|
|
// Also count the changed files themselves if they happen to be stories.
|
|
for (const f of start) if (STORY_RE.test(f)) stories.add(f)
|
|
return { affected: [...stories].sort(), unresolved }
|
|
}
|
|
|
|
export function unresolvedRuntimeFiles(unresolvedFiles) {
|
|
return unresolvedFiles.filter(
|
|
(file) => STORYBOOK_RELEVANT_FILE_RE.test(normalize(file)) && !NON_RUNTIME_FILE_RE.test(normalize(file))
|
|
)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Entrypoint
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export function main() {
|
|
const args = parseArgs(process.argv.slice(2))
|
|
if (!args.graph) {
|
|
process.stderr.write('Error: --graph is required\n')
|
|
process.exit(2)
|
|
}
|
|
if (!args.baseRef) {
|
|
process.stderr.write('Error: --base-ref is required\n')
|
|
process.exit(2)
|
|
}
|
|
|
|
let changed
|
|
try {
|
|
changed = changedFilesFromGit(args.baseRef)
|
|
} catch (err) {
|
|
process.stderr.write(`Error: \`git diff\` against ${args.baseRef} failed: ${err.message}\n`)
|
|
process.exit(2)
|
|
}
|
|
process.stderr.write(`Derived ${changed.length} changed file(s) from \`git diff ${args.baseRef}...HEAD\`\n`)
|
|
|
|
// Load graph first so we can emit total_story_count even in full-run mode.
|
|
// If the graph is unreadable/missing, fall back to a full-run recommendation
|
|
// without a count (caller will treat 0 as "unknown total").
|
|
let graph
|
|
let totalStories = 0
|
|
try {
|
|
graph = loadGraph(args.graph)
|
|
totalStories = countStories(graph.modules)
|
|
} catch (err) {
|
|
emitFull(`module-graph.json unavailable: ${err.message}`, 0)
|
|
return
|
|
}
|
|
|
|
if (process.env.FORCE_FULL_STORIES) {
|
|
emitFull('FORCE_FULL_STORIES env var set', totalStories)
|
|
return
|
|
}
|
|
if (changed.length > MAX_CHANGED_FILES) {
|
|
emitFull(`too many changed files (${changed.length} > ${MAX_CHANGED_FILES})`, totalStories)
|
|
return
|
|
}
|
|
for (const f of changed) {
|
|
const reason = requiresFullRun(f)
|
|
if (reason) {
|
|
emitFull(reason, totalStories)
|
|
return
|
|
}
|
|
}
|
|
|
|
const { affected, unresolved } = affectedStoriesFor(changed, graph)
|
|
const unresolvedRuntime = unresolvedRuntimeFiles(unresolved)
|
|
if (unresolvedRuntime.length > 0) {
|
|
emitFull(`changed frontend file missing from module graph: ${unresolvedRuntime[0]}`, totalStories)
|
|
return
|
|
}
|
|
process.stderr.write(`Affected stories: ${affected.length} / ${totalStories} (unresolved: ${unresolved.length})\n`)
|
|
emitSelective(affected, totalStories, unresolved)
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
main()
|
|
}
|