diff --git a/.changeset/dry-spoons-own.md b/.changeset/dry-spoons-own.md new file mode 100644 index 00000000..d5be5f19 --- /dev/null +++ b/.changeset/dry-spoons-own.md @@ -0,0 +1,5 @@ +--- +"changesets-gitlab": minor +--- + +feat: add an input to run at a subdirectory of the repo root diff --git a/README.md b/README.md index bfc760da..1d8d9a21 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ GitLab CI cli for [changesets](https://github.com/atlassian/changesets) like its - `INPUT_TARGET_BRANCH` -> The merge request target branch. Defaults to current branch - `INPUT_CREATE_GITLAB_RELEASES` - A boolean value to indicate whether to create Gitlab releases after publish or not. Default true. - `INPUT_LABELS` - A comma separated string of labels to be added to the version package Gitlab Merge request +- `INPUT_CWD` - A relative path from the repo root to the directory containing `package.json` and `.changeset/`. Use this when your npm/yarn workspace lives in a subdirectory of the git repo. Defaults to the repo root. ### Outputs diff --git a/package.json b/package.json index f8099286..fa5c14c2 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@types/global-agent": "^3.0.0", "@types/micromatch": "^4.0.9", "@types/web": "^0.0.228", - "@vitest/coverage-istanbul": "3.1.2", + "@vitest/coverage-istanbul": "^3.2.7", "clean-pkg-json": "^1.2.1", "eslint": "^9.24.0", "nano-staged": "^0.8.0", @@ -108,7 +108,7 @@ "tsx": "^4.19.3", "type-coverage": "^2.29.7", "typescript": "^5.8.3", - "vitest": "^3.1.1", + "vitest": "^3.2.7", "yarn-berry-deduplicate": "^6.1.1", "yarn-deduplicate": "^6.0.2" }, diff --git a/src/comment.ts b/src/comment.ts index 1ea39016..4afc4126 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -6,12 +6,12 @@ import type { } from '@changesets/types' import type { CommitDiffSchema, Gitlab } from '@gitbeaker/core' import { - GitbeakerRequestError, type DiscussionNoteSchema, type DiscussionSchema, type MergeRequestDiffSchema, type MergeRequestNoteSchema, type NoteSchema, + GitbeakerRequestError, } from '@gitbeaker/rest' import { humanId } from 'human-id' import { markdownTable } from 'markdown-table' @@ -21,7 +21,12 @@ import * as context from './context.js' import { env } from './env.js' import { getChangedPackages } from './get-changed-packages.js' import type { LooseString } from './types.js' -import { getUsername, HTTP_STATUS_NOT_FOUND, TRUTHY_VALUES } from './utils.js' +import { + HTTP_STATUS_NOT_FOUND, + TRUTHY_VALUES, + getCwdInput, + getUsername, +} from './utils.js' const generatedByBotNote = 'Generated By Changesets GitLab Bot' @@ -220,13 +225,15 @@ async function getNoteInfo( const hasChangesetBeenAdded = async ( changedFilesPromise: Promise, + changesetDirPrefix: string, ) => { const changedFiles = await changedFilesPromise return changedFiles.some(file => { return ( file.new_file && - /^\.changeset\/.+\.md$/.test(file.new_path) && - file.new_path !== '.changeset/README.md' + file.new_path.startsWith(changesetDirPrefix) && + file.new_path.endsWith('.md') && + file.new_path !== `${changesetDirPrefix}README.md` ) }) } @@ -249,6 +256,12 @@ export const comment = async () => { return } + const { relative: relativeCwd } = getCwdInput() + + const cwdPrefix = relativeCwd ? `${relativeCwd}/` : '' + + const changesetDirPrefix = `${cwdPrefix}.changeset/` + const api = createApi() let errFromFetchingChangedFiles = '' @@ -273,15 +286,21 @@ export const comment = async () => { return changes }) + const packageChangedFiles = changedFilesPromise.then(changedFiles => + changedFiles.flatMap(({ new_path }) => + new_path.startsWith(cwdPrefix) + ? [new_path.slice(cwdPrefix.length)] + : [], + ), + ) + const [noteInfo, hasChangeset, { changedPackages, releasePlan }] = await Promise.all([ getNoteInfo(api, mrIid, commentType), - hasChangesetBeenAdded(changedFilesPromise), + hasChangesetBeenAdded(changedFilesPromise, changesetDirPrefix), getChangedPackages({ - changedFiles: changedFilesPromise.then(changedFiles => - changedFiles.map(({ new_path }) => new_path), - ), - api, + changedFiles: packageChangedFiles, + cwdPrefix, }).catch((err: unknown) => { if (err instanceof ValidationError) { errFromFetchingChangedFiles = `
💥 An error occurred when fetching the changed packages and changesets in this MR\n\n\`\`\`\n${err.message}\n\`\`\`\n\n
\n` @@ -295,7 +314,7 @@ export const comment = async () => { }), ] as const) - const newChangesetFileName = `.changeset/${humanId({ + const newChangesetFileName = `${changesetDirPrefix}${humanId({ separator: '-', capitalize: false, })}.md` diff --git a/src/get-changed-packages.ts b/src/get-changed-packages.ts index 4e73dd91..b8934190 100644 --- a/src/get-changed-packages.ts +++ b/src/get-changed-packages.ts @@ -1,5 +1,5 @@ import fs from 'node:fs/promises' -import nodePath from 'node:path' +import path from 'node:path' import assembleReleasePlan from '@changesets/assemble-release-plan' import { parse as parseConfig } from '@changesets/config' @@ -10,30 +10,29 @@ import type { NewChangeset, WrittenConfig, } from '@changesets/types' -import type { Gitlab } from '@gitbeaker/core' import type { Packages, Tool } from '@manypkg/get-packages' import micromatch from 'micromatch' import { parse } from 'yaml' import { getAllFiles } from './utils.js' -function fetchFile(path: string) { - return fs.readFile(path, 'utf8') -} - export const getChangedPackages = async ({ changedFiles: changedFilesPromise, + cwdPrefix = '', }: { changedFiles: Promise | string[] - api: Gitlab + cwdPrefix?: string // eslint-disable-next-line sonarjs/cognitive-complexity }) => { + function fetchFile(path: string) { + return fs.readFile(`${cwdPrefix}${path}`, 'utf8') + } + let hasErrored = false async function fetchJsonFile(path: string) { try { - const x = await fetchFile(path) - return JSON.parse(x) as T + return JSON.parse(await fetchFile(path)) as T } catch (err) { hasErrored = true console.error(err) @@ -53,7 +52,7 @@ export const getChangedPackages = async ({ async function getPackage(pkgPath: string) { const jsonContent = await fetchJsonFile( - pkgPath + '/package.json', + `${pkgPath}/package.json`, ) return { packageJson: jsonContent, @@ -75,7 +74,7 @@ export const getChangedPackages = async ({ >('package.json') const configPromise = fetchJsonFile('.changeset/config.json') - const tree = await getAllFiles(process.cwd()) + const tree = await getAllFiles(cwdPrefix) let preStatePromise: Promise | undefined const changesetPromises: Array> = [] @@ -85,12 +84,12 @@ export const getChangedPackages = async ({ for (const item of tree) { if (item.endsWith('/package.json')) { - const dirPath = nodePath.dirname(item) + const dirPath = path.dirname(item) potentialWorkspaceDirectories.push(dirPath) } else if (item === 'pnpm-workspace.yaml') { isPnpm = true } else if (item === '.changeset/pre.json') { - preStatePromise = fetchJsonFile('.changeset/pre.json') + preStatePromise = fetchJsonFile(item) } else if ( item !== '.changeset/README.md' && item.startsWith('.changeset') && diff --git a/src/main.ts b/src/main.ts index 05232db1..6fd72123 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ import { TRUTHY_VALUES, execSync, fileExists, + getCwdInput, getOptionalInput, getUsername, } from './utils.js' @@ -52,7 +53,9 @@ export const main = async ({ ) } - const { changesets } = await readChangesetState() + const { absolute: cwd } = getCwdInput() + + const { changesets } = await readChangesetState(cwd) const publishScript = getInput('publish') const hasChangesets = changesets.length > 0 @@ -111,6 +114,7 @@ export const main = async ({ createGitlabReleases: !FALSY_VALUES.has( getInput('create_gitlab_releases'), ), + cwd, }) if (result.published) { @@ -133,6 +137,7 @@ export const main = async ({ commitMessage: getOptionalInput('commit'), removeSourceBranch: getInput('remove_source_branch') === 'true', hasPublishScript, + cwd, }) if (onlyChangesets) { execSync(onlyChangesets) diff --git a/src/utils.ts b/src/utils.ts index cb291ad5..de753528 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -138,6 +138,7 @@ export const identify = ( > => !!_ export async function getAllFiles(dir: string, base = dir): Promise { + dir ||= '.' const direntList = await fs.readdir(dir, { withFileTypes: true }) const files = await Promise.all( // eslint-disable-next-line sonarjs/function-return-type, @typescript-eslint/await-thenable @@ -157,6 +158,24 @@ export const execSync = (command: string) => export const getOptionalInput = (name: string) => getInput(name) || undefined +export const getCwdInput = (): { relative: string; absolute: string } => { + const CWD = process.cwd() + const input = getOptionalInput('cwd') + if (!input) { + return { relative: '', absolute: CWD } + } + const absolute = path.resolve(CWD, input) + const relative = path.relative(CWD, absolute) + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`Invalid cwd input: "${input}"`) + } + return { relative, absolute } +} + // eslint-disable-next-line sonarjs/function-return-type export const getUsername = (api: Gitlab) => { return ( diff --git a/test/get-changed-packages.spec.ts b/test/get-changed-packages.spec.ts index 16a0078a..dd84a257 100644 --- a/test/get-changed-packages.spec.ts +++ b/test/get-changed-packages.spec.ts @@ -56,7 +56,6 @@ describe('getChangedPackages', () => { test('does not match sibling packages with the same prefix', async () => { const result = await getChangedPackages({ changedFiles: ['packages/ui-kit-storybook/src/index.ts'], - api: undefined as never, }) expect(result.changedPackages).toEqual([]) @@ -65,7 +64,6 @@ describe('getChangedPackages', () => { test('matches files within the package directory', async () => { const result = await getChangedPackages({ changedFiles: ['packages/ui-kit/src/index.ts'], - api: undefined as never, }) expect(result.changedPackages).toEqual(['@example/ui-kit']) diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 74574f6c..bc05fea4 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -1,7 +1,46 @@ -import { getAllFiles } from '../src/utils.js' +import path from 'node:path' + +import { getAllFiles, getCwdInput } from '../src/utils.js' describe('utils', () => { test('getAllFiles', async () => { expect(await getAllFiles('test/fixtures')).toMatchSnapshot() }) + + test('getAllFiles with empty string', async () => { + const files = await getAllFiles('') + // Should treat empty string as cwd and not throw + expect(Array.isArray(files)).toBe(true) + expect(files.length).toBeGreaterThan(0) + }) + + describe('getCwdInput', () => { + const CWD = process.cwd() + + afterEach(() => { + delete process.env.INPUT_CWD + }) + + test('returns cwd when no input set', () => { + expect(getCwdInput()).toEqual({ relative: '', absolute: CWD }) + }) + + test('returns relative and absolute for valid subdirectory', () => { + process.env.INPUT_CWD = 'src' + expect(getCwdInput()).toEqual({ + relative: 'src', + absolute: path.join(CWD, 'src'), + }) + }) + + test('throws for parent directory traversal', () => { + process.env.INPUT_CWD = '..' + expect(() => getCwdInput()).toThrow('Invalid cwd input') + }) + + test('throws for path with ../', () => { + process.env.INPUT_CWD = 'foo/../../bar' + expect(() => getCwdInput()).toThrow('Invalid cwd input') + }) + }) }) diff --git a/yarn.lock b/yarn.lock index fc5e2567..6be0d71f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4472,12 +4472,12 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-istanbul@npm:3.1.2": - version: 3.1.2 - resolution: "@vitest/coverage-istanbul@npm:3.1.2" +"@vitest/coverage-istanbul@npm:^3.2.7": + version: 3.2.7 + resolution: "@vitest/coverage-istanbul@npm:3.2.7" dependencies: "@istanbuljs/schema": "npm:^0.1.3" - debug: "npm:^4.4.0" + debug: "npm:^4.4.1" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-instrument: "npm:^6.0.3" istanbul-lib-report: "npm:^3.0.1" @@ -4487,8 +4487,8 @@ __metadata: test-exclude: "npm:^7.0.1" tinyrainbow: "npm:^2.0.0" peerDependencies: - vitest: 3.1.2 - checksum: 10c0/0b01b9c76495b63d303e34ad113ecd32fc21fcc6f212f9b3dd0771f5f89d8c2726fe2d62c51e88e5a738fa7f7088645d84a7257adaad1e569420a13ee1d1c48a + vitest: 3.2.7 + checksum: 10c0/4a322a9aca6f321efed553c24cff5e02cd2d786bae3d79eadf871cfc22e4d8818e166edc18c0e2826dbbabbbd4c35a89e703b6a0dae2cc6d8c2d40ebf444e06c languageName: node linkType: hard @@ -5506,7 +5506,7 @@ __metadata: "@types/global-agent": "npm:^3.0.0" "@types/micromatch": "npm:^4.0.9" "@types/web": "npm:^0.0.228" - "@vitest/coverage-istanbul": "npm:3.1.2" + "@vitest/coverage-istanbul": "npm:^3.2.7" clean-pkg-json: "npm:^1.2.1" commander: "npm:^13.1.0" dotenv: "npm:^16.5.0" @@ -5531,7 +5531,7 @@ __metadata: type-coverage: "npm:^2.29.7" typescript: "npm:^5.8.3" unified: "npm:^11.0.5" - vitest: "npm:^3.1.1" + vitest: "npm:^3.2.7" yaml: "npm:^2.7.1" yarn-berry-deduplicate: "npm:^6.1.1" yarn-deduplicate: "npm:^6.0.2" @@ -13467,7 +13467,7 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^3.1.1": +"vitest@npm:^3.2.7": version: 3.2.7 resolution: "vitest@npm:3.2.7" dependencies: