diff --git a/README.md b/README.md index c0d75152..eab8bc84 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ Visit The full documentation here: https://preevy.dev/ - [Compose files](#compose-files) - [`x-preevy`: Preevy-specific configuration in the Compose file(s)](#x-preevy-preevy-specific-configuration-in-the-compose-files) - [Plugins](#plugins-1) + - [Default plugins](#default-plugins) + - [Enabling or disabling plugins](#enabling-or-disabling-plugins) - [Docs and support](#docs-and-support) - [Telemetry](#telemetry) @@ -282,10 +284,18 @@ See [Plugins](#plugins) below. Plugins are a way to extend Preevy's functionality via externally-published NPM packages. -A plugin can execute code in response to events. It can also defined new commands, and add flags to existing commands to customize their behavior. +A plugin can add hooks which execute code in response to events. It can also define new commands, and add flags to existing commands to customize their behavior. + +### Default plugins + +The [GitHub integration plugin](packages/plugin-github) packaged as `@preevy/plugin-github` is bundled with Preevy and enabled by default. + +### Enabling or disabling plugins + +#### From the Docker Compose file -Plugins are specified in the [Preevy configuration](#preevy-specific-configuration). Add a `plugins` section to the `x-preevy` top-level element: +Plugins can be configured in the [Preevy configuration](#x-preevy-preevy-specific-configuration-in-the-compose-files) section of your Compose file. Add a `plugins` section to the `x-preevy` top-level element: ```yaml @@ -293,12 +303,22 @@ services: ... x-preevy: plugins: - - module: '@preevy/plugin-github-pr-link' + - module: '@preevy/plugin-github' disabled: false # optional, set to true to disable plugin # ...additional plugin-specific configuration goes here ``` -See the [included GitHub PR Link Plugin](packages/plugin-github-pr-link) for an example. +See the [included GitHub integration plugin](packages/plugin-github/README.md) for a detailed example. + +#### From the environment + +Plugins can be enabled or disabled by setting the `PREEVY_ENABLE_PLUGINS` and `PREEVY_DISABLE_PLUGINS` environment variables to a comma-separated list of packages. + +Example: To disable the default GitHub integration plugin, set `PREEVY_DISABLE_PLUGINS=@preevy/plugin-github`. + +#### From the CLI flags + +Specify the global `--enable-plugin=` and `--disable-plugin=` flags to enable or disable plugins per command execution. CLI flags take priority over the Docker Compose and environment configuration. ## Docs and support diff --git a/packages/cli-common/src/commands/base-command.ts b/packages/cli-common/src/commands/base-command.ts index 1c7c636b..a1459300 100644 --- a/packages/cli-common/src/commands/base-command.ts +++ b/packages/cli-common/src/commands/base-command.ts @@ -4,7 +4,7 @@ import { } from '@preevy/core' import { asyncReduce } from 'iter-tools-es' import { commandLogger } from '../lib/log' -import { composeFlags } from '../lib/common-flags' +import { composeFlags, pluginFlags } from '../lib/common-flags' // eslint-disable-next-line no-use-before-define export type Flags = Interfaces.InferredFlags @@ -30,6 +30,7 @@ abstract class BaseCommand extends Comm ], }), ...composeFlags, + ...pluginFlags, } protected flags!: Flags diff --git a/packages/cli-common/src/hooks/init/load-plugins.ts b/packages/cli-common/src/hooks/init/load-plugins.ts index fab42b2f..2b1c9769 100644 --- a/packages/cli-common/src/hooks/init/load-plugins.ts +++ b/packages/cli-common/src/hooks/init/load-plugins.ts @@ -1,8 +1,9 @@ import { Hook as OclifHook, Command, Flags } from '@oclif/core' import { Parser } from '@oclif/core/lib/parser/parse' -import { Config, Topic } from '@oclif/core/lib/interfaces' +import { BooleanFlag, Config, Topic } from '@oclif/core/lib/interfaces' import { localComposeClient, ComposeModel, resolveComposeFiles, withSpinner, NoComposeFilesError } from '@preevy/core' -import { composeFlags } from '../../lib/common-flags' +import { cloneDeep } from 'lodash' +import { composeFlags, pluginFlags } from '../../lib/common-flags' import { addPluginFlags, loadPlugins, hooksFromPlugins, addPluginCommands } from '../../lib/plugins' type InternalConfig = Config & { @@ -12,18 +13,34 @@ type InternalConfig = Config & { const excludedCommandIds = ['init', 'version', /^profile:/, 'ls'] -export const initHook: OclifHook<'init'> = async function hook({ config, id, argv }) { +const flagDefs = cloneDeep({ + ...composeFlags, + ...pluginFlags, + json: Flags.boolean(), +}) as typeof composeFlags & typeof pluginFlags & { json: BooleanFlag } + +flagDefs['enable-plugin'].default = undefined + +export const initHook: OclifHook<'init'> = async function hook(args) { + const { config, id, argv } = args + // workaround oclif bug when executing `preevy --flag1 --flag2` with no command + if (id?.startsWith('-')) { + await initHook.call(this, ({ ...args, id: undefined, argv: [id].concat(argv) })) + return + } + if (id && excludedCommandIds.some(excluded => (typeof excluded === 'string' ? excluded === id : excluded.test(id)))) { return } const { flags, raw } = await new Parser({ - flags: { ...composeFlags, json: Flags.boolean() }, + flags: flagDefs, strict: false, args: {}, context: undefined, argv, } as const).parse() + const composeFiles = await resolveComposeFiles({ userSpecifiedFiles: flags.file, userSpecifiedSystemFiles: flags['system-compose-file'], @@ -45,7 +62,12 @@ export const initHook: OclifHook<'init'> = async function hook({ config, id, arg const userModel = userModelOrError instanceof Error ? {} as ComposeModel : userModelOrError const preevyConfig = userModel['x-preevy'] ?? {} - const loadedPlugins = await loadPlugins(preevyConfig, { userModel, oclifConfig: config, argv }) + const loadedPlugins = await loadPlugins( + config, + flags, + preevyConfig.plugins, + { preevyConfig, userModel, oclifConfig: config, argv }, + ) const commands = addPluginFlags(addPluginCommands(config.commands, loadedPlugins), loadedPlugins) const topics = [...config.topics, ...loadedPlugins.flatMap(({ initResults }) => initResults.topics ?? [])]; diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts index 7c09748a..0539015f 100644 --- a/packages/cli-common/src/index.ts +++ b/packages/cli-common/src/index.ts @@ -2,7 +2,7 @@ export * from './lib/plugins/model' export * as text from './lib/text' export { HookName, HookFunc, HooksListeners, Hooks } from './lib/hooks' export { PluginContext, PluginInitContext } from './lib/plugins/context' -export { composeFlags, envIdFlags, tunnelServerFlags, urlFlags } from './lib/common-flags' -export { formatFlagsToArgs } from './lib/flags' +export { composeFlags, pluginFlags, envIdFlags, tunnelServerFlags, urlFlags } from './lib/common-flags' +export { formatFlagsToArgs, parseFlags, ParsedFlags } from './lib/flags' export { initHook } from './hooks/init/load-plugins' export { default as BaseCommand } from './commands/base-command' diff --git a/packages/cli-common/src/lib/common-flags.ts b/packages/cli-common/src/lib/common-flags.ts index e2a5d136..bf964811 100644 --- a/packages/cli-common/src/lib/common-flags.ts +++ b/packages/cli-common/src/lib/common-flags.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core' +import { DEFAULT_PLUGINS } from './plugins/default-plugins' const projectFlag = { project: Flags.string({ @@ -31,6 +32,24 @@ export const composeFlags = { ...projectFlag, } as const +export const pluginFlags = { + 'enable-plugin': Flags.string({ + description: 'Enable plugin with specified package name', + multiple: true, + delimiter: ',', + singleValue: true, + helpGroup: 'GLOBAL', + default: DEFAULT_PLUGINS, + }), + 'disable-plugin': Flags.string({ + description: 'Disable plugin with specified package name', + multiple: true, + delimiter: ',', + singleValue: true, + helpGroup: 'GLOBAL', + }), +} as const + export const envIdFlags = { id: Flags.string({ description: 'Environment id - affects created URLs. If not specified, will try to detect automatically', diff --git a/packages/cli-common/src/lib/flags.ts b/packages/cli-common/src/lib/flags.ts index 37be5c0b..10c1f6a9 100644 --- a/packages/cli-common/src/lib/flags.ts +++ b/packages/cli-common/src/lib/flags.ts @@ -1,4 +1,5 @@ import { Flag } from '@oclif/core/lib/interfaces' +import { Parser } from '@oclif/core/lib/parser/parse' type FlagSpec =Pick, 'type' | 'default'> @@ -23,3 +24,13 @@ export function formatFlagsToArgs(flags: Record, spec: Record(def: T, argv: string[]) => (await new Parser({ + flags: def, + strict: false, + args: {}, + context: undefined, + argv, +}).parse()).flags + +export type ParsedFlags = Omit>>, 'json'> diff --git a/packages/cli-common/src/lib/plugins/default-plugins.ts b/packages/cli-common/src/lib/plugins/default-plugins.ts new file mode 100644 index 00000000..de998b02 --- /dev/null +++ b/packages/cli-common/src/lib/plugins/default-plugins.ts @@ -0,0 +1,3 @@ +export const DEFAULT_PLUGINS = [ + '@preevy/plugin-github', +] diff --git a/packages/cli-common/src/lib/plugins/index.ts b/packages/cli-common/src/lib/plugins/index.ts index 175f36ce..f61b2634 100644 --- a/packages/cli-common/src/lib/plugins/index.ts +++ b/packages/cli-common/src/lib/plugins/index.ts @@ -2,3 +2,4 @@ export { loadPlugins } from './load' export { addPluginFlags } from './flags' export { addPluginCommands } from './commands' export { hooksFromPlugins } from './hooks' +export { DEFAULT_PLUGINS } from './default-plugins' diff --git a/packages/cli-common/src/lib/plugins/load.ts b/packages/cli-common/src/lib/plugins/load.ts index a1015f69..8ca14dae 100644 --- a/packages/cli-common/src/lib/plugins/load.ts +++ b/packages/cli-common/src/lib/plugins/load.ts @@ -1,19 +1,41 @@ +import { Config } from '@oclif/core' +import { InferredFlags } from '@oclif/core/lib/interfaces' import { config as coreConfig } from '@preevy/core' import { InitResults, PluginModule } from './model' import { PluginInitContext } from './context' import PreevyPluginConfig = coreConfig.PreevyPluginConfig -import PreevyConfig = coreConfig.PreevyConfig +import { pluginFlags } from '../common-flags' +import { DEFAULT_PLUGINS } from './default-plugins' export type LoadedPlugin = { initResults: InitResults config: PreevyPluginConfig } +const mergePluginDefs = ( + envConfig: Pick, + flags: InferredFlags, + pluginConfig: PreevyPluginConfig[] | undefined, +) => { + const pluginDefinitions = DEFAULT_PLUGINS.map(m => ({ module: m })) + .concat(pluginConfig ?? []) + .concat(envConfig.scopedEnvVar('ENABLE_PLUGINS')?.split(',')?.map(m => ({ module: m })) ?? []) + .concat(envConfig.scopedEnvVar('DISABLE_PLUGINS')?.split(',')?.map(m => ({ module: m, disabled: true })) ?? []) + .concat(flags['enable-plugin']?.map(m => ({ module: m })) ?? []) + .concat(flags['disable-plugin']?.map(m => ({ module: m, disabled: true })) ?? []) + .map(p => [p.module, p] as [string, PreevyPluginConfig]) + .reduce((acc, [k, v]) => ({ ...acc, ...{ [k]: v } }), {} as Record) + + return Object.values(pluginDefinitions).filter(({ disabled }) => !disabled) +} + export const loadPlugins = async ( - preevyConfig: Pick, - initArgs: Omit, + envConfig: Pick, + flags: InferredFlags, + pluginConfig: PreevyPluginConfig[] | undefined, + initArgs: Omit, ): Promise => { - const pluginDefinitions = (preevyConfig.plugins ?? []).filter(p => !p.disabled) + const pluginDefinitions = mergePluginDefs(envConfig, flags, pluginConfig) const plugins = await Promise.all(pluginDefinitions.map( async p => ({ plugin: (await import(p.module) as PluginModule).preevyPlugin, config: p }), @@ -21,7 +43,7 @@ export const loadPlugins = async ( return await Promise.all( plugins.map(async p => ({ - initResults: await p.plugin.init({ ...initArgs, preevyConfig, pluginConfig: p.config }), + initResults: await p.plugin.init({ ...initArgs, pluginConfig: p.config }), config: p.config, })), ) diff --git a/packages/cli/package.json b/packages/cli/package.json index 91fc9da7..f39d14b0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,7 +29,7 @@ "@preevy/driver-gce": "0.0.56", "@preevy/driver-kube-pod": "0.0.56", "@preevy/driver-lightsail": "0.0.56", - "@preevy/plugin-github-pr-link": "0.0.56", + "@preevy/plugin-github": "0.0.56", "inquirer": "^8.0.0", "inquirer-autocomplete-prompt": "^2.0.0", "iter-tools-es": "^7.5.3", diff --git a/packages/cli/src/commands/urls.ts b/packages/cli/src/commands/urls.ts index d4fc352a..c37bcabf 100644 --- a/packages/cli/src/commands/urls.ts +++ b/packages/cli/src/commands/urls.ts @@ -16,8 +16,8 @@ export const writeUrlsToFile = async ( ) => { if (!outputUrlsTo) return const contents = /\.ya?ml$/.test(outputUrlsTo) ? yaml.stringify(urls) : JSON.stringify(urls) + log.info(`Writing URLs to file ${text.code(outputUrlsTo)}`) await fs.promises.writeFile(outputUrlsTo, contents, { encoding: 'utf8' }) - log.info(`URLs written to file ${text.code(outputUrlsTo)}`) } export const printUrls = ( diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 19cbe8e0..6a6122df 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -25,6 +25,6 @@ { "path": "../driver-gce" }, { "path": "../driver-azure" }, { "path": "../driver-kube-pod" }, - { "path": "../plugin-github-pr-link" }, + { "path": "../plugin-github" }, ] } diff --git a/packages/core/src/ci-providers/index.ts b/packages/core/src/ci-providers/index.ts index 595e7b51..67312470 100644 --- a/packages/core/src/ci-providers/index.ts +++ b/packages/core/src/ci-providers/index.ts @@ -15,3 +15,5 @@ export const ciProviders = { export const detectCiProvider = (): CiProvider | undefined => Object.values(ciProviders) .find(p => p.currentlyRunningInProvider()) + +export { CiProvider } from './base' diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 3fe923d4..e2390c43 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -32,3 +32,5 @@ export function gitContext(cwd: string = process.cwd()) { remoteTrackingBranchUrl, } } + +export type GitContext = ReturnType diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e320b2b4..0ff9005f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,10 +59,10 @@ export { TunnelOpts } from './ssh' export { Spinner } from './spinner' export { withClosable } from './closable' export { generateBasicAuthCredentials as getUserCredentials, jwtGenerator, jwkThumbprint, jwkThumbprintUri, parseKey } from './credentials' -export { ciProviders, detectCiProvider } from './ci-providers' +export { ciProviders, detectCiProvider, CiProvider } from './ci-providers' export { paginationIterator } from './pagination' export { ensureDefined, extractDefined, HasRequired } from './nulls' export { pSeries } from './p-series' -export { gitContext } from './git' +export { gitContext, GitContext } from './git' export * as config from './config' export { login, getTokensFromLocalFs as getLivecycleTokensFromLocalFs, TokenExpiredError } from './login' diff --git a/packages/plugin-github-pr-link/README.md b/packages/plugin-github-pr-link/README.md deleted file mode 100644 index b2c3341b..00000000 --- a/packages/plugin-github-pr-link/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# GitHub Pull Request link - -This plugin allows showing the Preevy environment URLs in a GitHub PR comment. The comment is added/updated at the `up` command and deleted in the `down` command. - -![Demo comment](./demo.png) - -## Configuration - -Add the plugin to the `plugins` section of the `x-preevy` element in your Docker Compose file: - -```yaml -services: - ... -x-preevy: - plugins: - - module: '@preevy/plugin-github-pr-link' - # detect: false - # disabled: true - # commentTemplate: see below - # pullRequest: PR number - # token: GitHub token - # repo: GitHub repo (owner/reponame) -``` - -At runtime, the plugin will attempt to detect the configuration it needs from environment variables and the git context. Flags can be specified in the `up` and `down` commands to override the behaviour. - -| | Environment variable | Flag | Config section | Other sources | -|---|------|------|-----|----| -| GitHub token | `GITHUB_TOKEN` | `--github-pr-link-token` | `token` | -| Repo (owner/reponame) | `GITHUB_REPOSITORY` | `--github-pr-link-repo` | `commentTemplate` | git context (if `detect` is not `false`) | -| PR number | `GITHUB_REF` | `--github-pr-link-pull-request` | `pullRequest` | | -| Comment template | | `--github-pr-link-comment-template-file` | `commentTemplate` | | - -### Comment template - -The generated PR comment can be customized by specifying a template (see above table). The template is rendered by [`nunjucks`](https://mozilla.github.io/nunjucks/templating.html) and receives a context containing a `urls` property which is one of the following: - -* `undefined`: The environment is being deleted, or the `unlink` command has been invoked. -* Otherwise, the result of the [preevy `urls` command](../cli/README.md#preevy-urls-service-port): an array of `{ service: string; port: number; url: string; project: string }` - -Here is an example of a configuration file containing a customized template: - -```yaml -x-preevy: - plugins: - - module: '@preevy/plugin-github-pr-link' - commentTemplate: | - {% if urls %}[Preevy](https://preevy.dev) has created a preview environment for this PR. - - Here is how to access it: - - | Service | Port | URL | - |---------|------|-----| - {% for url in urls %}| {{ url.service }} | {{ url.port }} | {{ url.url }} | - {% endfor %} - {% else %}The [Preevy](https://preevy.dev) preview environment for this PR has been deleted. - {% endif %} -``` - -## CI providers - -The plugin can auto detect its configuration from the CI providers supported by `@preevy/core`: - -* [GitHub Actions](../core/src/ci-providers/github-actions.ts) -* [GitLab Actions](../core/src/ci-providers/gitlab.ts) -* [Circle CI](../core/src/ci-providers/circle.ts) -* [Travis CI](../core/src/ci-providers/travis.ts) -* [Azure Pipelines](../core/src/ci-providers/azure-pipelines.ts) - -To disable auto-detection, specify `detect: false` at the plugin configuration in the Docker Compose file. - -## Commands - -This plugin adds the following commands: - -`github-pr link`: Creates the comment from an existing Preevy environment - -`github-pr unlink`: Deletes the comment from a Preevy environment. - -The commands accept a similar set of flags as described above. Run `preevy github-pr link --help` for details. - - -## Disabling the plugin - -The plugin can be disabled by specifying `disabled: true` at the plugin configuration in the Docker Compose file, adding the environment variable `PREEVY_GITHUB_LINK=0` or specifying the flag `--github-pr-link-enabled=no` in the `up` and `down` commands. - diff --git a/packages/plugin-github-pr-link/src/commands/github-pr/unlink.ts b/packages/plugin-github-pr-link/src/commands/github-pr/unlink.ts deleted file mode 100644 index 062074d6..00000000 --- a/packages/plugin-github-pr-link/src/commands/github-pr/unlink.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Command, Flags, Interfaces } from '@oclif/core' -import { Octokit } from 'octokit' -import { upsertPreevyComment } from '../../lib/github-comment' -import BaseGithubPrCommand from './base' - -// eslint-disable-next-line no-use-before-define -export type Flags = Interfaces.InferredFlags -export type Args = Interfaces.InferredArgs - -// eslint-disable-next-line no-use-before-define -class UnLinkGithubPr extends BaseGithubPrCommand { - static id = 'github-pr:unlink' - static description = 'Unlink a GitHub Pull Request from an environment' - - static flags = {} - - async run() { - const { flags } = await this.parse(UnLinkGithubPr) - const config = await this.loadGithubConfig(flags) - - await upsertPreevyComment({ - octokit: new Octokit({ auth: config.token }), - }, { - ...config, - envId: await this.getEnvId(), - content: 'deleted', - }) - } -} - -export default UnLinkGithubPr diff --git a/packages/plugin-github-pr-link/src/config.ts b/packages/plugin-github-pr-link/src/config.ts deleted file mode 100644 index 2f4f7e6f..00000000 --- a/packages/plugin-github-pr-link/src/config.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { defaults } from 'lodash' -import fs from 'fs' -import { Config as OclifConfig } from '@oclif/core' -import { Logger, detectCiProvider, gitContext } from '@preevy/core' -import { tryParseRepo, tryParseUrlToRepo } from './repo' -import { ParsedFlags, flagsDef, prefixedFlagsDef } from './flags' -import { defaultCommentTemplate } from './lib/github-comment' - -export type PluginConfig = { - repo?: string - token?: string - pullRequest?: number - commentTemplate?: string - detect?: boolean -} - -export type GithubConfig = { - repo: { owner: string; repo: string } - token: string - commentTemplate: string - pullRequest: number -} - -type GithubConfigProp = keyof GithubConfig - -const githubConfigProps: readonly GithubConfigProp[] = [ - 'commentTemplate', - 'repo', - 'token', - 'pullRequest', -] as const - -export const missingGithubConfigProps = ( - config: Partial, -): GithubConfigProp[] => githubConfigProps.filter(prop => !config[prop]) - -const ambientGithubConfig = async ({ log }: { log: Logger }): Promise> => { - log.debug('ambientGithubConfig, have GITHUB_TOKEN: %j', Boolean(process.env.GITHUB_TOKEN)) - const result: Partial = { - token: process.env.GITHUB_TOKEN, - } - - const ciProvider = detectCiProvider() - log.debug('ambientGithubConfig, ciProvider: %j', ciProvider?.name) - - result.pullRequest = ciProvider?.pullRequestNumber() - - log.debug('ambientGithubConfig, ciProvider: %j', ciProvider?.name) - - const repoUrlStr = ciProvider?.repoUrl() ?? await gitContext().remoteTrackingBranchUrl().catch(() => undefined) - - log.debug('ambientGithubConfig, repoUrlStr: %j', repoUrlStr) - - if (repoUrlStr) { - result.repo = tryParseUrlToRepo(repoUrlStr) - log.debug('ambientGithubConfig, repoUrl: %j', result.repo) - } - - return result -} - -const readFromFile = (path?: string) => path && fs.promises.readFile(path, { encoding: 'utf-8' }) - -const githubConfigFromPrefixedFlags = async ( - flags: ParsedFlags, -): Promise> => ({ - pullRequest: flags['github-pr-link-pull-request'], - repo: flags['github-pr-link-repo'], - token: flags['github-pr-link-token'], - commentTemplate: await readFromFile(flags['github-pr-link-comment-template-file']), -}) - -export const githubConfigFromFlags = async ( - flags: ParsedFlags, -): Promise> => ({ - pullRequest: flags['pull-request'], - repo: flags.repo, - token: flags.token, - commentTemplate: await readFromFile(flags['comment-template-file']), -}) - -const githubConfigFromPluginConfig = (config: PluginConfig): Partial => ({ - pullRequest: config.pullRequest, - token: config.token, - repo: config.repo ? tryParseRepo(config.repo) : undefined, - commentTemplate: config.commentTemplate, -}) - -export class IncompleteGithubConfig extends Error { - constructor(readonly missingProps: readonly GithubConfigProp[]) { - super(`Incomplete github config: Missing required properties: ${missingProps.join(', ')}`) - } -} - -const mergeGithubConfig = async ( - factories: ((() => Partial) | (() => Promise>))[], - { log }: { log: Logger } -) => { - let result: Partial = {} - let missingProps: readonly GithubConfigProp[] = githubConfigProps - - let factoryIndex = -1 - for (const factory of factories) { - factoryIndex += 1 - // eslint-disable-next-line no-await-in-loop - const factoryResult = await factory() - log.debug('Merging github config with factory %i: %j', factoryIndex, factoryResult) - result = defaults(result, factoryResult) - missingProps = missingGithubConfigProps(result) - if (missingProps.length === 0) { - return result as GithubConfig - } - } - - throw new IncompleteGithubConfig(missingProps) -} - -export const loadGithubConfig = async ( - config: PluginConfig, - fromFlags: Partial, - { log }: { log: Logger } -): Promise => { - const shouldDetect = config.detect === undefined || config.detect - - return await mergeGithubConfig([ - () => fromFlags, - () => githubConfigFromPluginConfig(config), - ...shouldDetect ? [() => ambientGithubConfig({ log })] : [], - () => ({ commentTemplate: defaultCommentTemplate }), - ], { log }) -} - -const SCOPED_ENV_VAR = 'GITHUB_LINK' - -export const loadGithubConfigOrSkip = async ( - oclifConfig: Pick, - pluginConfig: PluginConfig, - flags: ParsedFlags, - log: Logger, -) => { - if (oclifConfig.scopedEnvVar(SCOPED_ENV_VAR) && !oclifConfig.scopedEnvVarTrue(SCOPED_ENV_VAR)) { - log.debug('Skipping due to env var') - return false - } - - if (flags['github-pr-link-enabled'] === 'no') { - log.debug('Skipping due to flag') - return false - } - - return await loadGithubConfig(pluginConfig, await githubConfigFromPrefixedFlags(flags), { log }) -} diff --git a/packages/plugin-github-pr-link/src/hooks.ts b/packages/plugin-github-pr-link/src/hooks.ts deleted file mode 100644 index bc7e31a4..00000000 --- a/packages/plugin-github-pr-link/src/hooks.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { HookFunc } from '@preevy/cli-common' -import { Octokit } from 'octokit' -import { Config as OclifConfig } from '@oclif/core/lib/interfaces' -import { Logger } from '@preevy/core' -import { upsertPreevyComment, Content } from './lib/github-comment' -import { parseFlags, prefixedFlagsDef } from './flags' -import { PluginConfig, loadGithubConfigOrSkip } from './config' - -const hook = async ({ argv, pluginConfig, oclifConfig, log, envId, content }: { - argv: string[] - pluginConfig: PluginConfig - oclifConfig: OclifConfig - log: Logger - envId: string - content: Content -}) => { - const flags = await parseFlags(prefixedFlagsDef, argv) - const config = await loadGithubConfigOrSkip(oclifConfig, pluginConfig, flags, log).catch(e => { - log.debug(e) - }) - - if (!config) { - log.debug('no config, skipping envCreated hook') - return - } - - await upsertPreevyComment({ - octokit: new Octokit({ auth: config.token }), - }, { - ...config, - envId, - content, - }) -} - -export const envCreated = ({ argv, pluginConfig, oclifConfig }: { - argv: string[] - pluginConfig: PluginConfig - oclifConfig: OclifConfig -}): HookFunc<'envCreated'> => async ({ log }, { envId, urls }) => { - await hook({ argv, pluginConfig, oclifConfig, log, envId, content: { urls } }) -} - -export const envDeleted = ({ argv, pluginConfig, oclifConfig }: { - argv: string[] - pluginConfig: PluginConfig - oclifConfig: OclifConfig -}): HookFunc<'envDeleted'> => async ({ log }, { envId }) => { - await hook({ argv, pluginConfig, oclifConfig, log, envId, content: 'deleted' }) -} diff --git a/packages/plugin-github-pr-link/src/index.ts b/packages/plugin-github-pr-link/src/index.ts deleted file mode 100644 index be668127..00000000 --- a/packages/plugin-github-pr-link/src/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Plugin } from '@preevy/cli-common' -import { envCreated, envDeleted } from './hooks' -import { PluginConfig } from './config' -import { prefixedFlagsDef } from './flags' -import LinkGithubPr from './commands/github-pr/link' -import UnLinkGithubPr from './commands/github-pr/unlink' - -export const preevyPlugin: Plugin = { - init: async context => ({ - flags: [ - { command: 'up', flags: prefixedFlagsDef }, - { command: 'down', flags: prefixedFlagsDef }, - ], - commands: [LinkGithubPr, UnLinkGithubPr], - topics: [{ - name: 'github-pr', - description: 'GitHub PR integration', - }], - hooks: { - envCreated: envCreated(context), - envDeleted: envDeleted(context), - }, - }), -} diff --git a/packages/plugin-github-pr-link/.eslintignore b/packages/plugin-github/.eslintignore similarity index 100% rename from packages/plugin-github-pr-link/.eslintignore rename to packages/plugin-github/.eslintignore diff --git a/packages/plugin-github-pr-link/.eslintrc.js b/packages/plugin-github/.eslintrc.js similarity index 100% rename from packages/plugin-github-pr-link/.eslintrc.js rename to packages/plugin-github/.eslintrc.js diff --git a/packages/plugin-github-pr-link/.gitignore b/packages/plugin-github/.gitignore similarity index 100% rename from packages/plugin-github-pr-link/.gitignore rename to packages/plugin-github/.gitignore diff --git a/packages/plugin-github-pr-link/.nvmrc b/packages/plugin-github/.nvmrc similarity index 100% rename from packages/plugin-github-pr-link/.nvmrc rename to packages/plugin-github/.nvmrc diff --git a/packages/plugin-github/README.md b/packages/plugin-github/README.md new file mode 100644 index 00000000..a056e1f1 --- /dev/null +++ b/packages/plugin-github/README.md @@ -0,0 +1,157 @@ +# GitHub integration plugin + +The `@preevy/plugin-github` plugin adds GitHub integration to Preevy. + +This plugin is bundled with Preevy and enabled by default. To disable it, see [below](#disabling-the-plugin). + +## GitHub PR comment for your environment + +![Demo comment](./demo.png) + +### Automatic comment at `up` and `down` + +Comment generation is done as part of the `up` and `down` core commands. + +If a GitHub context is detected (e.g, when running in a GitHub actions job), it will post the comment automatically. + +### Manual comment using the `github` commands + +This plugin adds the following commands: + +`github pr comment`: Creates a GitHub PR comment for an existing Preevy environment. If the comment exists, it is updated with the current set of URLs. + +`github pr uncomment`: Updates the GitHub PR comment to state that the Preevy environment has been deleted. + +Run `preevy github pr comment --help` for details. + +## Configuration + +At runtime, the plugin will attempt to detect the configuration it needs from environment variables and the git context. Options can be overridden using CLI flags and the Docker Compose file. + +| | Environment variable | Flag | Config section | Other sources | +|---|------|------|-----|----| +| GitHub token | `GITHUB_TOKEN` | `--github-token` | `token` | +| Repo (owner/reponame) | `GITHUB_REPOSITORY` | `--github-repo` | `repo` | git context (if `detect` is not `false`) | +| PR number | `GITHUB_REF` (format: `refs/pull/`) | `--github-pull-request` | `pullRequest` | | +| Comment template | | `--github-pr-comment-template-file` | `commentTemplate` | | + +### Configuration from the CI provider context + +The plugin can automatically detect its configuration when running in a CI provider supported by `@preevy/core`: + +* [GitHub Actions](../core/src/ci-providers/github-actions.ts) +* [GitLab Actions](../core/src/ci-providers/gitlab.ts) +* [Circle CI](../core/src/ci-providers/circle.ts) +* [Travis CI](../core/src/ci-providers/travis.ts) +* [Azure Pipelines](../core/src/ci-providers/azure-pipelines.ts) + +To disable auto-detection, specify `detect: false` at the plugin configuration in the Docker Compose file. + +### Configuration from the Docker Compose file + +Add the plugin to the `plugins` section of the `x-preevy` element in your Docker Compose file: + +```yaml +services: + ... +x-preevy: + plugins: + - module: '@preevy/plugin-github' + # detect: false + # disabled: true + # commentTemplate: see below + # pullRequest: PR number + # token: GitHub token + # repo: GitHub repo (owner/reponame) +``` + +### Configuration from CLI flags + +The following flags can be specified at the Preevy CLI: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandFlagDescription
up, down--github-token=<token>GitHub token
--github-repo=<owner>/<repo>GitHub repository
--github-pull-request=<number>GitHub PR number
--github-comment-template-file=<path>Path to a nunjucks comment template
--github-pr-comment-enabled=<auto|no|always>Whether to enable posting/updating a comment on the GitHub PR
github pr comment, github pr uncomment--token=<token>GitHub token
--repo=<owner>/<repo>GitHub repository
--pull-request=<number>GitHub PR number
--comment-template-file=<path>Path to a nunjucks comment template
+ +### Comment template + +The generated PR comment can be customized by specifying a template in your Docker Compose file, or in a separate file (see above). The template is rendered by [`nunjucks`](https://mozilla.github.io/nunjucks/templating.html) and receives a context containing a `urls` property which is one of the following: + +* `undefined`: The environment is being deleted, or the `uncomment` command has been invoked. +* Otherwise, the result of the [preevy `urls` command](../cli/README.md#preevy-urls-service-port): an array of `{ service: string; port: number; url: string; project: string }` + +Here is an example of a configuration file containing a customized template: + +```yaml +x-preevy: + plugins: + - module: '@preevy/plugin-github' + commentTemplate: | + {% if urls %}[Preevy](https://preevy.dev) has created a preview environment for this PR. + + Here is how to access it: + + | Service | Port | URL | + |---------|------|-----| + {% for url in urls %}| {{ url.service }} | {{ url.port }} | {{ url.url }} | + {% endfor %} + {% else %}The [Preevy](https://preevy.dev) preview environment for this PR has been deleted. + {% endif %} +``` + +## Disabling the plugin + +### Disabling the plugin completely (will remove all the GitHub integration) + +Any of the following will disable the plugin: + +- Specify `disabled: true` at the plugin configuration in the Docker Compose file +- Set the `PREEVY_DISABLE_PLUGINS` environment variable to `@preevy/plugin-github`. If multiple plugins need to be disabled, specify a comma-separated list of modules. +- Add the flag `--disable-plugin=@preevy/plugin-github` + +### Disabling the GitHub PR comment + +Automatic commenting on the GitHub PR can be disabled by, setting the environment variable `PREEVY_GITHUB_PR_COMMENT_ENABLED=0` or specifying the flag `--github-pr-comment-enabled=no` at the `up` and `down` commands. + diff --git a/packages/plugin-github-pr-link/demo.png b/packages/plugin-github/demo.png similarity index 100% rename from packages/plugin-github-pr-link/demo.png rename to packages/plugin-github/demo.png diff --git a/packages/plugin-github-pr-link/package.json b/packages/plugin-github/package.json similarity index 94% rename from packages/plugin-github-pr-link/package.json rename to packages/plugin-github/package.json index 9fc24210..3e29ed36 100644 --- a/packages/plugin-github-pr-link/package.json +++ b/packages/plugin-github/package.json @@ -1,5 +1,5 @@ { - "name": "@preevy/plugin-github-pr-link", + "name": "@preevy/plugin-github", "version": "0.0.56", "description": "", "main": "dist/index.js", diff --git a/packages/plugin-github-pr-link/src/commands/github-pr/base.ts b/packages/plugin-github/src/commands/github/base.ts similarity index 53% rename from packages/plugin-github-pr-link/src/commands/github-pr/base.ts rename to packages/plugin-github/src/commands/github/base.ts index dbda7977..ede56876 100644 --- a/packages/plugin-github-pr-link/src/commands/github-pr/base.ts +++ b/packages/plugin-github/src/commands/github/base.ts @@ -1,15 +1,15 @@ import { Command, Flags, Interfaces } from '@oclif/core' -import { BaseCommand, envIdFlags } from '@preevy/cli-common' -import { findEnvId } from '@preevy/core' -import { ParsedFlags, flagsDef } from '../../flags' -import { PluginConfig, githubConfigFromFlags, loadGithubConfig } from '../../config' +import { BaseCommand, ParsedFlags, envIdFlags } from '@preevy/cli-common' +import { CiProvider, detectCiProvider, findEnvId } from '@preevy/core' +import { PluginConfig, loadGithubConfig } from '../../config' +import { flagsDef } from '../../flags' // eslint-disable-next-line no-use-before-define -export type Flags = Interfaces.InferredFlags +export type Flags = Interfaces.InferredFlags export type Args = Interfaces.InferredArgs // eslint-disable-next-line no-use-before-define -abstract class BaseGithubPrCommand extends BaseCommand { +abstract class BaseGithubCommand extends BaseCommand { static baseFlags = { ...BaseCommand.baseFlags, ...envIdFlags, @@ -33,9 +33,23 @@ abstract class BaseGithubPrCommand extends BaseCommand return envId } + #ciProviderLoaded: boolean = false + #ciProvider: CiProvider | undefined + protected get ciProvider() { + if (!this.#ciProviderLoaded) { + this.#ciProvider = detectCiProvider() + } + return this.#ciProvider + } + protected async loadGithubConfig(flags: ParsedFlags) { - return await loadGithubConfig(this.pluginConfig, await githubConfigFromFlags(flags), { log: this.logger }) + return await loadGithubConfig({ + ciProvider: () => this.ciProvider, + env: process.env, + flags, + pluginConfig: this.pluginConfig, + }) } } -export default BaseGithubPrCommand +export default BaseGithubCommand diff --git a/packages/plugin-github/src/commands/github/pr/base.ts b/packages/plugin-github/src/commands/github/pr/base.ts new file mode 100644 index 00000000..7809f552 --- /dev/null +++ b/packages/plugin-github/src/commands/github/pr/base.ts @@ -0,0 +1,57 @@ +import { Command, Flags, Interfaces } from '@oclif/core' +import { ParsedFlags } from '@preevy/cli-common' +import { commentTemplateFlagDef, flagsDef, pullRequestFlagsDef } from '../../../flags' +import BaseGithubCommand from '../base' +import { GithubConfig, loadGithubPullRequestCommentConfig, loadGithubPullRequestConfig } from '../../../config' + +const ensureConfig = (config: T | undefined) => { + if (!config) { + throw new Error('Missing GitHub config') + } + return config +} + +// eslint-disable-next-line no-use-before-define +export type Flags = Interfaces.InferredFlags +export type Args = Interfaces.InferredArgs + +// eslint-disable-next-line no-use-before-define +abstract class BaseGithubPrCommand extends BaseGithubCommand { + static description = 'GitHub Pull Requests integration' + + static baseFlags = { + ...BaseGithubCommand.baseFlags, + ...pullRequestFlagsDef, + } + + protected flags!: Flags + protected args!: Args + + protected async loadGithubPullRequestConfig(flags: ParsedFlags) { + return ensureConfig(await loadGithubPullRequestConfig( + { + ciProvider: () => this.ciProvider, + env: process.env, + flags, + pluginConfig: this.pluginConfig, + }, + await this.loadGithubConfig(flags), + )) + } + + protected async loadGithubPullRequestCommentConfig( + flags: ParsedFlags + ) { + return ensureConfig(await loadGithubPullRequestCommentConfig( + { + ciProvider: () => this.ciProvider, + env: process.env, + flags, + pluginConfig: this.pluginConfig, + }, + await this.loadGithubPullRequestConfig(flags), + )) + } +} + +export default BaseGithubPrCommand diff --git a/packages/plugin-github-pr-link/src/commands/github-pr/link.ts b/packages/plugin-github/src/commands/github/pr/comment.ts similarity index 52% rename from packages/plugin-github-pr-link/src/commands/github-pr/link.ts rename to packages/plugin-github/src/commands/github/pr/comment.ts index ea6e1085..3662815d 100644 --- a/packages/plugin-github-pr-link/src/commands/github-pr/link.ts +++ b/packages/plugin-github/src/commands/github/pr/comment.ts @@ -1,19 +1,18 @@ -import { Command, Flags, Interfaces } from '@oclif/core' import { Octokit } from 'octokit' import { FlatTunnel } from '@preevy/core' -import { upsertPreevyComment } from '../../lib/github-comment' +import { upsertPreevyComment } from '../../../lib/github-comment' import BaseGithubPrCommand from './base' +import { commentTemplateFlagDef } from '../../../flags' // eslint-disable-next-line no-use-before-define -export type Flags = Interfaces.InferredFlags -export type Args = Interfaces.InferredArgs +class CommentGithubPr extends BaseGithubPrCommand { + static id = 'github:pr:comment' + static description = 'Post a comment on a GitHub Pull Request describing the preevy environment' -// eslint-disable-next-line no-use-before-define -class LinkGithubPr extends BaseGithubPrCommand { - static id = 'github-pr:link' - static description = 'Link a GitHub Pull Request to an existing environment' - - static flags = {} + static flags = { + ...BaseGithubPrCommand.baseFlags, // workaround: help not showing base flags due to command not cached + ...commentTemplateFlagDef, + } async run() { const urls = await this.config.runCommand('urls', [ @@ -24,8 +23,8 @@ class LinkGithubPr extends BaseGithubPrCommand { '--json', ]) as FlatTunnel[] - const { flags } = await this.parse(LinkGithubPr) - const config = await this.loadGithubConfig(flags) + const { flags } = await this.parse(CommentGithubPr) + const config = await this.loadGithubPullRequestCommentConfig(flags) await upsertPreevyComment({ octokit: new Octokit({ auth: config.token }), @@ -37,4 +36,4 @@ class LinkGithubPr extends BaseGithubPrCommand { } } -export default LinkGithubPr +export default CommentGithubPr diff --git a/packages/plugin-github/src/commands/github/pr/uncomment.ts b/packages/plugin-github/src/commands/github/pr/uncomment.ts new file mode 100644 index 00000000..454fbcfb --- /dev/null +++ b/packages/plugin-github/src/commands/github/pr/uncomment.ts @@ -0,0 +1,35 @@ +import { Command, Flags, Interfaces } from '@oclif/core' +import { Octokit } from 'octokit' +import { upsertPreevyComment } from '../../../lib/github-comment' +import BaseGithubPrCommand from './base' +import { commentTemplateFlagDef } from '../../../flags' + +// eslint-disable-next-line no-use-before-define +export type Flags = Interfaces.InferredFlags +export type Args = Interfaces.InferredArgs + +// eslint-disable-next-line no-use-before-define +class UnCommentGithubPr extends BaseGithubPrCommand { + static id = 'github:pr:uncomment' + static description = 'Update the Preevy comment on a GitHub Pull Request saying the preevy environment has been deleted' + + static flags = { + ...BaseGithubPrCommand.baseFlags, // workaround: help not showing base flags due to command not cached + ...commentTemplateFlagDef, + } + + async run() { + const { flags } = await this.parse(UnCommentGithubPr) + const config = await this.loadGithubPullRequestCommentConfig(flags) + + await upsertPreevyComment({ + octokit: new Octokit({ auth: config.token }), + }, { + ...config, + envId: await this.getEnvId(), + content: 'deleted', + }) + } +} + +export default UnCommentGithubPr diff --git a/packages/plugin-github/src/config.ts b/packages/plugin-github/src/config.ts new file mode 100644 index 00000000..52931ad8 --- /dev/null +++ b/packages/plugin-github/src/config.ts @@ -0,0 +1,100 @@ +import fs from 'fs' +import { CiProvider, gitContext } from '@preevy/core' +import { ParsedFlags } from '@preevy/cli-common' +import { tryParseUrlToRepo } from './repo' +import { commentTemplateFlagDef, flagsDef, pullRequestFlagsDef } from './flags' +import { defaultCommentTemplate } from './lib/github-comment' + +export type PluginConfig = { + repo?: string + token?: string + commentTemplate?: string + detect?: boolean +} + +export type GithubConfig = { + repo: { owner: string; repo: string } + token: string +} + +export type GithubPullRequestConfig = GithubConfig & { + pullRequest: number +} + +export type GithubPullRequestCommentConfig = GithubPullRequestConfig & { + commentTemplate: string +} + +export type ConfigFactory = ( + sources: { + flags: ParsedFlags + pluginConfig: PluginConfig + env: Record + ciProvider: () => Promise | (CiProvider | undefined) + }, + base?: Base, +) => Promise + +export const loadGithubConfig: ConfigFactory = async ( + { flags, pluginConfig, env, ciProvider }, + base, +): Promise => { + if (base) return base + + const token = flags.token ?? env.GITHUB_TOKEN ?? pluginConfig.token + if (!token) { + return undefined + } + let repo = flags.repo ?? pluginConfig.repo + if (!repo && pluginConfig.detect !== false) { + const cip = await ciProvider() + const url = cip?.repoUrl() ?? await gitContext().remoteTrackingBranchUrl().catch(() => undefined) + if (url) { + repo = tryParseUrlToRepo(url) + } + } + if (!repo) { + return undefined + } + return { token, repo } +} + +export const loadGithubPullRequestConfig: ConfigFactory< + typeof flagsDef & typeof pullRequestFlagsDef, GithubPullRequestConfig, GithubConfig +> = async (sources, base) => { + const ghc = base ?? await loadGithubConfig(sources) + if (!ghc) { + return undefined + } + const { flags: { 'pull-request': prFlag }, ciProvider, pluginConfig } = sources + let pr = prFlag + if (!pr && pluginConfig.detect !== false) { + const cip = await ciProvider() + if (cip) { + pr = cip.pullRequestNumber() + } + } + if (!pr) { + return undefined + } + return { ...ghc, pullRequest: pr } +} + +export const loadGithubPullRequestCommentConfig: ConfigFactory< + typeof flagsDef & typeof pullRequestFlagsDef & typeof commentTemplateFlagDef, + GithubPullRequestCommentConfig, + GithubPullRequestConfig +> = async (sources, base) => { + const ghprc = base ?? await loadGithubPullRequestConfig(sources) + if (!ghprc) { + return undefined + } + const { flags: { 'pr-comment-template-file': commentTemplateFile }, pluginConfig } = sources + const commentTemplateFromFile = commentTemplateFile + ? await fs.promises.readFile(commentTemplateFile, { encoding: 'utf-8' }) + : undefined + return { + ...ghprc, + commentTemplate: commentTemplateFromFile ?? pluginConfig.commentTemplate ?? defaultCommentTemplate, + } +} diff --git a/packages/plugin-github-pr-link/src/flags.ts b/packages/plugin-github/src/flags.ts similarity index 52% rename from packages/plugin-github-pr-link/src/flags.ts rename to packages/plugin-github/src/flags.ts index 72ecb69b..e80cc607 100644 --- a/packages/plugin-github-pr-link/src/flags.ts +++ b/packages/plugin-github/src/flags.ts @@ -1,9 +1,9 @@ -import { Parser } from '@oclif/core/lib/parser/parse' import { Flags } from '@oclif/core' import { mapKeys } from 'lodash' +import { ParsedFlags, parseFlags } from '@preevy/cli-common' import { tryParseRepo } from './repo' -const HELP_GROUP = 'GitHub PR link' +const HELP_GROUP = 'GitHub integration' export const flagsDef = { token: Flags.string({ @@ -23,6 +23,9 @@ export const flagsDef = { return result }, })(), +} as const + +export const pullRequestFlagsDef = { 'pull-request': Flags.custom({ description: 'GitHub Pull Request number. Will auto-detect if not specified', required: false, @@ -35,22 +38,33 @@ export const flagsDef = { return result }, })(), - 'comment-template-file': Flags.string({ +} as const + +export const commentTemplateFlagDef = { + 'pr-comment-template-file': Flags.string({ description: 'Path to nunjucks template file', required: false, helpGroup: HELP_GROUP, }), } as const -const flagPrefix = 'github-pr-link' as const +const flagPrefix = 'github' as const -type PrefixedFlagsDef = { - [K in keyof typeof flagsDef as `${typeof flagPrefix}-${K}`]: typeof flagsDef[K] +type Prefixed = { + [K in keyof T as `${typeof flagPrefix}-${string & K}`]: T[K] } -export const prefixedFlagsDef = { - ...mapKeys(flagsDef, (_v, k) => `${flagPrefix}-${k}`) as PrefixedFlagsDef, - [`${flagPrefix}-enabled` as const]: Flags.custom<'auto' | 'no' | 'always'>({ +type ExtractPrefix = S extends `${typeof flagPrefix}-${infer suffix}` ? suffix : never + +type Unprefixed = { + [K in keyof T as ExtractPrefix]: T[K] +} + +const upDownFlagsDefSource = { ...flagsDef, ...pullRequestFlagsDef, ...commentTemplateFlagDef } as const + +export const upDownFlagsDef = { + ...mapKeys(upDownFlagsDefSource, (_v, k) => `${flagPrefix}-${k}`) as Prefixed, + [`${flagPrefix}-pr-comment-enabled` as const]: Flags.custom<'auto' | 'no' | 'always'>({ description: 'Whether to enable posting to the GitHub PR', required: false, helpGroup: HELP_GROUP, @@ -59,12 +73,7 @@ export const prefixedFlagsDef = { })(), } as const -export const parseFlags = async (def: T, argv: string[]) => (await new Parser({ - flags: def, - strict: false, - args: {}, - context: undefined, - argv, -}).parse()).flags - -export type ParsedFlags = Awaited>> +export const parseUpDownFlagsDef = (argv: string[]) => mapKeys( + parseFlags(upDownFlagsDef, argv), + (_v, k) => k.replace(/^github-/, ''), +) as Unprefixed> diff --git a/packages/plugin-github/src/hooks.ts b/packages/plugin-github/src/hooks.ts new file mode 100644 index 00000000..580e5108 --- /dev/null +++ b/packages/plugin-github/src/hooks.ts @@ -0,0 +1,70 @@ +import { HookFunc } from '@preevy/cli-common' +import { Octokit } from 'octokit' +import { Config as OclifConfig } from '@oclif/core/lib/interfaces' +import { Logger, detectCiProvider } from '@preevy/core' +import { memoize } from 'lodash' +import { upsertPreevyComment, Content } from './lib/github-comment' +import { parseUpDownFlagsDef } from './flags' +import { PluginConfig, loadGithubPullRequestCommentConfig } from './config' + +const SCOPED_ENV_VAR = 'GITHUB_PR_COMMENT_ENABLED' + +const upsertPrCommentHook = async ({ argv, pluginConfig, oclifConfig, log, envId, content }: { + argv: string[] + pluginConfig: PluginConfig + oclifConfig: OclifConfig + log: Logger + envId: string + content: Content +}) => { + if (oclifConfig.scopedEnvVar(SCOPED_ENV_VAR) && !oclifConfig.scopedEnvVarTrue(SCOPED_ENV_VAR)) { + log.debug(`Skipping due to env var ${oclifConfig.scopedEnvVarKey(SCOPED_ENV_VAR)}=${oclifConfig.scopedEnvVar(SCOPED_ENV_VAR)}`) + return + } + + const flags = parseUpDownFlagsDef(argv) + + if (flags['pr-comment-enabled'] === 'no') { + log.debug('Skipping due to flag') + return + } + + const config = await loadGithubPullRequestCommentConfig({ + ciProvider: memoize(() => detectCiProvider()), + flags, + env: process.env, + pluginConfig, + }).catch(e => { + log.warn(`failed to load github plugin config: ${e?.stack ?? e.toString()}`) + return undefined + }) + + if (!config) { + log.debug('no config, skipping envCreated/envDeleted hook') + return + } + + await upsertPreevyComment({ + octokit: new Octokit({ auth: config.token }), + }, { + ...config, + envId, + content, + }) +} + +export const envCreated = ({ argv, pluginConfig, oclifConfig }: { + argv: string[] + pluginConfig: PluginConfig + oclifConfig: OclifConfig +}): HookFunc<'envCreated'> => async ({ log }, { envId, urls }) => { + await upsertPrCommentHook({ argv, pluginConfig, oclifConfig, log, envId, content: { urls } }) +} + +export const envDeleted = ({ argv, pluginConfig, oclifConfig }: { + argv: string[] + pluginConfig: PluginConfig + oclifConfig: OclifConfig +}): HookFunc<'envDeleted'> => async ({ log }, { envId }) => { + await upsertPrCommentHook({ argv, pluginConfig, oclifConfig, log, envId, content: 'deleted' }) +} diff --git a/packages/plugin-github/src/index.ts b/packages/plugin-github/src/index.ts new file mode 100644 index 00000000..c92271c0 --- /dev/null +++ b/packages/plugin-github/src/index.ts @@ -0,0 +1,24 @@ +import { Plugin } from '@preevy/cli-common' +import { envCreated, envDeleted } from './hooks' +import { PluginConfig } from './config' +import { upDownFlagsDef } from './flags' +import CommentGithubPr from './commands/github/pr/comment' +import UnCommentGithubPr from './commands/github/pr/uncomment' + +export const preevyPlugin: Plugin = { + init: async context => ({ + flags: [ + { command: 'up', flags: upDownFlagsDef }, + { command: 'down', flags: upDownFlagsDef }, + ], + commands: [CommentGithubPr, UnCommentGithubPr], + topics: [{ + name: 'github', + description: 'GitHub integration', + }], + hooks: { + envCreated: envCreated(context), + envDeleted: envDeleted(context), + }, + }), +} diff --git a/packages/plugin-github-pr-link/src/lib/github-comment.ts b/packages/plugin-github/src/lib/github-comment.ts similarity index 100% rename from packages/plugin-github-pr-link/src/lib/github-comment.ts rename to packages/plugin-github/src/lib/github-comment.ts diff --git a/packages/plugin-github-pr-link/src/repo.ts b/packages/plugin-github/src/repo.ts similarity index 100% rename from packages/plugin-github-pr-link/src/repo.ts rename to packages/plugin-github/src/repo.ts diff --git a/packages/plugin-github-pr-link/tsconfig.json b/packages/plugin-github/tsconfig.json similarity index 100% rename from packages/plugin-github-pr-link/tsconfig.json rename to packages/plugin-github/tsconfig.json