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
183 changes: 183 additions & 0 deletions .github/scripts/pr-preview/analyze-pr.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/env node
// Work out which components a PR changed, and map each one onto the story
// entries that Storybook actually built for it. The mapping is read out of the
// built `index.json` rather than guessed, so a component whose stories live
// under a different title still resolves.
//
// A component here is a directory: `src/components/<Name>/` for ui-common's
// own components, `src/forks/<Name>/` for its copies of Astryx components. Any
// file inside it (source, stylesheet, messages, stories) counts as a change to
// that component; tests alone do not, since they change nothing to look at.
//
// Usage:
// node analyze-pr.mjs --base origin/main --head HEAD \
// --storybook-index <storybook-static/index.json> --output analysis.json

import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { parseArgs } from "node:util";

const COMPONENT_DIR_RE = /^src\/(components|forks)\/([^/]+)\//;

const {
values: { base, head, "storybook-index": indexPath, output },
} = parseArgs({
options: {
base: { type: "string", default: "origin/main" },
head: { type: "string", default: "HEAD" },
"storybook-index": { type: "string" },
output: { type: "string", default: "analysis.json" },
},
});

const git = (args) =>
execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });

// A three-dot diff reports only what the PR branch changed, ignoring commits
// that landed on base meanwhile — that is the list we want. It needs a merge
// base, which a shallow clone may not have once base has advanced past the
// fetch window; deepen and retry before degrading. The two-dot fallback needs
// no merge base but compares two trees, so it can both over-report base churn
// and under-report a change that also exists on base. Callers get `diffMode` so
// the report can say when the list is approximate.
function changedFiles() {
// `--name-status` so a newly ADDED component can be told apart from a
// modified one; the report only flags missing stories for the former.
const parse = (raw) =>
raw
.split("\n")
.filter(Boolean)
.flatMap((line) => {
const [status, ...paths] = line.split("\t");
// A rename (`R100\told\tnew`) changes both directories: report it as
// the old path deleted and the new one added.
if (status[0] === "R") {
return [
{ status: "D", file: paths[0] },
{ status: "A", file: paths[1] },
];
}
return [{ status: status[0], file: paths[paths.length - 1] }];
});

const threeDot = () => parse(git(["diff", "--name-status", `${base}...${head}`]));

try {
return { files: threeDot(), diffMode: "three-dot" };
} catch {
/* fall through to the deepen retry */
}

try {
const bareBase = base.replace(/^origin\//, "");
git([
"fetch",
"--deepen=200",
"origin",
`+refs/heads/${bareBase}:refs/remotes/origin/${bareBase}`,
]);
return { files: threeDot(), diffMode: "three-dot" };
} catch {
/* fall through to the two-dot fallback */
}

try {
return {
files: parse(git(["diff", "--name-status", `${base}..${head}`])),
diffMode: "two-dot",
};
} catch (error) {
// A failed diff is not the same as "nothing changed" — publishing an empty
// analysis here would look legitimate and silently drop the whole report.
console.error(`::error::Could not diff ${base}..${head}: ${error.message}`);
process.exit(1);
}
}

const isStory = (file) => /\.stories\.(ts|tsx)$/.test(file);
const isTest = (file) => /\.test\.(ts|tsx)$/.test(file);

function loadStoryEntries() {
if (!indexPath || !existsSync(indexPath)) return [];
const parsed = JSON.parse(readFileSync(indexPath, "utf8"));
return Object.values(parsed.entries ?? {});
}

/**
* Where a component directory's stories open (one stories file per directory,
* docs/storybook.md). The autodocs page comes first — it shows every story of
* the component on one page, which is what a reviewer wants first.
*/
function storiesFor(dir, storyEntries) {
const entries = storyEntries.filter((e) => e.importPath?.startsWith(`./${dir}`));
const preferred = entries.find((e) => e.type === "docs") ?? entries[0];
return {
storyCount: entries.filter((e) => e.type === "story").length,
deepLink: preferred ? `?path=/${preferred.type}/${preferred.id}` : null,
};
}

const { files, diffMode } = changedFiles();
const storyEntries = loadStoryEntries();

// One row per changed component directory.
const rows = new Map();
const accounted = new Set();

for (const { status, file } of files) {
const match = COMPONENT_DIR_RE.exec(file);
if (!match || isTest(file)) continue;
const [dir, kind, name] = match;
accounted.add(file);

let row = rows.get(dir);
if (!row) {
const { storyCount, deepLink } = storiesFor(dir, storyEntries);
row = {
name,
kind,
dir,
deepLink,
hasStories: deepLink !== null,
storyCount,
sourceChanged: false,
storiesChanged: false,
isNew: false,
};
rows.set(dir, row);
}

if (isStory(file)) {
row.storiesChanged = true;
} else {
row.sourceChanged = true;
// A directory whose barrel was added in this PR is a new component.
if (status === "A" && file === `${dir}index.ts`) row.isNew = true;
}
}

const components = [...rows.values()].sort((a, b) => a.name.localeCompare(b.name));

const analysis = {
diffMode,
base,
// A hook, theme or i18n change still deploys a preview; the report says so
// rather than pretending nothing happened.
otherSourceFiles: files
.map(({ file }) => file)
.filter((f) => f.startsWith("src/") && !accounted.has(f) && !isTest(f)),
components,
summary: {
// Only NEW components are counted as a gap. Most existing components
// have no story yet, and nagging about them on every unrelated edit trains
// reviewers to ignore the report.
newWithoutStories: components.filter((c) => c.isNew && !c.hasStories).length,
},
};

writeFileSync(output, `${JSON.stringify(analysis, null, 2)}\n`);
console.log(
`Analyzed ${files.length} changed files (${diffMode}); ` +
`${components.length} component(s) changed, ` +
`${analysis.summary.newWithoutStories} new one(s) without stories.`,
);
71 changes: 71 additions & 0 deletions .github/scripts/pr-preview/compact-gh-pages-history.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Squash gh-pages down to a single orphan commit.
#
# Every preview deploy and every cleanup adds a commit whose tree carries a full
# Storybook build per live PR. The history of those trees is worthless — only
# the current tree is ever served — but it is what makes the branch grow without
# bound. Rewriting to one orphan commit lets git drop every unreferenced blob on
# the next gc.
#
# Force-pushing a branch is normally off-limits here. gh-pages is the exception:
# it holds no reviewed history, nobody branches from it, and the tree is
# reproducible from CI. The rewrite keeps the tree byte-identical — it only
# discards ancestry.
set -euo pipefail

BRANCH="${BRANCH:-gh-pages}"
WORK="${WORK:-${RUNNER_TEMP:-/tmp}/gh-pages-compact}"
# Below this, the rewrite costs more (a full force-push of the tree) than the
# history it reclaims.
MIN_COMMITS="${MIN_COMMITS:-5}"
# REPO_URL is overridable so the logic can be exercised against a local bare
# repository; CI leaves it unset and gets the token URL.
if [ -z "${REPO_URL:-}" ]; then
REPO="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
REPO_URL="https://x-access-token:${TOKEN}@github.com/${REPO}.git"
fi

rm -rf "$WORK"
git clone --branch "$BRANCH" "$REPO_URL" "$WORK" \
|| { echo "No ${BRANCH} branch yet — nothing to compact."; exit 0; }

cd "$WORK"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

# The tip we cloned. Everything below is asserted against exactly this commit,
# and the push is leased to it, so a deploy or cleanup that lands while we work
# makes the push fail instead of being erased by it.
CLONED_TIP="$(git rev-parse HEAD)"

COMMITS="$(git rev-list --count HEAD)"
echo "${BRANCH} has ${COMMITS} commit(s) at ${CLONED_TIP}."
if [ "$COMMITS" -lt "$MIN_COMMITS" ]; then
echo "Fewer than ${MIN_COMMITS} — leaving history alone."
exit 0
fi

BEFORE="$(git count-objects -vH | awk '/size-pack/ {print $2 $3}')"

git checkout --orphan compacted
git add -A
git commit -m "Compact ${BRANCH} history ($(date -u +%Y-%m-%d), was ${COMMITS} commits)"

# The tree must be identical to the commit we cloned; a mismatch means the
# orphan commit lost content and must not be pushed.
if [ "$(git rev-parse compacted^{tree})" != "$(git rev-parse "${CLONED_TIP}^{tree}")" ]; then
echo "::error::Compacted tree differs from ${CLONED_TIP} — refusing to force-push."
exit 1
fi

# --force-with-lease pinned to the cloned tip, NOT a bare --force. The deploy and
# cleanup jobs run in different concurrency groups, so one of them can land a new
# preview between our clone and this push; a bare force would delete it, and the
# tree check above would not notice because it only ever saw the stale tip.
# Failing here is correct — the next weekly run compacts the newer history.
if ! git push --force-with-lease="${BRANCH}:${CLONED_TIP}" origin "compacted:${BRANCH}"; then
echo "::warning::${BRANCH} moved since the clone (a deploy or cleanup landed) — skipping compaction this run."
exit 0
fi
echo "Compacted ${COMMITS} commits into 1 (pack size before rewrite: ${BEFORE})."
Loading
Loading