diff --git a/node-src/git/git.test.ts b/node-src/git/git.test.ts index 31ea11d8c..746de1edf 100644 --- a/node-src/git/git.test.ts +++ b/node-src/git/git.test.ts @@ -4,7 +4,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { findFilesFromRepositoryRoot, getCommit, + getCommittedFileCount, + getNumberOfComitters, + getRepositoryCreationDate, getSlug, + getStorybookCreationDate, hasPreviousCommit, mergeQueueBranchMatch, NULL_BYTE, @@ -145,3 +149,53 @@ describe('findFilesFromRepositoryRoot', () => { expect(results).toEqual(filesFound); }); }); + +describe('getRepositoryCreationDate', () => { + it('parses the date successfully', async () => { + command.mockImplementation(() => Promise.resolve({ all: `2017-05-17 10:00:35 -0700` }) as any); + expect(await getRepositoryCreationDate()).toEqual(new Date('2017-05-17T17:00:35.000Z')); + }); +}); + +describe('getStorybookCreationDate', () => { + it('passes the config dir to the git command', async () => { + await getStorybookCreationDate({ options: { storybookConfigDir: 'special-config-dir' } }); + expect(command).toHaveBeenCalledWith( + expect.stringMatching(/special-config-dir/), + expect.anything() + ); + }); + + it('defaults the config dir to the git command', async () => { + await getStorybookCreationDate({ options: {} }); + expect(command).toHaveBeenCalledWith(expect.stringMatching(/.storybook/), expect.anything()); + }); + + it('parses the date successfully', async () => { + command.mockImplementation(() => Promise.resolve({ all: `2017-05-17 10:00:35 -0700` }) as any); + expect( + await getStorybookCreationDate({ options: { storybookConfigDir: '.storybook' } }) + ).toEqual(new Date('2017-05-17T17:00:35.000Z')); + }); +}); + +describe('getNumberOfComitters', () => { + it('parses the count successfully', async () => { + command.mockImplementation(() => Promise.resolve({ all: ` 17` }) as any); + expect(await getNumberOfComitters()).toEqual(17); + }); +}); + +describe('getCommittedFileCount', () => { + it('constructs the correct command', async () => { + await getCommittedFileCount(['page', 'screen'], ['js', 'ts']); + expect(command).toHaveBeenCalledWith( + 'git ls-files -- "*page*.js" "*page*.ts" "*Page*.js" "*Page*.ts" "*screen*.js" "*screen*.ts" "*Screen*.js" "*Screen*.ts" | wc -l', + expect.anything() + ); + }); + it('parses the count successfully', async () => { + command.mockImplementation(() => Promise.resolve({ all: ` 17` }) as any); + expect(await getCommittedFileCount(['page', 'screen'], ['js', 'ts'])).toEqual(17); + }); +}); diff --git a/node-src/git/git.ts b/node-src/git/git.ts index 5621f1c3f..36e716f63 100644 --- a/node-src/git/git.ts +++ b/node-src/git/git.ts @@ -422,3 +422,68 @@ export async function mergeQueueBranchMatch(branch: string) { return match ? Number(match[1]) : undefined; } + +/** + * Determine the date the repository was created + * + * @returns Date The date the repository was created + */ +export async function getRepositoryCreationDate() { + const dateString = await execGitCommand(`git log --reverse --format=%cd --date=iso | head -1`); + return dateString ? new Date(dateString) : undefined; +} + +/** + * Determine the date the storybook was added to the repository + * + * @param ctx Context The context set when executing the CLI. + * @param ctx.options Object standard context options + * @param ctx.options.storybookConfigDir Configured Storybook config dir, if set + * + * @returns Date The date the storybook was added + */ +export async function getStorybookCreationDate(ctx: { + options: { + storybookConfigDir?: Context['options']['storybookConfigDir']; + }; +}) { + const configDirectory = ctx.options.storybookConfigDir ?? '.storybook'; + const dateString = await execGitCommand( + `git log --follow --reverse --format=%cd --date=iso -- ${configDirectory} | head -1` + ); + return dateString ? new Date(dateString) : undefined; +} + +/** + * Determine the number of committers in the last 6 months + * + * @returns number The number of committers + */ +export async function getNumberOfComitters() { + const numberString = await execGitCommand( + `git shortlog -sn --all --since="6 months ago" | wc -l` + ); + return numberString ? Number.parseInt(numberString, 10) : undefined; +} + +/** + * Find the number of files in the git index that include a name with the given prefixes. + * + * @param nameMatches The names to match - will be matched with upper and lowercase first letter + * @param extensions The filetypes to match + * + * @returns The number of files matching the above + */ +export async function getCommittedFileCount(nameMatches: string[], extensions: string[]) { + const bothCasesNameMatches = nameMatches.flatMap((match) => [ + match, + [match[0].toUpperCase(), ...match.slice(1)].join(''), + ]); + + const globs = bothCasesNameMatches.flatMap((match) => + extensions.map((extension) => `"*${match}*.${extension}"`) + ); + + const numberString = await execGitCommand(`git ls-files -- ${globs.join(' ')} | wc -l`); + return numberString ? Number.parseInt(numberString, 10) : undefined; +} diff --git a/node-src/index.test.ts b/node-src/index.test.ts index 1e2f4f2eb..6828507aa 100644 --- a/node-src/index.test.ts +++ b/node-src/index.test.ts @@ -314,6 +314,10 @@ vi.mock('./git/git', () => ({ getUncommittedHash: () => Promise.resolve('abc123'), getUserEmail: () => Promise.resolve('test@test.com'), mergeQueueBranchMatch: () => Promise.resolve(undefined), + getRepositoryCreationDate: () => Promise.resolve(new Date('2024-11-01')), + getStorybookCreationDate: () => Promise.resolve(new Date('2025-11-01')), + getNumberOfComitters: () => Promise.resolve(17), + getCommittedFileCount: () => Promise.resolve(100), })); vi.mock('./git/getParentCommits', () => ({ @@ -325,6 +329,8 @@ const getSlug = vi.mocked(git.getSlug); vi.mock('./lib/emailHash'); +vi.mock('./lib/getHasRouter'); + vi.mock('./lib/getFileHashes', () => ({ getFileHashes: (files: string[]) => Promise.resolve(Object.fromEntries(files.map((f) => [f, 'hash']))), diff --git a/node-src/lib/getHasRouter.test.ts b/node-src/lib/getHasRouter.test.ts new file mode 100644 index 000000000..baea9caa7 --- /dev/null +++ b/node-src/lib/getHasRouter.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest'; + +import { getHasRouter } from './getHasRouter'; + +it('returns true if there is a routing package in package.json', async () => { + expect( + getHasRouter({ + dependencies: { + react: '^18', + 'react-dom': '^18', + 'react-router': '^6', + }, + }) + ).toBe(true); +}); + +it('sreturns false if there is a routing package in package.json dependenices', async () => { + expect( + getHasRouter({ + dependencies: { + react: '^18', + 'react-dom': '^18', + }, + devDependencies: { + 'react-router': '^6', + }, + }) + ).toBe(false); +}); diff --git a/node-src/lib/getHasRouter.ts b/node-src/lib/getHasRouter.ts new file mode 100644 index 000000000..f03203a69 --- /dev/null +++ b/node-src/lib/getHasRouter.ts @@ -0,0 +1,38 @@ +import type { Context } from '../types'; + +const routerPackages = new Set([ + 'react-router', + 'react-router-dom', + 'remix', + '@tanstack/react-router', + 'expo-router', + '@reach/router', + 'react-easy-router', + '@remix-run/router', + 'wouter', + 'wouter-preact', + 'preact-router', + 'vue-router', + 'unplugin-vue-router', + '@angular/router', + '@solidjs/router', + + // metaframeworks that imply routing + 'next', + 'react-scripts', + 'gatsby', + 'nuxt', + '@sveltejs/kit', +]); + +/** + * @param packageJson The package JSON of the project (from context) + * + * @returns boolean Does this project use a routing package? + */ +export function getHasRouter(packageJson: Context['packageJson']) { + // NOTE: we just check real dependencies; if it is in dev dependencies, it may just be an example + return Object.keys(packageJson?.dependencies ?? {}).some((depName) => + routerPackages.has(depName) + ); +} diff --git a/node-src/tasks/gitInfo.test.ts b/node-src/tasks/gitInfo.test.ts index ba122dbe7..64fb705ee 100644 --- a/node-src/tasks/gitInfo.test.ts +++ b/node-src/tasks/gitInfo.test.ts @@ -5,6 +5,7 @@ import { getChangedFilesWithReplacement as getChangedFilesWithReplacementUnmocke import * as getCommitInfo from '../git/getCommitAndBranch'; import { getParentCommits as getParentCommitsUnmocked } from '../git/getParentCommits'; import * as git from '../git/git'; +import { getHasRouter as getHasRouterUnmocked } from '../lib/getHasRouter'; import { setGitInfo } from './gitInfo'; vi.mock('../git/getCommitAndBranch'); @@ -12,15 +13,21 @@ vi.mock('../git/git'); vi.mock('../git/getParentCommits'); vi.mock('../git/getBaselineBuilds'); vi.mock('../git/getChangedFilesWithReplacement'); +vi.mock('../lib/getHasRouter'); const getCommitAndBranch = vi.mocked(getCommitInfo.default); const getChangedFilesWithReplacement = vi.mocked(getChangedFilesWithReplacementUnmocked); const getSlug = vi.mocked(git.getSlug); const getVersion = vi.mocked(git.getVersion); const getUserEmail = vi.mocked(git.getUserEmail); +const getRepositoryCreationDate = vi.mocked(git.getRepositoryCreationDate); +const getStorybookCreationDate = vi.mocked(git.getStorybookCreationDate); +const getNumberOfComitters = vi.mocked(git.getNumberOfComitters); +const getCommittedFileCount = vi.mocked(git.getCommittedFileCount); const getUncommittedHash = vi.mocked(git.getUncommittedHash); const getBaselineBuilds = vi.mocked(getBaselineBuildsUnmocked); const getParentCommits = vi.mocked(getParentCommitsUnmocked); +const getHasRouter = vi.mocked(getHasRouterUnmocked); const log = { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }; @@ -47,6 +54,12 @@ beforeEach(() => { getVersion.mockResolvedValue('Git v1.0.0'); getUserEmail.mockResolvedValue('user@email.com'); getSlug.mockResolvedValue('user/repo'); + getRepositoryCreationDate.mockResolvedValue(new Date('2024-11-01')); + getStorybookCreationDate.mockResolvedValue(new Date('2025-11-01')); + getNumberOfComitters.mockResolvedValue(17); + getCommittedFileCount.mockResolvedValue(100); + getHasRouter.mockReturnValue(true); + client.runQuery.mockReturnValue({ app: { isOnboarding: false } }); }); @@ -164,4 +177,16 @@ describe('setGitInfo', () => { await setGitInfo(ctx, {} as any); expect(ctx.git.branch).toBe('repo'); }); + + it('sets projectMetadata on context', async () => { + const ctx = { log, options: { isLocalBuild: true }, client } as any; + await setGitInfo(ctx, {} as any); + expect(ctx.projectMetadata).toMatchObject({ + hasRouter: true, + creationDate: new Date('2024-11-01'), + storybookCreationDate: new Date('2025-11-01'), + numberOfCommitters: 17, + numberOfAppFiles: 100, + }); + }); }); diff --git a/node-src/tasks/gitInfo.ts b/node-src/tasks/gitInfo.ts index 1a5e411b8..2b5a0e8c2 100644 --- a/node-src/tasks/gitInfo.ts +++ b/node-src/tasks/gitInfo.ts @@ -4,7 +4,17 @@ import { getBaselineBuilds } from '../git/getBaselineBuilds'; import { getChangedFilesWithReplacement } from '../git/getChangedFilesWithReplacement'; import getCommitAndBranch from '../git/getCommitAndBranch'; import { getParentCommits } from '../git/getParentCommits'; -import { getSlug, getUncommittedHash, getUserEmail, getVersion } from '../git/git'; +import { + getCommittedFileCount, + getNumberOfComitters, + getRepositoryCreationDate, + getSlug, + getStorybookCreationDate, + getUncommittedHash, + getUserEmail, + getVersion, +} from '../git/git'; +import { getHasRouter } from '../lib/getHasRouter'; import { exitCodes, setExitCode } from '../lib/setExitCode'; import { createTask, transitionTo } from '../lib/tasks'; import { isPackageMetadataFile, matchesFile } from '../lib/utils'; @@ -87,6 +97,14 @@ export const setGitInfo = async (ctx: Context, task: Task) => { ...commitAndBranchInfo, }; + ctx.projectMetadata = { + hasRouter: getHasRouter(ctx.packageJson), + creationDate: await getRepositoryCreationDate(), + storybookCreationDate: await getStorybookCreationDate(ctx), + numberOfCommitters: await getNumberOfComitters(), + numberOfAppFiles: await getCommittedFileCount(['page', 'screen'], ['js', 'jsx', 'ts', 'tsx']), + }; + if (isLocalBuild && !ctx.git.gitUserEmail) { throw new Error(gitUserEmailNotFound()); } diff --git a/node-src/tasks/initialize.ts b/node-src/tasks/initialize.ts index 3bf69c80f..116ce4b84 100644 --- a/node-src/tasks/initialize.ts +++ b/node-src/tasks/initialize.ts @@ -107,6 +107,7 @@ export const announceBuild = async (ctx: Context) => { storybookAddons: ctx.storybook.addons, storybookVersion: ctx.storybook.version, storybookViewLayer: ctx.storybook.viewLayer, + projectMetadata: ctx.projectMetadata, }, }, { retries: 3 } diff --git a/node-src/tasks/storybookInfo.test.ts b/node-src/tasks/storybookInfo.test.ts index b1bc59a14..677a2d79c 100644 --- a/node-src/tasks/storybookInfo.test.ts +++ b/node-src/tasks/storybookInfo.test.ts @@ -12,7 +12,7 @@ describe('storybookInfo', () => { const storybook = { version: '1.0.0', viewLayer: 'react', addons: [] }; getStorybookInfo.mockResolvedValue(storybook); - const ctx = {} as any; + const ctx = { packageJson: {} } as any; await setStorybookInfo(ctx); expect(ctx.storybook).toEqual(storybook); }); diff --git a/node-src/types.ts b/node-src/types.ts index 1e39a0f1d..dc6f9e37e 100644 --- a/node-src/types.ts +++ b/node-src/types.ts @@ -250,6 +250,13 @@ export interface Context { }; mainConfigFilePath?: string; }; + projectMetadata: { + hasRouter?: boolean; + creationDate?: Date; + storybookCreationDate?: Date; + numberOfCommitters?: number; + numberOfAppFiles?: number; + }; storybookUrl?: string; announcedBuild: { id: string;