From c2b099f5428bdac6e6a1e031a5bc96bd36935ab5 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 27 Jan 2024 09:07:45 -0800 Subject: [PATCH] Add `chronus status` command (#9) * Status * tweak * Verify --- cspell.yaml | 1 + package.json | 1 + packages/chronus/package.json | 1 + packages/chronus/src/change/find.ts | 123 ++++++++++++++++++ packages/chronus/src/change/index.ts | 1 + packages/chronus/src/cli/cli.ts | 2 + .../chronus/src/cli/commands/add-changeset.ts | 39 +++--- .../src/cli/commands/verify-changeset.ts | 39 ++++++ packages/chronus/src/testing/test-host.ts | 6 +- packages/chronus/src/utils/fs-utils.ts | 4 +- packages/chronus/src/utils/host.ts | 2 +- packages/chronus/src/utils/node-host.ts | 4 +- .../chronus/src/workspace-manager/pnpm.ts | 8 +- pnpm-lock.yaml | 49 ++++++- 14 files changed, 250 insertions(+), 30 deletions(-) create mode 100644 packages/chronus/src/change/find.ts create mode 100644 packages/chronus/src/change/index.ts create mode 100644 packages/chronus/src/cli/commands/verify-changeset.ts diff --git a/cspell.yaml b/cspell.yaml index afbeb04d..0ff77470 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -5,6 +5,7 @@ dictionaries: - node - typescript words: + - picocolors ignorePaths: - "**/node_modules/**" - "**/dist/**" diff --git a/package.json b/package.json index 9f58860c..54ccc794 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-unicorn": "^50.0.1", "prettier": "^3.2.4", + "prettier-plugin-organize-imports": "^3.2.4", "rimraf": "^5.0.5", "syncpack": "^12.3.0", "typescript": "^5.3.3" diff --git a/packages/chronus/package.json b/packages/chronus/package.json index f4e27b0d..3b572a99 100644 --- a/packages/chronus/package.json +++ b/packages/chronus/package.json @@ -31,6 +31,7 @@ }, "homepage": "https://github.com/timotheeguerin/chronus#readme", "dependencies": { + "@changesets/parse": "^0.4.0", "@changesets/write": "^0.3.0", "js-yaml": "^4.1.0", "micromatch": "^4.0.5", diff --git a/packages/chronus/src/change/find.ts b/packages/chronus/src/change/find.ts new file mode 100644 index 00000000..6119c1ae --- /dev/null +++ b/packages/chronus/src/change/find.ts @@ -0,0 +1,123 @@ +import type { GitRepository } from "../source-control/git.js"; +import type { ChronusHost } from "../utils/host.js"; +import type { Package, Workspace } from "../workspace-manager/types.js"; +import readChangeset from "@changesets/parse"; + +export type ChangeArea = "committed" | "untrackedOrModified" | "staged"; + +export interface PackageStatus { + readonly package: Package; + readonly changed?: ChangeArea; + readonly documented?: ChangeArea; +} +export interface AreaStatus { + readonly filesChanged: string[]; + readonly packageChanged: Package[]; + readonly packagesDocumented: Package[]; +} + +export interface ChangeStatus { + readonly packages: ReadonlyMap; + readonly committed: AreaStatus; + readonly untrackedOrModified: AreaStatus; + readonly staged: AreaStatus; + readonly all: AreaStatus; +} + +export async function findChangeStatus( + host: ChronusHost, + sourceControl: GitRepository, + workspace: Workspace, +): Promise { + const filesChanged = await sourceControl.listChangedFilesFromBase(); + const untrackedOrModifiedFiles = await sourceControl.listUntrackedOrModifiedFiles(); + const stagedFiles = await sourceControl.listUntrackedOrModifiedFiles(); + const publicPackages = workspace.packages.filter((x) => !x.manifest.private); + + const committed = await findAreaStatus(host, publicPackages, filesChanged); + const untrackedOrModified = await findAreaStatus(host, publicPackages, untrackedOrModifiedFiles); + const staged = await findAreaStatus(host, publicPackages, stagedFiles); + const packages = new Map(); + function track(tracking: Package[], data: { readonly changed?: ChangeArea; readonly documented?: ChangeArea }) { + for (const pkg of tracking) { + const existing = packages.get(pkg.name); + if (existing) { + packages.set(pkg.name, { ...existing, ...data }); + } else { + packages.set(pkg.name, { package: pkg, ...data }); + } + } + } + + track(untrackedOrModified.packageChanged, { changed: "untrackedOrModified" }); + track(untrackedOrModified.packagesDocumented, { documented: "untrackedOrModified" }); + track(staged.packageChanged, { changed: "staged" }); + track(staged.packagesDocumented, { documented: "staged" }); + track(committed.packageChanged, { changed: "committed" }); + track(committed.packagesDocumented, { documented: "committed" }); + track(publicPackages, {}); + + return { + packages, + committed, + untrackedOrModified, + staged, + all: { + filesChanged: [ + ...new Set([...committed.filesChanged, ...untrackedOrModified.filesChanged, ...staged.filesChanged]), + ], + packageChanged: [ + ...new Set([...committed.packageChanged, ...untrackedOrModified.packageChanged, ...staged.packageChanged]), + ], + packagesDocumented: [ + ...new Set([ + ...committed.packagesDocumented, + ...untrackedOrModified.packagesDocumented, + ...staged.packagesDocumented, + ]), + ], + }, + }; +} + +async function findAreaStatus(host: ChronusHost, packages: Package[], filesChanged: string[]): Promise { + return { + filesChanged, + packageChanged: findPackageChanges(packages, filesChanged), + packagesDocumented: await findAlreadyDocumentedChanges(host, packages, filesChanged), + }; +} + +/** Find which packages changed from the file changed. */ +function findPackageChanges(packages: Package[], fileChanged: string[]): Package[] { + const packageChanged = new Set(); + + for (const file of fileChanged) { + const pkg = packages.find((x) => file.startsWith(x.relativePath + "/")); + if (pkg) { + packageChanged.add(pkg); + } + } + return [...packageChanged]; +} + +/** Find which packages changed from the file changed. */ +async function findAlreadyDocumentedChanges( + host: ChronusHost, + packages: Package[], + fileChanged: string[], +): Promise { + const packagesWithChangelog = new Set(); + + for (const filename of fileChanged.filter((x) => x.startsWith(".changeset/") && x.endsWith(".md"))) { + const file = await host.readFile(filename); + const changeset = readChangeset(file.content); + for (const release of changeset.releases) { + const pkg = packages.find((x) => x.name === release.name); + if (pkg) { + packagesWithChangelog.add(pkg); + } + } + } + return [...packagesWithChangelog]; +} diff --git a/packages/chronus/src/change/index.ts b/packages/chronus/src/change/index.ts new file mode 100644 index 00000000..056712f0 --- /dev/null +++ b/packages/chronus/src/change/index.ts @@ -0,0 +1 @@ +export { findChangeStatus } from "./find.js"; diff --git a/packages/chronus/src/cli/cli.ts b/packages/chronus/src/cli/cli.ts index 3f83b007..e9a50279 100644 --- a/packages/chronus/src/cli/cli.ts +++ b/packages/chronus/src/cli/cli.ts @@ -1,6 +1,7 @@ import "source-map-support/register.js"; import yargs from "yargs"; import { addChangeset } from "./commands/add-changeset.js"; +import { verifyChangeset } from "./commands/verify-changeset.js"; export const DEFAULT_PORT = 3000; @@ -19,6 +20,7 @@ async function main() { default: false, }) .command("add", "Add a new changeset", () => addChangeset(process.cwd())) + .command("verify", "Verify all packages changes have been documented", () => verifyChangeset(process.cwd())) .parse(); } diff --git a/packages/chronus/src/cli/commands/add-changeset.ts b/packages/chronus/src/cli/commands/add-changeset.ts index 8e49d58b..f852372c 100644 --- a/packages/chronus/src/cli/commands/add-changeset.ts +++ b/packages/chronus/src/cli/commands/add-changeset.ts @@ -4,6 +4,9 @@ import { createPnpmWorkspaceManager } from "../../workspace-manager/pnpm.js"; import type { Package } from "../../workspace-manager/types.js"; import prompts from "prompts"; import writeChangeset from "@changesets/write"; +import { findChangeStatus } from "../../change/index.js"; +import type { ChangeStatus } from "../../change/find.js"; +import pc from "picocolors"; function log(...args: any[]) { // eslint-disable-next-line no-console @@ -14,11 +17,9 @@ export async function addChangeset(cwd: string): Promise { const pnpm = createPnpmWorkspaceManager(host); const workspace = await pnpm.load(cwd); const sourceControl = createGitSourceControl(workspace.path); - const filesChanged = await sourceControl.listChangedFilesFromBase(); + const status = await findChangeStatus(host, sourceControl, workspace); - const publicPackages = workspace.packages.filter((x) => !x.manifest.private); - const packageChanged = findPackageChanges(publicPackages, filesChanged); - const packageToInclude = await promptForPackages(publicPackages, packageChanged); + const packageToInclude = await promptForPackages(status); if (packageToInclude.length === 0) { log("No package selected. Exiting."); @@ -37,25 +38,29 @@ export async function addChangeset(cwd: string): Promise { log("Wrote changeset ", result); } -function findPackageChanges(packages: Package[], fileChanged: string[]): Package[] { - const packageChanged = new Set(); - - for (const file of fileChanged) { - const pkg = packages.find((x) => file.startsWith(x.relativePath + "/")); - if (pkg) { - packageChanged.add(pkg); - } - } - return [...packageChanged]; -} +async function promptForPackages(status: ChangeStatus): Promise { + const undocummentedPackages = status.committed.packageChanged.filter( + (x) => !status.all.packagesDocumented.find((y) => x.name === y.name), + ); + const documentedPackages = status.committed.packageChanged.filter((x) => + status.all.packagesDocumented.find((y) => x.name === y.name), + ); -async function promptForPackages(allPackages: Package[], packageChanged: Package[]): Promise { const response = await prompts({ type: "multiselect", name: "value", instructions: false, message: "Select packages to include in this changeset", - choices: packageChanged.map((x) => ({ title: x.name, value: x })), + choices: [ + ...undocummentedPackages.map((x) => ({ + title: x.name, + value: x, + })), + ...documentedPackages.map((x) => ({ + title: `${x.name} ${pc.green("(Already documented)")}`, + value: x, + })), + ], }); return response.value; } diff --git a/packages/chronus/src/cli/commands/verify-changeset.ts b/packages/chronus/src/cli/commands/verify-changeset.ts new file mode 100644 index 00000000..14cf915c --- /dev/null +++ b/packages/chronus/src/cli/commands/verify-changeset.ts @@ -0,0 +1,39 @@ +import { createGitSourceControl } from "../../source-control/git.js"; +import { NodechronusHost } from "../../utils/node-host.js"; +import { createPnpmWorkspaceManager } from "../../workspace-manager/pnpm.js"; +import { findChangeStatus } from "../../change/index.js"; +import pc from "picocolors"; +function log(...args: any[]) { + // eslint-disable-next-line no-console + console.log(...args); +} +export async function verifyChangeset(cwd: string): Promise { + const host = NodechronusHost; + const pnpm = createPnpmWorkspaceManager(host); + const workspace = await pnpm.load(cwd); + const sourceControl = createGitSourceControl(workspace.path); + const status = await findChangeStatus(host, sourceControl, workspace); + + const undocummentedPackages = [...status.packages.values()].filter((x) => x.changed && !x.documented); + const documentedPackages = [...status.packages.values()].filter((x) => x.changed && x.documented); + if (undocummentedPackages.length > 0) { + log(`There is undocummented changes. Run ${pc.cyan("chronus add")} to add a changeset.`); + log(""); + log(pc.red(`The following packages have changes but are not documented.`)); + for (const pkg of undocummentedPackages) { + log(pc.red(` - ${pkg.package.name}`)); + } + log(""); + log(pc.green("The following packages have already been documented:")); + + for (const pkg of documentedPackages) { + log(pc.green(` - ${pkg.package.name}`)); + } + process.exit(1); + } + + log(pc.green("All changed packages have been documented:")); + for (const pkg of documentedPackages) { + log(pc.green(` - ${pkg.package.name}`)); + } +} diff --git a/packages/chronus/src/testing/test-host.ts b/packages/chronus/src/testing/test-host.ts index 06743e6d..1ad3d8fe 100644 --- a/packages/chronus/src/testing/test-host.ts +++ b/packages/chronus/src/testing/test-host.ts @@ -1,15 +1,15 @@ -import type { GlobOptions, chronusHost } from "../utils/host.js"; +import type { GlobOptions, ChronusHost } from "../utils/host.js"; import micromatch from "micromatch"; import { getDirectoryPath } from "../utils/path-utils.js"; export interface TestHost { - host: chronusHost; + host: ChronusHost; addFile(path: string, content: string): void; } export function createTestHost(files: Record = {}): TestHost { const fs: Record = files; - const host: chronusHost = { + const host: ChronusHost = { readFile: (path: string) => { const content = fs[path]; return Promise.resolve({ path, content }); diff --git a/packages/chronus/src/utils/fs-utils.ts b/packages/chronus/src/utils/fs-utils.ts index b5835dfa..e933501f 100644 --- a/packages/chronus/src/utils/fs-utils.ts +++ b/packages/chronus/src/utils/fs-utils.ts @@ -1,7 +1,7 @@ import { getDirectoryPath } from "./path-utils.js"; -import type { chronusHost } from "./host.js"; +import type { ChronusHost } from "./host.js"; -export async function isPathAccessible(host: chronusHost, path: string): Promise { +export async function isPathAccessible(host: ChronusHost, path: string): Promise { try { await host.access(path); return true; diff --git a/packages/chronus/src/utils/host.ts b/packages/chronus/src/utils/host.ts index 00b9bff1..334e98c8 100644 --- a/packages/chronus/src/utils/host.ts +++ b/packages/chronus/src/utils/host.ts @@ -1,7 +1,7 @@ /** * */ -export interface chronusHost { +export interface ChronusHost { /** * Read a file. * @param path Path to the file. diff --git a/packages/chronus/src/utils/node-host.ts b/packages/chronus/src/utils/node-host.ts index 11fc77ae..0dee1f52 100644 --- a/packages/chronus/src/utils/node-host.ts +++ b/packages/chronus/src/utils/node-host.ts @@ -1,12 +1,12 @@ import { readFile, writeFile, access } from "fs/promises"; -import type { File, GlobOptions, chronusHost } from "./host.js"; +import type { File, GlobOptions, ChronusHost } from "./host.js"; import { normalizePath } from "./path-utils.js"; import { globby } from "globby"; /** * Implementation of chronus host using node apis. */ -export const NodechronusHost: chronusHost = { +export const NodechronusHost: ChronusHost = { async readFile(path): Promise { const normalizedPath = normalizePath(path); const buffer = await readFile(normalizedPath); diff --git a/packages/chronus/src/workspace-manager/pnpm.ts b/packages/chronus/src/workspace-manager/pnpm.ts index 248546d6..71a33c70 100644 --- a/packages/chronus/src/workspace-manager/pnpm.ts +++ b/packages/chronus/src/workspace-manager/pnpm.ts @@ -3,7 +3,7 @@ import { isPathAccessible, joinPaths, lookup, - type chronusHost, + type ChronusHost, chronusError, resolvePath, } from "../utils/index.js"; @@ -15,7 +15,7 @@ interface PnpmWorkspaceConfig { packages: string[]; } -export function createPnpmWorkspaceManager(host: chronusHost): WorkspaceManager { +export function createPnpmWorkspaceManager(host: ChronusHost): WorkspaceManager { return { async load(dir: string): Promise { const root = await lookup(dir, (current) => { @@ -47,7 +47,7 @@ export function createPnpmWorkspaceManager(host: chronusHost): WorkspaceManager }; } -export async function findPackagesFromPattern(host: chronusHost, root: string, pattern: string): Promise { +export async function findPackagesFromPattern(host: ChronusHost, root: string, pattern: string): Promise { const packageRoots = await host.glob(pattern, { baseDir: root, onlyDirectories: true, @@ -57,7 +57,7 @@ export async function findPackagesFromPattern(host: chronusHost, root: string, p return packages.filter(isDefined); } -async function tryLoadNodePackage(host: chronusHost, root: string, relativePath: string): Promise { +async function tryLoadNodePackage(host: ChronusHost, root: string, relativePath: string): Promise { const pkgJsonPath = resolvePath(root, relativePath, "package.json"); if (await isPathAccessible(host, pkgJsonPath)) { const file = await host.readFile(pkgJsonPath); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c049788..5f8f6203 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: prettier: specifier: ^3.2.4 version: 3.2.4 + prettier-plugin-organize-imports: + specifier: ^3.2.4 + version: 3.2.4(prettier@3.2.4)(typescript@5.3.3) rimraf: specifier: ^5.0.5 version: 5.0.5 @@ -39,6 +42,9 @@ importers: packages/chronus: dependencies: + '@changesets/parse': + specifier: ^0.4.0 + version: 0.4.0 '@changesets/write': specifier: ^0.3.0 version: 0.3.0 @@ -148,6 +154,13 @@ packages: regenerator-runtime: 0.14.1 dev: false + /@changesets/parse@0.4.0: + resolution: {integrity: sha512-TS/9KG2CdGXS27S+QxbZXgr8uPsP4yNJYb4BC2/NeFUj80Rni3TeD2qwWmabymxmrLo7JEsytXH1FbpKTbvivw==} + dependencies: + '@changesets/types': 6.0.0 + js-yaml: 3.14.1 + dev: false + /@changesets/types@6.0.0: resolution: {integrity: sha512-b1UkfNulgKoWfqyHtzKS5fOZYSJO+77adgL7DLRDr+/7jhChN+QcHnbjiQVOz/U+Ts3PGNySq7diAItzDgugfQ==} dev: false @@ -1200,6 +1213,12 @@ packages: engines: {node: '>=12'} dev: true + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + dev: false + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2101,7 +2120,6 @@ packages: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true - dev: true /esquery@1.5.0: resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} @@ -2747,6 +2765,14 @@ packages: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: true + /js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + dev: false + /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3234,6 +3260,23 @@ packages: engines: {node: '>= 0.8.0'} dev: true + /prettier-plugin-organize-imports@3.2.4(prettier@3.2.4)(typescript@5.3.3): + resolution: {integrity: sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog==} + peerDependencies: + '@volar/vue-language-plugin-pug': ^1.0.4 + '@volar/vue-typescript': ^1.0.4 + prettier: '>=2.0' + typescript: '>=2.9' + peerDependenciesMeta: + '@volar/vue-language-plugin-pug': + optional: true + '@volar/vue-typescript': + optional: true + dependencies: + prettier: 3.2.4 + typescript: 5.3.3 + dev: true + /prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -3573,6 +3616,10 @@ packages: resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==} dev: true + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + dev: false + /stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: false