Skip to content

Commit

Permalink
Add chronus status command (#9)
Browse files Browse the repository at this point in the history
* Status

* tweak

* Verify
  • Loading branch information
timotheeguerin authored Jan 27, 2024
1 parent c7d0810 commit c2b099f
Show file tree
Hide file tree
Showing 14 changed files with 250 additions and 30 deletions.
1 change: 1 addition & 0 deletions cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ dictionaries:
- node
- typescript
words:
- picocolors
ignorePaths:
- "**/node_modules/**"
- "**/dist/**"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/chronus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
123 changes: 123 additions & 0 deletions packages/chronus/src/change/find.ts
Original file line number Diff line number Diff line change
@@ -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<string, PackageStatus>;
readonly committed: AreaStatus;
readonly untrackedOrModified: AreaStatus;
readonly staged: AreaStatus;
readonly all: AreaStatus;
}

export async function findChangeStatus(
host: ChronusHost,
sourceControl: GitRepository,
workspace: Workspace,
): Promise<ChangeStatus> {
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<string, PackageStatus>();
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<AreaStatus> {
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<Package>();

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<Package[]> {
const packagesWithChangelog = new Set<Package>();

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];
}
1 change: 1 addition & 0 deletions packages/chronus/src/change/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { findChangeStatus } from "./find.js";
2 changes: 2 additions & 0 deletions packages/chronus/src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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();
}

Expand Down
39 changes: 22 additions & 17 deletions packages/chronus/src/cli/commands/add-changeset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,11 +17,9 @@ export async function addChangeset(cwd: string): Promise<void> {
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.");
Expand All @@ -37,25 +38,29 @@ export async function addChangeset(cwd: string): Promise<void> {
log("Wrote changeset ", result);
}

function findPackageChanges(packages: Package[], fileChanged: string[]): Package[] {
const packageChanged = new Set<Package>();

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<Package[]> {
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<Package[]> {
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;
}
Expand Down
39 changes: 39 additions & 0 deletions packages/chronus/src/cli/commands/verify-changeset.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`));
}
}
6 changes: 3 additions & 3 deletions packages/chronus/src/testing/test-host.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): TestHost {
const fs: Record<string, string> = files;
const host: chronusHost = {
const host: ChronusHost = {
readFile: (path: string) => {
const content = fs[path];
return Promise.resolve({ path, content });
Expand Down
4 changes: 2 additions & 2 deletions packages/chronus/src/utils/fs-utils.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
export async function isPathAccessible(host: ChronusHost, path: string): Promise<boolean> {
try {
await host.access(path);
return true;
Expand Down
2 changes: 1 addition & 1 deletion packages/chronus/src/utils/host.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
*
*/
export interface chronusHost {
export interface ChronusHost {
/**
* Read a file.
* @param path Path to the file.
Expand Down
4 changes: 2 additions & 2 deletions packages/chronus/src/utils/node-host.ts
Original file line number Diff line number Diff line change
@@ -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<File> {
const normalizedPath = normalizePath(path);
const buffer = await readFile(normalizedPath);
Expand Down
8 changes: 4 additions & 4 deletions packages/chronus/src/workspace-manager/pnpm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
isPathAccessible,
joinPaths,
lookup,
type chronusHost,
type ChronusHost,
chronusError,
resolvePath,
} from "../utils/index.js";
Expand All @@ -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<Workspace> {
const root = await lookup(dir, (current) => {
Expand Down Expand Up @@ -47,7 +47,7 @@ export function createPnpmWorkspaceManager(host: chronusHost): WorkspaceManager
};
}

export async function findPackagesFromPattern(host: chronusHost, root: string, pattern: string): Promise<Package[]> {
export async function findPackagesFromPattern(host: ChronusHost, root: string, pattern: string): Promise<Package[]> {
const packageRoots = await host.glob(pattern, {
baseDir: root,
onlyDirectories: true,
Expand All @@ -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<Package | undefined> {
async function tryLoadNodePackage(host: ChronusHost, root: string, relativePath: string): Promise<Package | undefined> {
const pkgJsonPath = resolvePath(root, relativePath, "package.json");
if (await isPathAccessible(host, pkgJsonPath)) {
const file = await host.readFile(pkgJsonPath);
Expand Down
Loading

0 comments on commit c2b099f

Please sign in to comment.