diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index b70ccb5..e9817fa 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -31,7 +31,7 @@ }, ], - "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" }], @@ -39,12 +39,16 @@ "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, diff --git a/get-changed-packages.ts b/get-changed-packages.ts index 4665cd7..011d4ca 100644 --- a/get-changed-packages.ts +++ b/get-changed-packages.ts @@ -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 | { packages: ReadonlyArray }; @@ -53,7 +54,6 @@ function getReleasePlanConfig( const { access: _access, baseBranch: _baseBranch, - changedFilePatterns: _changedFilePatterns, changelog: _changelog, commit: _commit, format: _format, @@ -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( @@ -106,6 +108,46 @@ function isArray( 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, patterns?: ReadonlyArray): 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, @@ -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}`, }, @@ -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 = fetchJsonFile("package.json"); - const rawConfigPromise: Promise = fetchJsonFile(".changeset/config.json"); + const rootPackageJsonContentsPromise: Promise = fetchJsonFile( + nodePath.posix.join(REPO_ROOT, "package.json"), + ); + const rawConfigPromise: Promise = fetchJsonFile( + nodePath.posix.join(REPO_ROOT, ".changeset/config.json"), + ); const tree = await octokit.git.getTree({ owner, @@ -176,20 +224,21 @@ export const getChangedPackages = async ({ const changesetPromises: Array> = []; const potentialWorkspaceDirectories: Array = []; 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"); @@ -197,7 +246,7 @@ export const getChangedPackages = async ({ const id = res[1]; changesetPromises.push( - fetchTextFile(item.path).then((text) => { + fetchTextFile(itemPath).then((text) => { try { return { ...parseChangesetFile(text), @@ -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) { @@ -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: [], @@ -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 { @@ -297,6 +348,29 @@ 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 = []; + + 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, @@ -304,17 +378,8 @@ export const getChangedPackages = async ({ 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, }; }; diff --git a/match-globs.ts b/match-globs.ts new file mode 100644 index 0000000..ebc7cd6 --- /dev/null +++ b/match-globs.ts @@ -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 = /(? { + 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, + globs: ReadonlyArray, + { cwd }: { cwd: string }, +): Array { + const matchPatterns: Array = []; + // tinyglobby prunes node_modules while crawling. Match descendants explicitly + // because all candidate paths have already been collected from the Git tree. + const ignorePatterns: Array = ["**/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); + }); +} diff --git a/package.json b/package.json index 108bebe..7db5795 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e330aa0..53dee52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,12 +33,12 @@ importers: '@types/markdown-table': specifier: ^2.0.0 version: 2.0.0 - '@types/micromatch': - specifier: ^4.0.1 - version: 4.0.10 '@types/node': specifier: ^25.5.0 version: 25.6.2 + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 human-id: specifier: ^4.1.3 version: 4.1.3 @@ -48,9 +48,9 @@ importers: markdown-table: specifier: ^2.0.0 version: 2.0.0 - micromatch: - specifier: ^4.0.2 - version: 4.0.8 + picomatch: + specifier: ^4.0.4 + version: 4.0.4 probot: specifier: ^12.2.4 version: 12.4.0 @@ -993,9 +993,6 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/braces@3.0.5': - resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} - '@types/btoa-lite@1.0.2': resolution: {integrity: sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==} @@ -1035,9 +1032,6 @@ packages: '@types/markdown-table@2.0.0': resolution: {integrity: sha512-fVZN/DRjZvjuk+lo7ovlI/ZycS51gpYU5vw5EcFeqkcX6lucQ+UWgEOH2O4KJHkSck4DHAY7D7CkVLD0wzc5qw==} - '@types/micromatch@4.0.10': - resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==} - '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -1047,6 +1041,9 @@ packages: '@types/node@25.6.2': resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/pino-http@5.8.4': resolution: {integrity: sha512-UTYBQ2acmJ2eK0w58vVtgZ9RAicFFndfrnWC1w5cBTf8zwn/HEy8O+H7psc03UZgTzHmlcuX8VkPRnRDEj+FUQ==} @@ -3179,8 +3176,6 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.6.2 - '@types/braces@3.0.5': {} - '@types/btoa-lite@1.0.2': {} '@types/chai@5.2.3': @@ -3227,10 +3222,6 @@ snapshots: '@types/markdown-table@2.0.0': {} - '@types/micromatch@4.0.10': - dependencies: - '@types/braces': 3.0.5 - '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} @@ -3239,6 +3230,8 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/picomatch@4.0.3': {} + '@types/pino-http@5.8.4': dependencies: '@types/pino': 6.3.12 diff --git a/test/index.test.ts b/test/index.test.ts index c3f8895..774b6df 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -747,6 +747,118 @@ thing `); }); + it("respects changedFilePatterns when building the add-changeset link", async ({ + expect, + task, + }) => { + const probot = setupProbot(task.id); + const { requests } = usePrState(server, { + files: { + ".changeset/config.json": JSON.stringify({ + changedFilePatterns: ["src/**", "!src/generated/**"], + }), + "package.json": JSON.stringify({ + name: "test", + workspaces: ["packages/*"], + }), + "packages/a/package.json": JSON.stringify({ + name: "pkg-a", + }), + "packages/a/README.md": [{ status: "added" }, "# pkg-a"], + "packages/b/package.json": JSON.stringify({ + name: "pkg-b", + }), + "packages/b/src/index.ts": [{ status: "added" }, "export const b = true;"], + "packages/c/package.json": JSON.stringify({ + name: "pkg-c", + }), + "packages/c/src/generated/index.ts": [ + { status: "added" }, + "export const generated = true;", + ], + }, + comments: [], + }); + + await probot.receive({ + name: "pull_request", + payload: pullRequestOpen, + } as never); + + const commentRequests = requests.filter((request) => request.path.includes("/comments")); + const serializedRequests = JSON.stringify(commentRequests); + + expect(serializedRequests).toContain("%22pkg-b%22"); + expect(serializedRequests).not.toContain("%22pkg-a%22"); + expect(serializedRequests).not.toContain("%22pkg-c%22"); + }); + + it("attributes changed files to the deepest matching workspace package", async ({ + expect, + task, + }) => { + const probot = setupProbot(task.id); + const { requests } = usePrState(server, { + files: { + ".changeset/config.json": JSON.stringify({}), + "package.json": JSON.stringify({ + name: "test", + workspaces: ["packages/*", "packages/*/nested"], + }), + "packages/a/package.json": JSON.stringify({ + name: "pkg-a", + }), + "packages/a/nested/package.json": JSON.stringify({ + name: "pkg-a-nested", + }), + "packages/a/nested/src/index.ts": [{ status: "added" }, "export const nested = true;"], + }, + comments: [], + }); + + await probot.receive({ + name: "pull_request", + payload: pullRequestOpen, + } as never); + + const commentRequests = requests.filter((request) => request.path.includes("/comments")); + const serializedRequests = JSON.stringify(commentRequests); + + expect(serializedRequests).toContain("%22pkg-a-nested%22"); + expect(serializedRequests).not.toContain("%22pkg-a%22%3A"); + }); + + it("does not reinclude workspaces excluded by negative patterns", async ({ expect, task }) => { + const probot = setupProbot(task.id); + const { requests } = usePrState(server, { + files: { + ".changeset/config.json": JSON.stringify({}), + "package.json": JSON.stringify({ + name: "test", + workspaces: ["packages/**", "!packages/private/**", "packages/private/special"], + }), + "packages/private/special/package.json": JSON.stringify({ + name: "pkg-private-special", + }), + "packages/private/special/src/index.ts": [ + { status: "added" }, + "export const special = true;", + ], + }, + comments: [], + }); + + await probot.receive({ + name: "pull_request", + payload: pullRequestOpen, + } as never); + + const commentRequests = requests.filter((request) => request.path.includes("/comments")); + const serializedRequests = JSON.stringify(commentRequests); + + expect(serializedRequests).not.toContain("%22pkg-private-special%22"); + }); + it("shows release details when a changed changeset parses into a release plan", async ({ expect, task, @@ -812,10 +924,7 @@ add feature `); }); - it("shows release details for private packages when the config doesn't opt in", async ({ - expect, - task, - }) => { + it("uses the Changesets v2 default for private packages", async ({ expect, task }) => { const probot = setupProbot(task.id); const { requests } = usePrState(server, { files: { @@ -883,6 +992,56 @@ add feature `); }); + it("excludes private dependent releases with Changesets v3 defaults", async ({ + expect, + task, + }) => { + const probot = setupProbot(task.id); + const { requests } = usePrState(server, { + files: { + ...baseFiles, + "package.json": JSON.stringify({ + name: "test", + workspaces: ["packages/*"], + devDependencies: { "@changesets/cli": "^3.0.0" }, + }), + ".changeset/abc123.md": [ + { + status: "added", + }, + `--- +"pkg-public": patch +--- + +add feature +`, + ], + "packages/public/package.json": JSON.stringify({ + name: "pkg-public", + version: "1.0.0", + }), + "packages/private/package.json": JSON.stringify({ + name: "pkg-private", + version: "1.0.0", + private: true, + dependencies: { "pkg-public": "workspace:*" }, + }), + }, + comments: [], + }); + + await probot.receive({ + name: "pull_request", + payload: pullRequestOpen, + } as never); + + const commentRequests = requests.filter((request) => request.path.includes("/comments")); + const serializedRequests = JSON.stringify(commentRequests); + + expect(serializedRequests).toContain("pkg-public"); + expect(serializedRequests).not.toContain("pkg-private"); + }); + it("reports changesets config validation errors in the comment", async ({ expect, task }) => { const probot = setupProbot(task.id); const { requests } = usePrState(server, { diff --git a/test/match-globs.test.ts b/test/match-globs.test.ts new file mode 100644 index 0000000..92f2d52 --- /dev/null +++ b/test/match-globs.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { matchGlobs } from "../match-globs.ts"; + +const paths = ["/repo/packages/a", "/repo/packages/private/special"]; + +describe("matchGlobs", () => { + it.each(["packages/*/", "packages//*/", "/repo/packages/*", "../repo/packages/*"])( + "normalizes workspace glob %s like tinyglobby", + (glob) => { + expect(matchGlobs(paths, [glob], { cwd: "/repo" })).toEqual(["/repo/packages/a"]); + }, + ); + + it("keeps excluded workspaces excluded after a later positive pattern", () => { + expect( + matchGlobs(paths, ["packages/**", "!packages/private/**", "packages/private/special"], { + cwd: "/repo", + }), + ).toEqual(["/repo/packages/a"]); + }); +});