Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: cache project context in toolbox to avoid passing it everywhere #115

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions __tests__/fail-fast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,36 @@ describe('fail fast scenarios', () => {
'The current directory is workspace root directory. Please run the script again from selected package root directory.'
)
})

test('fails when no lock file in repo root in non-monorepo', async () => {
const { appRoot } = TEST_PROJECTS['rn-setup-ci-yarn-flat']
setupTestProject('rn-setup-ci-yarn-flat')

await rm(join(appRoot, 'yarn.lock'))

const output = await cli(['--skip-git-check'], { cwd: appRoot })

expect(output).toContain(
'No lock file found in repository root directory. Are you sure you are in a project directory?'
)
expect(output).toContain(
'Make sure you generated lock file by installing project dependencies.'
)
})

test('fails when no lock file found in monorepo root', async () => {
const { appRoot, repoRoot } = TEST_PROJECTS['rn-setup-ci-yarn-monorepo']
setupTestProject('rn-setup-ci-yarn-monorepo')

await rm(join(repoRoot, 'yarn.lock'))

const output = await cli(['--skip-git-check'], { cwd: appRoot })

expect(output).toContain(
'No lock file found in repository root directory. Are you sure you are in a project directory?'
)
expect(output).toContain(
'Make sure you generated lock file by installing project dependencies.'
)
})
})
172 changes: 56 additions & 116 deletions src/commands/setup-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ import detox from '../recipes/detox'
import maestro from '../recipes/maestro'
import isGitDirty from 'is-git-dirty'
import sequentialPromiseMap from '../utils/sequentialPromiseMap'
import { CycliError, CycliRecipe, CycliToolbox, ProjectContext } from '../types'
import intersection from 'lodash/intersection'
import { CycliError, CycliRecipe, CycliToolbox } from '../types'
import {
CYCLI_COMMAND,
HELP_FLAG,
PRESET_FLAG,
REPOSITORY_FEATURES_HELP_URL,
REPOSITORY_METRICS_HELP_URL,
REPOSITORY_TROUBLESHOOTING_URL,
SKIP_TELEMETRY_FLAG,
Expand All @@ -41,70 +39,59 @@ const RECIPES = [
maestro,
]

const getSelectedOptions = async (toolbox: CycliToolbox): Promise<string[]> => {
if (toolbox.options.isPreset()) {
const featureFlags = RECIPES.map((option) => option.meta.flag)
// Try to obtain package manager and package root path.
// In case of failure, an error is thrown and cli exits early.
const validateProject = (toolbox: CycliToolbox) => {
toolbox.context.packageManager()
toolbox.context.path.packageRoot()
}

const selectedOptions = intersection(
featureFlags,
Object.keys(toolbox.parameters.options)
)
const checkGit = async (toolbox: CycliToolbox) => {
if (isGitDirty() == null) {
throw CycliError('This is not a git repository.')
}

RECIPES.forEach((recipe: CycliRecipe) => {
if (selectedOptions.includes(recipe.meta.flag)) {
try {
recipe.validate?.(toolbox)
} catch (error: unknown) {
const validationError = messageFromError(error)

// adding context to validation error reason (used in multiselect menu hint)
throw CycliError(
`Cannot generate ${recipe.meta.name} workflow in your project.\nReason: ${validationError}`
)
}
if (isGitDirty()) {
if (toolbox.parameters.options[SKIP_GIT_CHECK_FLAG]) {
toolbox.interactive.surveyWarning(
`Proceeding with dirty git repository as --${SKIP_GIT_CHECK_FLAG} option is enabled.`
)
} else {
if (toolbox.options.isPreset()) {
throw CycliError(
`You have to commit your changes before running with preset or use --${SKIP_GIT_CHECK_FLAG}.`
)
}
})

return selectedOptions
} else {
return await toolbox.interactive.multiselect(
'Select workflows you want to run on every PR',
`Learn more about PR workflows: ${REPOSITORY_FEATURES_HELP_URL}`,
RECIPES.map(
({ validate, meta: { name, flag, selectHint } }: CycliRecipe) => {
let validationError = ''
try {
validate?.(toolbox)
} catch (error: unknown) {
validationError = messageFromError(error)
}
const hint = validationError || selectHint
const disabled = Boolean(validationError)
return {
label: name,
value: flag,
hint,
disabled,
}
}

const proceed = await toolbox.interactive.confirm(
[
`It is advised to commit all your changes before running ${CYCLI_COMMAND}.`,
'Running the script with uncommitted changes may have destructive consequences.',
'Do you want to proceed anyway?\n',
].join('\n'),
{ type: 'warning' }
)
)

if (!proceed) {
toolbox.interactive.outro(
'Please commit your changes before running this command.'
)
return
}
}
}
}

const runReactNativeCiCli = async (
toolbox: CycliToolbox,
context: ProjectContext
) => {
const snapshotBefore = await toolbox.diff.gitStatus(context)
const runReactNativeCiCli = async (toolbox: CycliToolbox) => {
const snapshotBefore = await toolbox.diff.gitStatus()
toolbox.interactive.surveyStep(
'Created snapshot of project state before execution.'
)

context.selectedOptions = await getSelectedOptions(toolbox)
await toolbox.config.obtain(RECIPES)

const executors = RECIPES.filter((recipe: CycliRecipe) =>
context.selectedOptions.includes(recipe.meta.flag)
toolbox.config.selectedRecipes().includes(recipe.meta.flag)
).map((recipe: CycliRecipe) => recipe.execute)

if (executors.length === 0) {
Expand All @@ -113,23 +100,22 @@ const runReactNativeCiCli = async (
}

toolbox.interactive.surveyStep(
`Detected ${context.packageManager} as your package manager.`
`Detected ${toolbox.context.packageManager()} as your package manager.`
)

await sequentialPromiseMap(executors, (executor) =>
executor(toolbox, context)
)
await sequentialPromiseMap(executors, (executor) => executor(toolbox))

const snapshotAfter = await toolbox.diff.gitStatus(context)
const snapshotAfter = await toolbox.diff.gitStatus()
const diff = toolbox.diff.compare(snapshotBefore, snapshotAfter)

toolbox.prettier.formatFiles(Array.from(diff.keys()))

toolbox.diff.print(diff, context)
toolbox.diff.print(diff)

toolbox.furtherActions.print()

const usedFlags = context.selectedOptions
const usedFlags = toolbox.config
.selectedRecipes()
.map((flag: string) => `--${flag}`)
.join(' ')

Expand All @@ -143,42 +129,6 @@ const runReactNativeCiCli = async (
}
}

const checkGit = async (toolbox: CycliToolbox) => {
if (isGitDirty() == null) {
throw CycliError('This is not a git repository.')
}

if (isGitDirty()) {
if (toolbox.parameters.options[SKIP_GIT_CHECK_FLAG]) {
toolbox.interactive.surveyWarning(
`Proceeding with dirty git repository as --${SKIP_GIT_CHECK_FLAG} option is enabled.`
)
} else {
if (toolbox.options.isPreset()) {
throw CycliError(
`You have to commit your changes before running with preset or use --${SKIP_GIT_CHECK_FLAG}.`
)
}

const proceed = await toolbox.interactive.confirm(
[
`It is advised to commit all your changes before running ${CYCLI_COMMAND}.`,
'Running the script with uncommitted changes may have destructive consequences.',
'Do you want to proceed anyway?\n',
].join('\n'),
{ type: 'warning' }
)

if (!proceed) {
toolbox.interactive.outro(
'Please commit your changes before running this command.'
)
return
}
}
}
}

const run = async (toolbox: CycliToolbox) => {
toolbox.interactive.vspace()
toolbox.interactive.intro(` Welcome to npx ${CYCLI_COMMAND}! `)
Expand All @@ -194,18 +144,12 @@ const run = async (toolbox: CycliToolbox) => {
}

let finishedWithUnexpectedError = false
let context: ProjectContext | undefined

try {
await checkGit(toolbox as CycliToolbox)
validateProject(toolbox)

context = toolbox.projectContext.obtain()
toolbox.interactive.surveyStep('Obtained project context.')

await runReactNativeCiCli(
toolbox as CycliToolbox,
context as ProjectContext
)
await runReactNativeCiCli(toolbox as CycliToolbox)
} catch (error: unknown) {
toolbox.interactive.vspace()
let errMessage = messageFromError(error)
Expand All @@ -227,17 +171,13 @@ const run = async (toolbox: CycliToolbox) => {
if (!toolbox.options.skipTelemetry()) {
await toolbox.telemetry.sendLog({
version: toolbox.meta.version(),
firstUse: context?.firstUse,
options:
context &&
Object.fromEntries(
RECIPES.map((recipe) => [
recipe.meta.flag,
(context as ProjectContext).selectedOptions.includes(
recipe.meta.flag
),
])
),
firstUse: toolbox.context.isFirstUse(),
options: Object.fromEntries(
RECIPES.map((recipe) => [
recipe.meta.flag,
toolbox.config.selectedRecipes().includes(recipe.meta.flag),
])
),
error: finishedWithUnexpectedError,
})
}
Expand Down
81 changes: 81 additions & 0 deletions src/extensions/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
CycliError,
CycliRecipe,
CycliRecipeFlag,
CycliToolbox,
} from '../types'
import intersection from 'lodash/intersection'
import { messageFromError } from '../utils/errors'
import { REPOSITORY_FEATURES_HELP_URL } from '../constants'

module.exports = (toolbox: CycliToolbox) => {
// State for caching the config
let selectedRecipes: CycliRecipeFlag[] | undefined = undefined

const obtain = async (allRecipes: CycliRecipe[]): Promise<void> => {
if (toolbox.options.isPreset()) {
const allFlags = Object.values(CycliRecipeFlag)

selectedRecipes = intersection(
allFlags,
Object.keys(toolbox.parameters.options)
.filter((option) => allFlags.includes(option as CycliRecipeFlag))
.map((flag) => flag as CycliRecipeFlag)
)

allRecipes.forEach((recipe: CycliRecipe) => {
if (selectedRecipes?.includes(recipe.meta.flag)) {
try {
recipe.validate?.(toolbox)
} catch (error: unknown) {
const validationError = messageFromError(error)

// adding context to validation error reason (used in multiselect menu hint)
throw CycliError(
`Cannot generate ${recipe.meta.name} workflow in your project.\nReason: ${validationError}`
)
}
}
})
} else {
selectedRecipes = (await toolbox.interactive.multiselect(
'Select workflows you want to run on every PR',
`Learn more about PR workflows: ${REPOSITORY_FEATURES_HELP_URL}`,
allRecipes.map(
({ validate, meta: { name, flag, selectHint } }: CycliRecipe) => {
let validationError = ''
try {
validate?.(toolbox)
} catch (error: unknown) {
validationError = messageFromError(error)
}
const hint = validationError || selectHint
const disabled = Boolean(validationError)
return {
label: name,
value: flag,
hint,
disabled,
}
}
)
)) as CycliRecipeFlag[]
}
}

const getSelectedRecipes = (): CycliRecipeFlag[] => {
return selectedRecipes || []
}

toolbox.config = {
obtain,
selectedRecipes: getSelectedRecipes,
}
}

export interface ConfigExtension {
config: {
obtain: (allRecipes: CycliRecipe[]) => Promise<void>
selectedRecipes: () => CycliRecipeFlag[]
}
}
Loading