Files
better-auth/.github/scripts/lib/pr-analyzer.ts

348 lines
10 KiB
TypeScript

/**
* PR Analyzer — shared classification module
*
* Pure functions for mapping conventional commit scopes and file paths
* to domain labels. No side effects, no network calls.
*
* Used by: auto-changeset.ts, release-notes.ts
*/
const SCOPE_TO_DOMAIN: Record<string, string> = {
// core
core: "core",
api: "core",
client: "core",
cookies: "core",
crypto: "core",
account: "core",
session: "core",
instrumentation: "core",
"last-login-method": "core",
"redis-storage": "core",
// database
db: "database",
adapters: "database",
"drizzle-adapter": "database",
"prisma-adapter": "database",
"kysely-adapter": "database",
"mongo-adapter": "database",
"memory-adapter": "database",
// oauth
"oauth-proxy": "oauth",
"one-tap": "oauth",
"generic-oauth": "oauth",
"social-provider": "oauth",
// credentials
"magic-link": "credentials",
"email-otp": "credentials",
"phone-number": "credentials",
phone: "credentials",
username: "credentials",
anonymous: "credentials",
siwe: "credentials",
passkey: "credentials",
// identity
"oauth-provider": "identity",
"oidc-provider": "identity",
mcp: "identity",
"device-authorization": "identity",
// organization
organization: "organization",
admin: "organization",
access: "organization",
// security
"two-factor": "security",
"2fa": "security",
captcha: "security",
haveibeenpwned: "security",
"rate-limiter": "security",
// enterprise
sso: "enterprise",
scim: "enterprise",
// payments
stripe: "payments",
"api-key": "payments",
// platform
expo: "platform",
electron: "platform",
// devtools
cli: "devtools",
telemetry: "devtools",
i18n: "devtools",
"test-utils": "devtools",
"open-api": "devtools",
// devops (filtered from release notes)
build: "devops",
ci: "devops",
deps: "devops",
"deps-dev": "devops",
knip: "devops",
// docs (filtered from release notes)
docs: "docs",
blog: "docs",
landing: "docs",
};
const PATH_TO_DOMAIN: [string, string][] = [
["packages/oauth-provider/", "identity"],
["packages/better-auth/src/plugins/oidc-provider/", "identity"],
["packages/better-auth/src/plugins/mcp/", "identity"],
["packages/better-auth/src/plugins/device-authorization/", "identity"],
["packages/better-auth/src/plugins/magic-link/", "credentials"],
["packages/better-auth/src/plugins/email-otp/", "credentials"],
["packages/better-auth/src/plugins/phone-number/", "credentials"],
["packages/better-auth/src/plugins/username/", "credentials"],
["packages/better-auth/src/plugins/anonymous/", "credentials"],
["packages/better-auth/src/plugins/siwe/", "credentials"],
["packages/passkey/", "credentials"],
["packages/better-auth/src/plugins/two-factor/", "security"],
["packages/better-auth/src/api/rate-limiter/", "security"],
["packages/better-auth/src/plugins/captcha/", "security"],
["packages/better-auth/src/plugins/haveibeenpwned/", "security"],
["packages/better-auth/src/plugins/organization/", "organization"],
["packages/better-auth/src/plugins/admin/", "organization"],
["packages/better-auth/src/plugins/access/", "organization"],
["packages/better-auth/src/plugins/generic-oauth/", "oauth"],
["packages/better-auth/src/plugins/oauth-proxy/", "oauth"],
["packages/better-auth/src/plugins/one-tap/", "oauth"],
["packages/better-auth/src/oauth2/", "oauth"],
["packages/core/src/social-providers/", "oauth"],
["packages/core/src/oauth2/", "oauth"],
["packages/sso/", "enterprise"],
["packages/scim/", "enterprise"],
["packages/stripe/", "payments"],
["packages/api-key/", "payments"],
["packages/better-auth/src/db/", "database"],
["packages/better-auth/src/adapters/", "database"],
["packages/drizzle-adapter/", "database"],
["packages/prisma-adapter/", "database"],
["packages/mongo-adapter/", "database"],
["packages/kysely-adapter/", "database"],
["packages/memory-adapter/", "database"],
["packages/expo/", "platform"],
["packages/electron/", "platform"],
["packages/better-auth/src/integrations/", "platform"],
["packages/cli/", "devtools"],
["packages/better-auth/src/plugins/open-api/", "devtools"],
["packages/telemetry/", "devtools"],
["packages/i18n/", "devtools"],
["packages/test-utils/", "devtools"],
// Session-related plugins → core
["packages/better-auth/src/plugins/jwt/", "core"],
["packages/better-auth/src/plugins/bearer/", "core"],
["packages/better-auth/src/plugins/multi-session/", "core"],
["packages/better-auth/src/plugins/custom-session/", "core"],
["packages/redis-storage/", "core"],
// Catch-all for better-auth and core packages
["packages/better-auth/", "core"],
["packages/core/", "core"],
// Non-user-facing
["docs/", "docs"],
["demo/", "docs"],
[".github/", "devops"],
["e2e/", "devops"],
];
export interface ConventionalCommit {
type: string;
scope: string;
subject: string;
breaking: boolean;
}
export function parseConventionalCommit(title: string): ConventionalCommit {
const typeMatch = title.match(/^([a-z]+)/);
const type = typeMatch?.[1] ?? "";
const scopeMatch = title.match(/^[a-z]+\(([^)]+)\)/);
const scope = scopeMatch?.[1] ?? "";
const breaking = /^[a-z]+(\([^)]+\))?!:/.test(title);
const subject = title.replace(/^[a-z]+(\([^)]+\))?!?:\s*/, "");
return { type, scope, subject, breaking };
}
function classifyDomain(filePath: string): string | undefined {
for (const [prefix, domain] of PATH_TO_DOMAIN) {
if (filePath.startsWith(prefix)) return domain;
}
return undefined;
}
export function resolveDomain(
scope: string | undefined,
changedFiles: string[],
): string {
if (scope) {
const domain = SCOPE_TO_DOMAIN[scope];
if (domain) return domain;
}
const counts: Record<string, number> = {};
for (const file of changedFiles) {
const domain = classifyDomain(file);
if (domain) {
counts[domain] = (counts[domain] ?? 0) + 1;
}
}
const domains = Object.keys(counts);
if (domains.length === 0) return "devops";
if (domains.length >= 3) return "core";
return domains.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0))[0]!;
}
export function mapTypeToBump(
type: string,
breaking: boolean,
): "patch" | "minor" | "major" | "skip" {
if (breaking) return "major";
switch (type) {
case "fix":
case "perf":
case "refactor":
return "patch";
case "feat":
return "minor";
case "chore":
case "docs":
case "ci":
case "test":
case "style":
case "build":
return "skip";
default:
return "patch";
}
}
/** Domains in display order for release notes */
export const DOMAIN_ORDER = [
"core",
"database",
"oauth",
"credentials",
"identity",
"organization",
"security",
"enterprise",
"payments",
"platform",
"devtools",
] as const;
/** Domains excluded from release notes */
export const FILTERED_DOMAINS = new Set(["docs", "devops"]);
// ── Package resolution (for release notes output) ─────────────────────
/**
* Maps commit scopes to npm package names.
* Used by release-notes.ts to group entries by the package users install.
*/
const SCOPE_TO_PACKAGE: Record<string, string> = {
sso: "@better-auth/sso",
scim: "@better-auth/scim",
passkey: "@better-auth/passkey",
"oauth-provider": "@better-auth/oauth-provider",
stripe: "@better-auth/stripe",
"api-key": "@better-auth/api-key",
expo: "@better-auth/expo",
electron: "@better-auth/electron",
i18n: "@better-auth/i18n",
"test-utils": "@better-auth/test-utils",
"drizzle-adapter": "@better-auth/drizzle-adapter",
"prisma-adapter": "@better-auth/prisma-adapter",
"kysely-adapter": "@better-auth/kysely-adapter",
"mongo-adapter": "@better-auth/mongo-adapter",
"memory-adapter": "@better-auth/memory-adapter",
"redis-storage": "@better-auth/redis-storage",
cli: "auth",
};
/**
* Maps file path prefixes to npm package names.
* Order matters: more specific paths must come before catch-alls.
*/
const PATH_TO_PACKAGE: [string, string][] = [
["packages/sso/", "@better-auth/sso"],
["packages/scim/", "@better-auth/scim"],
["packages/passkey/", "@better-auth/passkey"],
["packages/oauth-provider/", "@better-auth/oauth-provider"],
["packages/stripe/", "@better-auth/stripe"],
["packages/api-key/", "@better-auth/api-key"],
["packages/expo/", "@better-auth/expo"],
["packages/electron/", "@better-auth/electron"],
["packages/i18n/", "@better-auth/i18n"],
["packages/redis-storage/", "@better-auth/redis-storage"],
["packages/test-utils/", "@better-auth/test-utils"],
["packages/telemetry/", "@better-auth/telemetry"],
["packages/drizzle-adapter/", "@better-auth/drizzle-adapter"],
["packages/prisma-adapter/", "@better-auth/prisma-adapter"],
["packages/kysely-adapter/", "@better-auth/kysely-adapter"],
["packages/mongo-adapter/", "@better-auth/mongo-adapter"],
["packages/memory-adapter/", "@better-auth/memory-adapter"],
// Catch-all: everything in better-auth or core maps to the main package
["packages/better-auth/", "better-auth"],
["packages/core/", "better-auth"],
["packages/cli/", "auth"],
];
/**
* Resolves the npm package name for release notes grouping.
* Priority: scope match > file path match > "better-auth" fallback.
*/
export function resolvePackage(
scope: string | undefined,
changedFiles: string[],
): string {
if (scope) {
const pkg = SCOPE_TO_PACKAGE[scope];
if (pkg) return pkg;
}
const counts: Record<string, number> = {};
for (const file of changedFiles) {
for (const [prefix, pkg] of PATH_TO_PACKAGE) {
if (file.startsWith(prefix)) {
counts[pkg] = (counts[pkg] ?? 0) + 1;
break;
}
}
}
const packages = Object.keys(counts);
if (packages.length === 0) return "better-auth";
// If files span multiple external packages, return the one with the most hits.
// If all files are in better-auth, return better-auth.
return packages.sort((a, b) => {
// Prefer non-better-auth packages (they're more specific)
const aIsCore = a === "better-auth" ? 1 : 0;
const bIsCore = b === "better-auth" ? 1 : 0;
if (aIsCore !== bIsCore) return aIsCore - bIsCore;
return (counts[b] ?? 0) - (counts[a] ?? 0);
})[0]!;
}
/** Classifies a conventional commit type into a release notes category. */
export function classifyChangeType(
type: string,
breaking: boolean,
): "breaking" | "feat" | "fix" {
if (breaking) return "breaking";
if (type === "feat") return "feat";
return "fix";
}