From 7c4836960481e5f1e11b1d470cb10cfa86ba5e53 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Thu, 15 Jun 2023 17:41:10 -0400 Subject: [PATCH] feat: add --ownership option --- README.md | 3 +- src/assertValidOwnership.test.ts | 19 ++++ src/assertValidOwnership.ts | 20 ++++ src/createUserPackagesFilter.test.ts | 149 +++++++++++++++++++++++++++ src/createUserPackagesFilter.ts | 48 +++++++++ src/packageOwnershipForms.ts | 3 + src/tideliftMeUp.test.ts | 55 ---------- src/tideliftMeUp.ts | 7 +- src/tideliftMeUpCli.test.ts | 8 +- src/tideliftMeUpCli.ts | 12 ++- vitest.config.ts | 2 +- 11 files changed, 263 insertions(+), 63 deletions(-) create mode 100644 src/assertValidOwnership.test.ts create mode 100644 src/assertValidOwnership.ts create mode 100644 src/createUserPackagesFilter.test.ts create mode 100644 src/createUserPackagesFilter.ts create mode 100644 src/packageOwnershipForms.ts delete mode 100644 src/tideliftMeUp.test.ts diff --git a/README.md b/README.md index 122a3b91..e85774a4 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,14 @@ npx tidelift-me-up ### Options +- `--ownership` _(default: `["author", "publisher"]`)_: If provided, any filters user packages must match one of based on username: `"author"`, `"maintainer"`, and/or `"publisher"` - `--since` _(default: 2 years ago)_: A date that packages need to have been updated since to be considered - This will be provided as a string to the `Date` constructor - `--username` _(default: result of `npm whoami`)_: The npm username to search for packages maintained by - The search is done by [`npm-user-packages`](https://github.com/kevva/npm-user-packages), which fetches from [npm.io](https://npm.io) ```shell -npx tidelift-me-up --since 2020 --username your-username +npx tidelift-me-up --ownership author --ownership publisher --since 2020 --username your-username ``` ## Development diff --git a/src/assertValidOwnership.test.ts b/src/assertValidOwnership.test.ts new file mode 100644 index 00000000..0e0a2af8 --- /dev/null +++ b/src/assertValidOwnership.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { assertValidOwnership } from "./assertValidOwnership.js"; + +describe("assertValidOwnership", () => { + it("does not throw an error when ownership is undefined", () => { + expect(() => assertValidOwnership(undefined)).not.toThrow(); + }); + + it("does not throw an error when ownership contains valid ownership", () => { + expect(() => assertValidOwnership(["author"])).not.toThrow(); + }); + + it("throws an error when ownership contains invalid ownership", () => { + expect(() => assertValidOwnership(["abc"])).toThrowError( + `Unknown --ownership: abc (must be one of: author, maintainer, publisher)` + ); + }); +}); diff --git a/src/assertValidOwnership.ts b/src/assertValidOwnership.ts new file mode 100644 index 00000000..24bf04b6 --- /dev/null +++ b/src/assertValidOwnership.ts @@ -0,0 +1,20 @@ +import { + PackageOwnershipForm, + packageOwnershipForms, +} from "./packageOwnershipForms.js"; + +export function assertValidOwnership( + ownership: string[] | undefined +): asserts ownership is PackageOwnershipForm[] | undefined { + if (ownership) { + for (const ownershipForm of ownership) { + if (!["author", "maintainer", "publisher"].includes(ownershipForm)) { + throw new Error( + `Unknown --ownership: ${ownershipForm} (must be one of: ${packageOwnershipForms.join( + ", " + )})` + ); + } + } + } +} diff --git a/src/createUserPackagesFilter.test.ts b/src/createUserPackagesFilter.test.ts new file mode 100644 index 00000000..a0352b0f --- /dev/null +++ b/src/createUserPackagesFilter.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import { + UserPackageData, + createUserPackagesFilter, +} from "./createUserPackagesFilter.js"; + +const username = "abc123"; + +const createPackageData = (overrides?: Partial) => + ({ + author: { name: "" }, + date: new Date().toString(), + maintainers: [], + publisher: { email: "", username: "" }, + ...overrides, + } satisfies UserPackageData); + +describe("createUserPackagesFilter", () => { + it("filters a package when its date predates since", () => { + const filter = createUserPackagesFilter({ + ownership: ["author"], + since: new Date(Date.now() - 10_000), + username, + }); + + const actual = filter( + createPackageData({ + author: { name: "", username }, + date: new Date(Date.now() - 20_000).toString(), + }) + ); + + expect(actual).toBe(false); + }); + + it("allows a package when its date is after since", () => { + const filter = createUserPackagesFilter({ + ownership: ["author"], + since: new Date(Date.now() - 20_000), + username, + }); + + const actual = filter( + createPackageData({ + author: { name: "", username }, + date: new Date(Date.now() - 10_000).toString(), + }) + ); + + expect(actual).toBe(true); + }); + + it("filters a package when author ownership doesn't match", () => { + const filter = createUserPackagesFilter({ + ownership: ["author"], + since: new Date(0), + username, + }); + + const actual = filter( + createPackageData({ + author: { name: "", username: "other" }, + }) + ); + + expect(actual).toBe(false); + }); + + it("allows a package when author ownership matches", () => { + const filter = createUserPackagesFilter({ + ownership: ["author"], + since: new Date(0), + username, + }); + + const actual = filter( + createPackageData({ + author: { name: "", username }, + }) + ); + + expect(actual).toBe(true); + }); + + it("filters a package when maintainer ownership doesn't match", () => { + const filter = createUserPackagesFilter({ + ownership: ["maintainer"], + since: new Date(0), + username, + }); + + const actual = filter( + createPackageData({ + maintainers: [{ email: "", username: "other" }], + }) + ); + + expect(actual).toBe(false); + }); + + it("allows a package when maintainer ownership matches", () => { + const filter = createUserPackagesFilter({ + ownership: ["maintainer"], + since: new Date(0), + username, + }); + + const actual = filter( + createPackageData({ + maintainers: [{ email: "", username }], + }) + ); + + expect(actual).toBe(true); + }); + + it("filters a package when publisher ownership doesn't match", () => { + const filter = createUserPackagesFilter({ + ownership: ["publisher"], + since: new Date(0), + username, + }); + + const actual = filter( + createPackageData({ + publisher: { email: "", username: "other" }, + }) + ); + + expect(actual).toBe(false); + }); + + it("allows a package when publisher ownership matches", () => { + const filter = createUserPackagesFilter({ + ownership: ["publisher"], + since: new Date(0), + username, + }); + + const actual = filter( + createPackageData({ + publisher: { email: "", username }, + }) + ); + + expect(actual).toBe(true); + }); +}); diff --git a/src/createUserPackagesFilter.ts b/src/createUserPackagesFilter.ts new file mode 100644 index 00000000..d4242826 --- /dev/null +++ b/src/createUserPackagesFilter.ts @@ -0,0 +1,48 @@ +import { PackageData } from "npm-user-packages"; + +import { PackageOwnershipForm } from "./packageOwnershipForms.js"; + +export interface FilterSettings { + ownership: PackageOwnershipForm[]; + since: Date; + username: string; +} + +export type UserPackageData = Pick< + PackageData, + "author" | "date" | "maintainers" | "publisher" +>; + +export function createUserPackagesFilter({ + ownership, + since, + username, +}: FilterSettings) { + return (userPackage: UserPackageData) => { + if (new Date(userPackage.date) < since) { + return false; + } + + if ( + !ownership.some((ownershipForm) => { + switch (ownershipForm) { + case "author": + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return userPackage.author?.username === username; + + case "maintainer": + return userPackage.maintainers.some( + (maintainer) => maintainer.username === username + ); + + case "publisher": + return userPackage.publisher.username === username; + } + }) + ) { + return false; + } + + return true; + }; +} diff --git a/src/packageOwnershipForms.ts b/src/packageOwnershipForms.ts new file mode 100644 index 00000000..b7344ede --- /dev/null +++ b/src/packageOwnershipForms.ts @@ -0,0 +1,3 @@ +export type PackageOwnershipForm = "author" | "maintainer" | "publisher"; + +export const packageOwnershipForms = ["author", "maintainer", "publisher"]; diff --git a/src/tideliftMeUp.test.ts b/src/tideliftMeUp.test.ts deleted file mode 100644 index fb8176b2..00000000 --- a/src/tideliftMeUp.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { tideliftMeUp } from "./tideliftMeUp.js"; - -const mockNpmUserPackages = vi.fn(); - -vi.mock("npm-user-packages", () => ({ - get default() { - return mockNpmUserPackages; - }, -})); - -const mockGetPackageEstimates = vi.fn(); - -vi.mock("./getPackageEstimates.js", () => ({ - get getPackageEstimates() { - return mockGetPackageEstimates; - }, -})); - -describe("tideliftMeUp", () => { - it("defaults since to two years ago when not provided", async () => { - const packageNameNew = "package-name-new"; - const packageNameOld = "package-name-old"; - - mockNpmUserPackages.mockResolvedValue([ - { date: new Date(), name: packageNameNew }, - { name: new Date(Date.now() - 1000), packageNameOld }, - ]); - - await tideliftMeUp({ username: "abc123" }); - - expect(mockGetPackageEstimates).toHaveBeenCalledWith([packageNameNew]); - }); - - it("filters to packages to since when provided", async () => { - const packageNameNew = "package-name-new"; - const packageNameOld = "package-name-old"; - - mockNpmUserPackages.mockResolvedValue([ - { date: new Date(Date.now()), name: packageNameNew }, - { date: new Date(Date.now() - 10_000), name: packageNameOld }, - ]); - - await tideliftMeUp({ - since: new Date(Date.now() - 20_000), - username: "abc123", - }); - - expect(mockGetPackageEstimates).toHaveBeenCalledWith([ - packageNameNew, - packageNameOld, - ]); - }); -}); diff --git a/src/tideliftMeUp.ts b/src/tideliftMeUp.ts index aa10afd2..d452e293 100644 --- a/src/tideliftMeUp.ts +++ b/src/tideliftMeUp.ts @@ -1,19 +1,22 @@ import npmUserPackages from "npm-user-packages"; +import { createUserPackagesFilter } from "./createUserPackagesFilter.js"; import { getPackageEstimates } from "./getPackageEstimates.js"; +import { PackageOwnershipForm } from "./packageOwnershipForms.js"; export interface TideliftMeUpSettings { + ownership?: PackageOwnershipForm[]; since?: Date | number | string; username: string; } export async function tideliftMeUp({ + ownership = ["author", "publisher"], since = getTwoYearsAgo(), username, }: TideliftMeUpSettings) { - const sinceDate = new Date(since); const userPackages = (await npmUserPackages(username)).filter( - (userPackage) => new Date(userPackage.date) >= sinceDate + createUserPackagesFilter({ ownership, since: new Date(since), username }) ); return await getPackageEstimates( diff --git a/src/tideliftMeUpCli.test.ts b/src/tideliftMeUpCli.test.ts index a27d5d4a..b08fb8d6 100644 --- a/src/tideliftMeUpCli.test.ts +++ b/src/tideliftMeUpCli.test.ts @@ -36,7 +36,9 @@ describe("tideliftMeUpCli", () => { await tideliftMeUpCli(["--username", username]); expect(mockGetNpmWhoami).not.toHaveBeenCalled(); - expect(mockTideliftMeUp).toHaveBeenCalledWith({ username }); + expect(mockTideliftMeUp).toHaveBeenCalledWith({ + username, + }); }); it("logs packages for a username when --username is not provided and the user is logged in", async () => { @@ -47,7 +49,9 @@ describe("tideliftMeUpCli", () => { await tideliftMeUpCli([]); - expect(mockTideliftMeUp).toHaveBeenCalledWith({ username }); + expect(mockTideliftMeUp).toHaveBeenCalledWith({ + username, + }); }); it("logs a package as already lifted when it is", async () => { diff --git a/src/tideliftMeUpCli.ts b/src/tideliftMeUpCli.ts index ab69cd90..60e7f1e1 100644 --- a/src/tideliftMeUpCli.ts +++ b/src/tideliftMeUpCli.ts @@ -1,6 +1,7 @@ import chalk from "chalk"; import { parseArgs } from "node:util"; +import { assertValidOwnership } from "./assertValidOwnership.js"; import { getNpmWhoami } from "./getNpmWhoami.js"; import { tideliftMeUp } from "./tideliftMeUp.js"; @@ -8,20 +9,27 @@ export async function tideliftMeUpCli(args: string[]) { const { values } = parseArgs({ args, options: { + ownership: { + multiple: true, + type: "string", + }, since: { type: "string" }, username: { type: "string" }, }, tokens: true, }); - const { since, username = await getNpmWhoami() } = values; + const { ownership, since, username = await getNpmWhoami() } = values; + + assertValidOwnership(ownership); + if (!username) { throw new Error( "Either log in to npm or provide a username with --username." ); } - const packageEstimates = await tideliftMeUp({ since, username }); + const packageEstimates = await tideliftMeUp({ ownership, since, username }); for (const packageEstimate of packageEstimates) { const currency = new Intl.NumberFormat("en-US", { diff --git a/vitest.config.ts b/vitest.config.ts index 2cddffb6..8710acf0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ clearMocks: true, coverage: { all: true, - exclude: ["lib", "src/cli.ts"], + exclude: ["lib", "src/cli.ts", "src/tideliftMeUp.ts"], include: ["src"], provider: "istanbul", reporter: ["html", "lcov"],