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

feat: @loopback/boot #858

Merged
merged 12 commits into from
Feb 21, 2018
Merged

feat: @loopback/boot #858

merged 12 commits into from
Feb 21, 2018

Conversation

virkt25
Copy link
Contributor

@virkt25 virkt25 commented Jan 12, 2018

Initial implementation of a Phase based Booter in Application as well as a @loopback/boot package to contain booters.

implements #780

Checklist

  • npm test passes on your machine
  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style guide
  • Related API Documentation was updated
  • Affected artifact templates in packages/cli were updated
  • Affected example projects in packages/example-* were updated

this.options.boot.projectRoot = resolve(this.options.boot.projectRoot);

// Bind Boot Config for Booters
this.bind(CoreBindings.BOOT_CONFIG).to(this.options.boot);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to create a child context of app and use it to run boot().

* A Booter class interface
*/
export interface Booter {
config?(): void;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use verbs for the phases/methods? For example, configure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for using verbs for phases/method names, that's the usual convention AFAIK.

// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {CoreBindings, Application, Booter, BootOptions} from '@loopback/core';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Booter and BootOptions interfaces should be defined in boot package.

* a list of skipped files. Other files that were read will still be bound
* to the Application instance.
*/
async boot() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to find a better name for this phase, such as load.

* @param {BootOptions} bootOptions Options for boot. Bound for Booters to
* receive via Dependency Injection.
*/
async boot(bootOptions?: BootOptions) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Majority of this logic should be refactored into a BootStrapper class in boot package as the extension point for all booters. app.boot is just a sugar method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also as I mentioned earlier, the boot configuration is an internal business of the Application class that should not be leaking to Application's public API. The boot method should not be accepting any assembly-related config.

for (const inst of booterInsts) {
if (inst[phase]) {
await inst[phase]();
console.log(`${inst.constructor.name} phase: ${phase} complete.`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use debug module instead of console.log.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this new version, I find it a lot better than the previous proposal 👍

There are some problems from our previous discussions that are still not addresses, please see my comments below.

@@ -0,0 +1,3 @@
*.tgz
dist*
package
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use top-level .gitgnore from loopback-next monorepo and remove this file?

@@ -0,0 +1,5 @@
sudo: false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto, can we use top-level .travis.yml from loopback-next monorepo and remove this file?


```ts
import {ControllerBooter} from '@loopback/boot';
app.booter(ControllerBooter); // register booter
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. How are we going to register all booters, will we need another metabooter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow. A booter is registered by app.booter. A normal Application developer will likely not be writing their own Booters. We can/should add support for a ScriptBooter however at some point down the road to boot all scripts in a scripts folder similar to how it is done today.

**Via Application Config**
```ts
new Application({
boot: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👎

As I commented before, we should not be mixing assembly configuration with application configuration, see #742. In particular, users of Application class should not be able to modify how the Application is assembled (booted). It's the responsibility of the Application class to know where to find its assembly bits like controllers.

**Via BootOptions**
```ts
app.boot({
boot: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API feels clunky to me, why are we nesting configuration of app.boot inside boot property? We already know that the argument of app.boot is specifying boot-related config.

The previous comment applies here too - users of Application class, including the callers of app.boot should not have access to boot configuration.

projectRoot =
process.cwd().indexOf('packages') > -1
? `${dist}/test/fixtures/booterApp`
: `packages/boot/${dist}/test/fixtures/booterApp`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I consider it as a code smell that this code needs to know about what is our compilation target and what is the layout of dist directories used by the compiler.

I am proposing to use a simpler solution based on __dirname or require.resolve, for example:

projectRoot = path.resolve(__dirname, '..', 'fixtures', 'booterApp');
// or
projectRoot = path.dirname(require.resolve('../fixtures/booterApp/application.ts'));

(This requires that fixtures are copied or transpiled as part of our build. The first alternative can be achieved by adding --allowJs (possibly together with --checkJs) to tsc compiler options. The second alternative requires us to change the dist files from .js to .ts. I personally prefer the second option, as it's closer to how our users are going to use the bootstrapper. IIUC, this part is already solved.)

* app.booters([ControllerBooter, RepositoryBooter]);
* ```
*/
booters<T extends Booter>(booterArr: Constructor<T>[]): Binding[] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I strongly dislike when we have two entities with almost the same name, differing only in the s suffix. Such difference is very easy to overlook when reading code. Please use a more distinctive names, or perhaps allow app.booter to accept both a single booter or an array of booters.

* @param {BootOptions} bootOptions Options for boot. Bound for Booters to
* receive via Dependency Injection.
*/
async boot(bootOptions?: BootOptions) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also as I mentioned earlier, the boot configuration is an internal business of the Application class that should not be leaking to Application's public API. The boot method should not be accepting any assembly-related config.

* A Booter class interface
*/
export interface Booter {
config?(): void;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for using verbs for phases/method names, that's the usual convention AFAIK.

`${resolve(projectRoot, 'controllers/hello.controller.js')}`,
`${resolve(projectRoot, 'controllers/two.controller.js')}`,
`${resolve(projectRoot, 'controllers/another.ext.ctrl.js')}`,
`${resolve(projectRoot, 'controllers/nested/nested.controller.js')}`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned before, having an external fixture with multiple artifacts is an anti-pattern that yields test suite that's difficult to maintain over time.

For example, in this particular test, the expected list is unnecessary repetitive. There is no need to specify all of empty, hello and two with the same extension .controller.js, one entry is enough to validate the logic.

A better solution is to create a set of helpers that make it super easy to prepare a test app directory with exactly the right (and minimal!) set of artifacts needed by the test. This makes it also much much easier to understand which parts of the fixture are relevant to the particular (usually failing) test.


A Booter is a Class that can be bound to an Application and is called
to perform a task before the Application is started. A Booter may have multiple
phases to complete it's task.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: it's -> its

"version": "4.0.0-alpha.1",
"description": "A collection of Booters for LoopBack 4 Applications",
"engines": {
"node": ">=6"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Echoing Miroslav's comments from Kyu's PR. Best to drop support of node v6 in this new package?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding on @bajtos comment on Kyu's PR was that we support Node 6 in new packages to avoid CI failure till such time that we can create a new PR to drop Node 6 across all packages. (I'm going to do that soon ... because it's an annoyance to support it when we are looking to drop it).


/**
* This phase is responsible for configuring this Booter. It converts
* options and assigns default values for missing values so ther phases
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: ther -> that other


// Find Bindings and get instance
const bindings = this.findByTag(CoreBindings.BOOTERS_TAG);
let booterInsts = bindings.map(binding => this.getSync(binding.key));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would let booterInsts = await bindings.map(binding => this.get(binding.key)); be better here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did that but unfortunately that doesn't work. I was ending up with an array of Promises instead of resolved instances. I also tried bindings.map(async binding => await this.get(binding.key)) but was again getting Promises not instances (I think it has to do with the fact that this is an arrow function).

Copy link
Contributor

@shimks shimks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor nitpicks. Once major concerns from @bajtos and @raymondfeng are addressed, I'll review again hopefully more in detail

The options for this are passed in a `controllers` object on `boot`.

Available Options on the `boot.controllers` are as follows:
|Options|Type|Default|Description|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Broken table here: probably needs a space in between the captions and the |s


const nodeMajorVersion = +process.versions.node.split('.')[0];
module.exports =
nodeMajorVersion >= 7 ? require('./dist/src') : require('./dist6/src');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

module.exports = require('./dist/src') since we're getting rid of node 6 support

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that we will support Node 6 till we can create a PR to drop it all together from all packages because otherwise we may start seeing CI failures.

virkt25 added a commit that referenced this pull request Jan 17, 2018
Follow up based on a review comment here:
#858 (comment)
36

This removes package level .gitignore and .travis.yml files in favour
of top-level files.
virkt25 added a commit that referenced this pull request Jan 17, 2018
Follow up based on a review comment here:
#858 (comment)

This removes package level .gitignore and .travis.yml files in favour
of top-level files.
virkt25 added a commit that referenced this pull request Jan 18, 2018
Follow up based on a review comment here:
#858 (comment)

This removes package level .gitignore and .travis.yml files in favour
of top-level files.
@virkt25
Copy link
Contributor Author

virkt25 commented Jan 18, 2018

Hi @bajtos
I've addressed most of the comments I think. Some comments regarding the outstanding issues:

  • Passing in options to app.boot(). I'm not sure what of what other way we can offer for setting these options. Removed it from Application constructor.
  • Booter, BootOptions interfaces have to live in @loopback/core to avoid circular dependencies. Similar to how the Server, Component interface live in core but are implemented in other packages.
  • For the test cases, I was thinking it might be better to create a follow-up PR adding to @loopback/testlab a utility to create a random "sandbox" which provides convenience functions to copy files into the temp folder, clear it, etc. Once that is done we can refactor this package and any others. If this is not ok, I can look into adding a simpler utility to this package / testlab first.

@virkt25
Copy link
Contributor Author

virkt25 commented Jan 18, 2018

I'm not sure why AppVeyor builds are failing. The expected and actual error messages are identical. 😞

@bajtos
Copy link
Member

bajtos commented Jan 18, 2018

For the test cases, I was thinking it might be better to create a follow-up PR adding to @loopback/testlab a utility to create a random "sandbox" which provides convenience functions to copy files into the temp folder, clear it, etc. Once that is done we can refactor this package and any others. If this is not ok, I can look into adding a simpler utility to this package / testlab first.

Fair enough 👍 I think I would slightly prefer if you could create and land that pull request adding "sandbox" functions before finishing this pull request. Your proposed approach is fine with me as long as the follow-up work is done ASAP - I don't want it to be delayed ad infinitum, as it often happens with clean-ups.

@bajtos
Copy link
Member

bajtos commented Jan 18, 2018

Booter, BootOptions interfaces have to live in @loopback/core to avoid circular dependencies. Similar to how the Server, Component interface live in core but are implemented in other packages.

This makes sense to a certain degree. Unfortunately it also means that we cannot version the bootstrapper and the core runtime independently any more :(

For example, if we decide to rename one of the boot phases (booter methods) in the future, we must release a major version of both core and boot at the same time.

We have been already bitten by a built-in app.boot() in LoopBack 1.x and went through a bit of pain to extract it into a standalone module loopback-boot in a user-friendly way.

I would rather avoid repeating the same mistake in loopback-next.

Passing in options to app.boot(). I'm not sure what of what other way we can offer for setting these options. Removed it from Application constructor.

I think the simplest solution for now is to remove app.boot from core and move it to a regular (non-member) function in @loopback/boot.

Example usage (EDITED on 2018-01-19 17:31 CEST):

import {Application} from '@loopback/core';
import {boot} from '@loopback/boot';

new Application({components: [BootComponent]});
boot(app, {
  projectRoot: '',
  controllers: {...}
});

This will allow us to remove BootOptions from core and put it in boot where it logically belongs.

To be honest, I think we should revisit the current design and consider moving the registration of individual booters to a different class than Application, a class that will live in the boot package. What would be the downsides of such design?

Example usage:

import {Application} from '@loopback/core';
import {ControllerBooter, BootRunnert} from '@loopback/boot';
const app = new Application();
const booter = new BootComponent(app);
booter.register(ControllerBooter);
booter.run({
  projectRoot: __dirname,
  controllers: {
  dirs: ['controllers'],
  extensions: ['.controller.js'],
  nested: true
}

@raymondfeng @kjdelisle could you please chime in?

```ts
import {Application} from '@loopback/core';
import {ControllerBooter, BootComponent} from '@loopback/boot';
const app = new Application({components:[BootComponent]});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't work after #742 is done. I am proposing to consider components config property as deprecated and stop using it in new code and examples, so that we have less things to fix as part of #742.

const app = new Application();
app.component(BootComponent);
// etc.

#### Examples
**Via BootOptions**
```ts
new Application({components: [BootComponent]});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto, please use app.component instead.

@virkt25 virkt25 self-assigned this Jan 18, 2018
@bajtos
Copy link
Member

bajtos commented Feb 19, 2018

@virkt25 Since you are adding a new package, please make sure that all steps described in add a new package and new package checklist are implemented as part of this pull request. See #1013 and #1015 for more details.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I like this new design a lot!

I am no longer able to review all changes in full details as I was re-reading this patch too many times. Please get somebody with fresh eyes to give this pull request a closer look.

// Project Root and Boot Options need to be bound so we can resolve an
// instance of Bootstrapper.
this.bind(BootBindings.PROJECT_ROOT).to(this.projectRoot);
this.bind(BootBindings.BOOT_OPTIONS).to(this.bootOptions);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we create these bindings inside the constructor?

Have you considered using a dynamic binding, to ensure the bindings and the properties are always in sync?

this.bind(...).toDynamicValue(() => this.projectRoot);
this.bind(...).toDynamicValue(() => this.bootOptions);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea.

* app.booter(MyBooter, MyOtherBooter)
* ```
*/
booter(...booterCls: Constructor<Booter>[]): Binding[] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this method is accepting multiple arguments, I think it should have a plural name booters.

*/
booter(...booterCls: Constructor<Booter>[]): Binding[] {
// tslint:disable-next-line:no-any
return booterCls.map(cls => _bindBooter(<Context>(<any>this), cls));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map is used to transform an array of X values to an array of Y values. Please use forEach instead.

I find this typecast very worrying: <Context>(<any>this). Isn't there any way how to tell TypeScript that BootMixin can be applied only on classes inheriting from Context?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map is used because we're transforming an array of Constructor<Booter> classes to an array of Binding. Using forEach will mean manually adding bindings to an array to return.

I had tried constraining the Mixin type to Application but it caused issues with TypeScript ... issues relating to requiring me to import every definition used by Application into the file where we use the Mixin ... OR ... issues with being able to chain mixins. I'll add comments documenting this as per @raymondfeng's suggestion for future reference and maybe at some point there will be a way to fix this.

*/
mountComponentBooters(component: Constructor<{}>) {
const componentKey = `components.${component.name}`;
const compInstance = this.getSync(componentKey);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern when we have mountComponent{something} calling this.getSync to obtain the component instance is starting to repeat in too many places. IMO, we should refactor app.component and introduce app.mountComponent that can be used to access things provided by component instances.

// in application
  public component(componentCtor: Constructor<Component>, name?: string) {
    name = name || componentCtor.name;
    const componentKey = `components.${name}`;
    this.bind(componentKey)
      .toClass(componentCtor)
      .inScope(BindingScope.SINGLETON)
      .tag('component');
    // Assuming components can be synchronously instantiated
    const instance = this.getSync(componentKey);
    this._mountComponent(instance);
  }

  protected _mountComponent() {
    mountComponent(this, instance);
  }

  // in extensions/component mixins:
  // - do not change public component() method
  // - change mountComponent instead
  protected _mountComponent(component: Component & {booters: /*add typedef*/}) {
    super._mountComponent(component);
    if (component.booters) {
      this.booter(...compInstance.booters);
    }
  }

Thoughts? Feel free to leave this improvement out of this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a great idea! I've created #1017 to track this and keep it out of this PR. Feel free to edit that issue if I missed anything.

/**
* Filter Object for Bootstrapper
*/
filter?: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think the filter should left out of this initial implementation and we should spend more time researching what typical use cases for filter will be and how to support them in the easiest way.

It's not a blocker, this PR can be landed with the filter option in place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll mark this as a feature that may change. I'd like to leave it in for future discussion. It can't be used via the Mixin approach anyways for now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My worry is that we'll never have that discussion and there'll be a vestigial organ hanging off this code.

Anything that isn't something we're dead set on providing shouldn't be in the code base.
👎

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'll be more effort to remove this so I'm leaving it in and I'll create a follow up task for the discussion around this topic. In the meantime this is something we will definitely need to provide one way or another for APIC / etc. later on post-MVP.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue to discuss this further: #1021

export class ControllerBooterApp extends BootMixin(RestApplication) {
constructor(options?: ApplicationConfig) {
super(options);
this.projectRoot = __dirname;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👍

}

async start() {
await super.start();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method can be removed, since it's effectively a no-op now. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!


it('throws an error given a non-existent file', async () => {
const files = [resolve(SANDBOX_PATH, 'fake.artifact.js')];
expect(loadClassesFromFiles(files)).to.eventually.throw();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please narrow down the check to verify that an expected error was thrown. We don't want this test to pass when e.g. TypeError: undefined is not a function was reported by the loader.

// Customize @loopback/boot Booter Conventions here
this.bootOptions = {
controllers: {
// Customize ControllerBooter Conventiones here
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please spell out all default configuration options here? It will make it easier for users to immediately see which options are available for tweaking.

"api-docs",
"src"
],
"repository": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add the following so that lerna publishes it as a public package:

"publishConfig": {
    "access": "public"
  },

* - Adds `mountComponentBooters` which binds Booters to the application from `component.booters[]`
*/
// tslint:disable-next-line:no-any
export function BootMixin<T extends Constructor<any>>(superClass: T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be tricky things to deal with if the base is Constructor<Application>. We should at lease document why here.

@raymondfeng
Copy link
Contributor

A few commit messages violate our conventions:

⧗   input: fix(cli): Add comments to template
✖   subject must not be sentence-case, start-case, pascal-case, upper-case [subject-case]
✖   found 1 problems, 0 warnings
⧗   input: fix(boot): fix test name
✔   found 0 problems, 0 warnings
⧗   input: fix(boot): Move bootOptions to ApplicationConfig
✖   subject must not be sentence-case, start-case, pascal-case, upper-case [subject-case]
✖   found 1 problems, 0 warnings
⧗   input: fix(cli): loopbackBoot should always be enabled and not an option
✔   found 0 problems, 0 warnings

Copy link
Contributor

@kjdelisle kjdelisle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not happy with how much scope creep has occurred here, and the idea of committing some features based on potential future usage bothers me.

That being said, if you're certain we're going to need it and its existence requires upfront testing then I'll live with it.

/**
* Filter Object for Bootstrapper
*/
filter?: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My worry is that we'll never have that discussion and there'll be a vestigial organ hanging off this code.

Anything that isn't something we're dead set on providing shouldn't be in the code base.
👎

Copy link
Contributor

@b-admike b-admike left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏

@kjdelisle
Copy link
Contributor

Squash this to the minimum number of distinct, self-contained commits and then merge it when ready.

@kjdelisle
Copy link
Contributor

🎉 🎉 🎉 🎉 🎉 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants