Skip to content
Open
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
8 changes: 6 additions & 2 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,24 @@
},
],

"vitest/no-conditional-in-test": "off",
"eslint-plugin-import/max-dependencies": "off",
"jest/no-conditional-in-test": "off",

"typescript/array-type": ["error", { "default": "generic", "readonly": "generic" }],
"typescript/ban-types": "off", // deprecated, replaced by no-unsafe-function-type and no-wrapper-object-types
"typescript/consistent-type-imports": "error",
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-unsafe-function-type": "error",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-readonly-parameter-types": "off",
"typescript/return-await": "off",
"typescript/strict-boolean-expressions": "off",

"unicorn/no-array-callback-reference": "off",

"vitest/no-conditional-in-test": "off",
},
"env": {
"builtin": true,
Expand Down
121 changes: 93 additions & 28 deletions get-changed-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import type {
PackageJSON as ChangesetPackageJSON,
} from "@changesets/types";
import jsYaml from "js-yaml";
import micromatch from "micromatch";
import picomatch from "picomatch";
import type { ProbotOctokit } from "probot";
import subset from "semver/ranges/subset.js";
import { isChangeset } from "./is-changeset.ts";
import { matchGlobs } from "./match-globs.ts";

interface PackageJSON extends ChangesetPackageJSON {
workspaces?: ReadonlyArray<string> | { packages: ReadonlyArray<string> };
Expand Down Expand Up @@ -53,7 +54,6 @@ function getReleasePlanConfig(
const {
access: _access,
baseBranch: _baseBranch,
changedFilePatterns: _changedFilePatterns,
changelog: _changelog,
commit: _commit,
format: _format,
Expand Down Expand Up @@ -94,6 +94,8 @@ function getReleasePlanConfig(
return releasePlanConfig;
}

const REPO_ROOT = "/repo";

// TODO: it might be possible to remove this if improvements to `Array.isArray` ever land
// related thread: github.com/microsoft/TypeScript/issues/36554
function isArray<T>(
Expand All @@ -106,6 +108,46 @@ function isArray<T>(
return Array.isArray(arg);
}

function normalizeRepoPath(path: string): string {
if (path === "." || path === "" || path === "/") {
return REPO_ROOT;
}

if (path === REPO_ROOT || path.startsWith(`${REPO_ROOT}/`)) {
return path;
}

return path.startsWith("/") ? `${REPO_ROOT}${path}` : `${REPO_ROOT}/${path}`;
}

function isSubdir(pkgDir: string, file: string): boolean {
return file === pkgDir || file.startsWith(`${pkgDir}/`);
}

// Mirrors https://github.com/changesets/changesets/blob/5eeb0125f2766b9458aa1725900430b27b24116e/packages/git/src/index.ts#L346-L374
function globMatchSome(paths: ReadonlyArray<string>, patterns?: ReadonlyArray<string>): boolean {
if (!patterns) return paths.length > 0;

const matchers = patterns.map((pattern) => picomatch(pattern, undefined, true));
return paths.some((path) => {
if (path.includes("\\")) {
path = path.replaceAll("\\", "/");
}

let passed = false;
for (const matcher of matchers) {
if (!passed) {
if (!matcher.state.negated && matcher(path)) {
passed = true;
}
} else if (matcher.state.negated && !matcher(path)) {
passed = false;
}
}
return passed;
});
}

export const getChangedPackages = async ({
owner,
repo,
Expand All @@ -125,7 +167,9 @@ export const getChangedPackages = async ({
const encodedCredentials = Buffer.from(`x-access-token:${installationToken}`).toString("base64");

function fetchFile(path: string) {
return fetch(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`, {
const repoRelativePath = path.replace(new RegExp(`^${REPO_ROOT}/?`), "");

return fetch(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${repoRelativePath}`, {
headers: {
Authorization: `Basic ${encodedCredentials}`,
},
Expand Down Expand Up @@ -155,15 +199,19 @@ export const getChangedPackages = async ({
}

async function getPackage(pkgPath: string): Promise<{ dir: string; packageJson: PackageJSON }> {
const jsonContent = await fetchJsonFile(pkgPath + "/package.json");
const jsonContent = await fetchJsonFile(nodePath.posix.join(pkgPath, "package.json"));
return {
dir: pkgPath,
packageJson: jsonContent as PackageJSON,
};
}

const rootPackageJsonContentsPromise: Promise<PackageJSON> = fetchJsonFile("package.json");
const rawConfigPromise: Promise<WrittenConfig> = fetchJsonFile(".changeset/config.json");
const rootPackageJsonContentsPromise: Promise<PackageJSON> = fetchJsonFile(
nodePath.posix.join(REPO_ROOT, "package.json"),
);
const rawConfigPromise: Promise<WrittenConfig> = fetchJsonFile(
nodePath.posix.join(REPO_ROOT, ".changeset/config.json"),
);

const tree = await octokit.git.getTree({
owner,
Expand All @@ -176,28 +224,29 @@ export const getChangedPackages = async ({
const changesetPromises: Array<Promise<NewChangeset>> = [];
const potentialWorkspaceDirectories: Array<string> = [];
let isPnpm = false;
const changedFiles = await changedFilesPromise;
const changedFiles = (await changedFilesPromise).map(normalizeRepoPath);

for (const item of tree.data.tree) {
if (!item.path) {
continue;
}
if (nodePath.basename(item.path) === "package.json") {
const dirPath = nodePath.dirname(item.path);
const itemPath = normalizeRepoPath(item.path);
if (nodePath.posix.basename(itemPath) === "package.json") {
const dirPath = normalizeRepoPath(nodePath.posix.dirname(itemPath));
potentialWorkspaceDirectories.push(dirPath);
} else if (item.path === "pnpm-workspace.yaml") {
} else if (itemPath === `${REPO_ROOT}/pnpm-workspace.yaml`) {
isPnpm = true;
} else if (item.path === ".changeset/pre.json") {
preStatePromise = fetchJsonFile(".changeset/pre.json");
} else if (changedFiles.includes(item.path) && isChangeset(item.path)) {
} else if (itemPath === `${REPO_ROOT}/.changeset/pre.json`) {
preStatePromise = fetchJsonFile(nodePath.posix.join(REPO_ROOT, ".changeset/pre.json"));
} else if (changedFiles.includes(itemPath) && isChangeset(item.path)) {
const res = /\.changeset\/([^.]+)\.md/.exec(item.path);
if (!res) {
throw new Error("could not get name from changeset filename");
}
const id = res[1];

changesetPromises.push(
fetchTextFile(item.path).then((text) => {
fetchTextFile(itemPath).then((text) => {
try {
return {
...parseChangesetFile(text),
Expand All @@ -220,7 +269,9 @@ export const getChangedPackages = async ({
| undefined;

if (isPnpm) {
const pnpmWorkspaceContent = await fetchTextFile("pnpm-workspace.yaml");
const pnpmWorkspaceContent = await fetchTextFile(
nodePath.posix.join(REPO_ROOT, "pnpm-workspace.yaml"),
);
const pnpmWorkspace = jsYaml.safeLoad(pnpmWorkspaceContent) as PnpmWorkspace;

if (pnpmWorkspace.packages) {
Expand Down Expand Up @@ -255,12 +306,12 @@ export const getChangedPackages = async ({
const rootPackageJsonContent = await rootPackageJsonContentsPromise;

const rootPackage: Package = {
dir: "/",
dir: REPO_ROOT,
packageJson: rootPackageJsonContent,
};

const packages: Packages = {
rootDir: "/",
rootDir: REPO_ROOT,
rootPackage,
tool: { type: tool ? tool.type : "root" },
packages: [],
Expand All @@ -273,7 +324,7 @@ export const getChangedPackages = async ({
) {
throw new Error("globs are not valid: " + JSON.stringify(tool.globs));
}
const matches = micromatch(potentialWorkspaceDirectories, tool.globs);
const matches = matchGlobs(potentialWorkspaceDirectories, tool.globs, { cwd: REPO_ROOT });

packages.packages = await Promise.all(matches.map((dir) => getPackage(dir)));
} else {
Expand All @@ -297,24 +348,38 @@ export const getChangedPackages = async ({
);
}

// Mirrors https://github.com/changesets/changesets/blob/5eeb0125f2766b9458aa1725900430b27b24116e/packages/git/src/index.ts#L273-L304
const changedPackages = packages.packages
.toSorted((pkgA, pkgB) => pkgB.dir.length - pkgA.dir.length)
.filter((pkg) => {
const changedPackageFiles: Array<string> = [];

for (let i = changedFiles.length - 1; i >= 0; i--) {
const file = changedFiles[i];

if (isSubdir(pkg.dir, file)) {
changedFiles.splice(i, 1);
const relativeFile = file.slice(pkg.dir.length + 1);
changedPackageFiles.push(relativeFile);
}
}

return (
changedPackageFiles.length > 0 &&
globMatchSome(changedPackageFiles, configResult.config.changedFilePatterns)
);
})
.map((pkg) => pkg.packageJson.name);

const releasePlan = assembleReleasePlan(
await Promise.all(changesetPromises),
packages,
configResult.config,
await preStatePromise,
);

// A root-only project has a single package covering the whole repository,
// so there is no directory to narrow the changed files down to.
const changedPackages =
packages.tool.type === "root"
? packages.packages
: packages.packages.filter((pkg) =>
changedFiles.some((changedFile) => changedFile.startsWith(`${pkg.dir}/`)),
);

return {
changedPackages: changedPackages.map((pkg) => pkg.packageJson.name),
changedPackages,
releasePlan,
};
};
87 changes: 87 additions & 0 deletions match-globs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import nodePath from "node:path";
import picomatch from "picomatch";

const PARENT_DIRECTORY = /^(\/?\.\.)+/;
const ESCAPING_BACKSLASHES = /\\(?=[()[\]{}!*+?@|])/g;

// Adapted from tinyglobby 0.2.16's POSIX escaping and pattern splitting helpers.
// https://github.com/SuperchupuDev/tinyglobby/blob/577920259c91f5603fab3dbfa599a83bbb14a27a/src/utils.ts#L132-L140
// https://github.com/SuperchupuDev/tinyglobby/blob/577920259c91f5603fab3dbfa599a83bbb14a27a/src/utils.ts#L164-L183
const POSIX_UNESCAPED_GLOB_SYMBOLS = /(?<!\\)([()[\]{}*?|]|^!|[!+@](?=\()|\\(?![()[\]{}!*+?@|]))/g;

function escapePosixPath(path: string): string {
return path.replace(POSIX_UNESCAPED_GLOB_SYMBOLS, "\\$&");
}

function splitPattern(pattern: string): Array<string> {
const result = picomatch.scan(pattern, { parts: true });
return result.parts?.length ? result.parts : [pattern];
}

// Adapted from tinyglobby 0.2.16. Crawler-root calculations are omitted because
// this helper filters paths from a Git tree instead of traversing a filesystem.
// https://github.com/SuperchupuDev/tinyglobby/blob/577920259c91f5603fab3dbfa599a83bbb14a27a/src/patterns.ts#L7-L65
function normalizePattern(pattern: string, cwd: string): string {
let result = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
const escapedCwd = escapePosixPath(cwd);

result = nodePath.posix.isAbsolute(result.replace(ESCAPING_BACKSLASHES, ""))
? nodePath.posix.relative(escapedCwd, result)
: nodePath.posix.normalize(result);

const parentDir = PARENT_DIRECTORY.exec(result)?.[0];
if (parentDir) {
const parts = splitPattern(result);
const parentCount = (parentDir.length + 1) / 3;
const cwdParts = escapedCwd.split("/");
let matchingParents = 0;

while (
matchingParents < parentCount &&
parts[matchingParents + parentCount] ===
cwdParts[cwdParts.length + matchingParents - parentCount]
) {
result =
result.slice(0, (parentCount - matchingParents - 1) * 3) +
result.slice(
(parentCount - matchingParents) * 3 + parts[matchingParents + parentCount].length + 1,
) || ".";
matchingParents++;
}
}

return result;
}

// Pattern classification and matching follow tinyglobby 0.2.16.
// https://github.com/SuperchupuDev/tinyglobby/blob/577920259c91f5603fab3dbfa599a83bbb14a27a/src/patterns.ts#L68-L98
// https://github.com/SuperchupuDev/tinyglobby/blob/577920259c91f5603fab3dbfa599a83bbb14a27a/src/crawler.ts#L18-L31
export function matchGlobs(
paths: ReadonlyArray<string>,
globs: ReadonlyArray<string>,
{ cwd }: { cwd: string },
): Array<string> {
const matchPatterns: Array<string> = [];
// tinyglobby prunes node_modules while crawling. Match descendants explicitly
// because all candidate paths have already been collected from the Git tree.
const ignorePatterns: Array<string> = ["**/node_modules", "**/node_modules/**"];

for (const glob of globs) {
if (!glob) continue;

if (glob[0] !== "!" || glob[1] === "(") {
matchPatterns.push(normalizePattern(glob, cwd));
} else if (glob[1] !== "!" || glob[2] === "(") {
ignorePatterns.push(normalizePattern(glob.slice(1), cwd));
}
}

const matchOptions = { posix: true };
const matches = picomatch(matchPatterns, matchOptions);
const ignores = picomatch(ignorePatterns, matchOptions);

return paths.filter((path) => {
const relativePath = nodePath.posix.relative(cwd, path) || ".";
return matches(relativePath) && !ignores(relativePath);
});
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@
"@sentry/node": "^6.0.0",
"@types/js-yaml": "^3.12.2",
"@types/markdown-table": "^2.0.0",
"@types/micromatch": "^4.0.1",
"@types/node": "^25.5.0",
"@types/picomatch": "^4.0.3",
"human-id": "^4.1.3",
"js-yaml": "^3.14.0",
"markdown-table": "^2.0.0",
"micromatch": "^4.0.2",
"picomatch": "^4.0.4",
"probot": "^12.2.4",
"semver": "^7.8.5",
"typescript": "^6.0.2"
Expand Down
Loading