├── .gitignore ├── bin └── index.js ├── src ├── @types │ └── color-hash │ │ └── index.d.ts ├── octokit.ts ├── index.ts ├── action-record.ts ├── run-event.ts ├── find-event-function.ts ├── instance.ts ├── register-models.ts └── model.ts ├── Dockerfile ├── action.yml ├── .github └── workflows │ └── ci.yml ├── tsconfig.json ├── tests ├── __snapshots__ │ └── model.test.ts.snap ├── instance.test.ts └── model.test.ts ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | __tests__/runner/* 3 | dist 4 | .rts2_cache_* -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../dist/index.js') 4 | -------------------------------------------------------------------------------- /src/@types/color-hash/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'color-hash' { 2 | export function hex (input: string): string 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | # Weird hack to install and run the library 4 | RUN npm install -g @jasonetco/action-record 5 | ENTRYPOINT ["action-record"] 6 | -------------------------------------------------------------------------------- /src/octokit.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from '@actions/github' 2 | 3 | // Export a singleton 4 | const octokit = new GitHub(process.env.GITHUB_TOKEN as string) 5 | export default octokit 6 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: ActionRecord 2 | description: The GitHub Actions ORM 3 | author: JasonEtco 4 | inputs: 5 | baseDir: 6 | description: The base directory of the ActionRecord files 7 | default: action-record 8 | runs: 9 | using: docker 10 | image: Dockerfile 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import runEvent from './run-event' 3 | 4 | async function run () { 5 | try { 6 | core.debug('Running event') 7 | await runEvent() 8 | } catch (error) { 9 | core.debug(error) 10 | core.setFailed(error.message) 11 | } 12 | } 13 | 14 | run() 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [10.x, 12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm ci, build, and test 21 | run: | 22 | npm ci 23 | npm run build 24 | npm test -------------------------------------------------------------------------------- /src/action-record.ts: -------------------------------------------------------------------------------- 1 | import { GitHub, context } from '@actions/github' 2 | import { Context } from '@actions/github/lib/context' 3 | import Model from './model' 4 | 5 | export default class ActionRecord { 6 | /** 7 | * An object of all the available models 8 | */ 9 | public models: { [key: string]: Model } 10 | 11 | public github: GitHub 12 | public context: Context 13 | 14 | constructor () { 15 | this.github = new GitHub(process.env.GITHUB_TOKEN as string) 16 | this.models = {} 17 | this.context = context 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/run-event.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import ActionRecord from './action-record' 3 | import { registerModels } from './register-models' 4 | import findEventFunction from './find-event-function' 5 | 6 | export default async function runEvent () { 7 | // Create the ActionRecord instance 8 | const actionRecord = new ActionRecord() 9 | // Register the models, setting actionRecord.models 10 | await registerModels(actionRecord) 11 | // Find the event function 12 | const eventFn = findEventFunction(context.eventName) 13 | if (eventFn) { 14 | // Run it 15 | return eventFn(actionRecord) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "lib": [ 5 | "es2015", 6 | "es2017" 7 | ], 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "target": "es5", 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUnusedLocals": true, 14 | "pretty": true, 15 | "strict": true, 16 | "sourceMap": true, 17 | "outDir": "./dist", 18 | "skipLibCheck": true, 19 | "noImplicitAny": true, 20 | "esModuleInterop": true, 21 | "declaration": true 22 | }, 23 | "include": [ 24 | "src/**/*" 25 | ], 26 | "compileOnSave": false 27 | } -------------------------------------------------------------------------------- /tests/__snapshots__/model.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Model #create calls the expected hooks 1`] = ` 4 | Instance { 5 | "action_record_id": "new-uuid", 6 | "created_at": "2009-07-12T20:10:41Z", 7 | "issue_number": 4, 8 | "login": "matchai", 9 | "modelName": "user", 10 | } 11 | `; 12 | 13 | exports[`Model #create creates and returns the expected record 1`] = ` 14 | Instance { 15 | "action_record_id": "new-uuid", 16 | "created_at": "2009-07-12T20:10:41Z", 17 | "issue_number": 4, 18 | "login": "matchai", 19 | "modelName": "user", 20 | } 21 | `; 22 | 23 | exports[`Model #create throws the expected error if the data object does not match the schema 1`] = `"\\"foo\\" is not allowed"`; 24 | 25 | exports[`Model #findOne returns the expected record 1`] = ` 26 | Instance { 27 | "action_record_id": "29da0a67-062a-46f8-b397-7eac68513492", 28 | "created_at": "2009-07-12T20:10:41Z", 29 | "foo": true, 30 | "issue_number": 1, 31 | "login": "JasonEtco", 32 | "modelName": "user", 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /tests/instance.test.ts: -------------------------------------------------------------------------------- 1 | import Joi from '@hapi/joi' 2 | import nock from 'nock' 3 | import Model from '../src/model' 4 | import Instance from '../src/instance' 5 | 6 | describe('Instance', () => { 7 | let instance: Instance 8 | let model: Model 9 | 10 | beforeEach(() => { 11 | model = new Model({ 12 | name: 'user', 13 | schema: Joi.object({ 14 | login: Joi.string() 15 | }) 16 | }) 17 | 18 | instance = new Instance(model, { 19 | issue_number: 1, 20 | action_record_id: '123abc', 21 | created_at: 'now' 22 | }) 23 | 24 | nock('https://api.github.com') 25 | .patch(/^\/.*\/.*\/issues\/\d+$/).reply(200) 26 | }) 27 | 28 | describe('#destroy', () => { 29 | beforeEach(() => { 30 | process.env.GITHUB_REPOSITORY = 'JasonEtco/example' 31 | }) 32 | 33 | afterEach(() => { 34 | delete process.env.GITHUB_REPOSITORY 35 | }) 36 | 37 | it('returns the expected record', async () => { 38 | await instance.destroy() 39 | expect(nock.isDone()).toBe(true) 40 | }) 41 | }) 42 | }) -------------------------------------------------------------------------------- /src/find-event-function.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import isFunction from 'is-function' 4 | import * as core from '@actions/core' 5 | 6 | /** 7 | * Finds the relevant event file for a given event and returns the function 8 | */ 9 | export default function findEventFunction (event: string): Function | null { 10 | const cwd = process.env.GITHUB_WORKSPACE as string 11 | const baseDir = core.getInput('baseDir') || 'action-record' 12 | const filename = core.getInput(`events.${event}`) || `${event}.js` 13 | const pathToFile = path.join(cwd, baseDir, 'events', filename) 14 | 15 | core.debug(`Going to find event function: ${pathToFile}`) 16 | 17 | // Verify that the file exists 18 | if (!fs.existsSync(pathToFile)) return null 19 | 20 | // Require the JS file 21 | const func = require(pathToFile) 22 | 23 | // Verify that they've exported a function 24 | if (!isFunction(func)) throw new Error(`${pathToFile} does not export a function!`) 25 | 26 | // Return the function to be run 27 | core.debug(`${event} event function is good to go!`) 28 | return func 29 | } 30 | -------------------------------------------------------------------------------- /src/instance.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import Model from './model' 3 | import octokit from './octokit' 4 | 5 | export interface IssueRecord { 6 | action_record_id: string 7 | created_at: string 8 | issue_number: number 9 | [key: string]: any 10 | } 11 | 12 | export default class Instance { 13 | readonly modelName: string 14 | readonly action_record_id: string 15 | readonly created_at: string 16 | readonly issue_number: number 17 | 18 | // Index signature 19 | [key: string]: any 20 | 21 | constructor (model: Model, issue_record: IssueRecord) { 22 | this.modelName = model.name 23 | 24 | const { 25 | action_record_id, 26 | created_at, 27 | issue_number, 28 | ...rest 29 | } = issue_record 30 | 31 | this.action_record_id = action_record_id 32 | this.created_at = created_at 33 | this.issue_number = issue_number 34 | 35 | Object.assign(this, rest) 36 | } 37 | 38 | async destroy () { 39 | return octokit.issues.update({ 40 | ...context.repo, 41 | issue_number: this.issue_number, 42 | state: 'closed' 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 GitHub, Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jasonetco/action-record", 3 | "version": "0.0.10", 4 | "description": "The GitHub Actions ORM", 5 | "main": "dist/index.js", 6 | "module": "dist/action-record.esm.js", 7 | "typings": "dist/index.d.ts", 8 | "bin": { 9 | "action-record": "bin/index.js" 10 | }, 11 | "scripts": { 12 | "build": "ncc build ./src/index.ts -o dist", 13 | "test": "jest", 14 | "prepublishOnly": "npm run build" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/JasonEtco/action-record.git" 19 | }, 20 | "files": [ 21 | "dist", 22 | "bin", 23 | "package.json", 24 | "LICENSE" 25 | ], 26 | "keywords": [ 27 | "actions", 28 | "node", 29 | "setup" 30 | ], 31 | "author": "JasonEtco", 32 | "license": "MIT", 33 | "dependencies": { 34 | "@actions/core": "^1.1.0", 35 | "@actions/github": "^1.1.0", 36 | "@hapi/joi": "^15.1.1", 37 | "@octokit/rest": "^16.28.9", 38 | "before-after-hook": "^2.1.0", 39 | "color-hash": "^1.0.3", 40 | "is-function": "^1.0.1", 41 | "uuid": "^3.3.3" 42 | }, 43 | "devDependencies": { 44 | "@types/hapi__joi": "^15.0.4", 45 | "@types/is-function": "^1.0.0", 46 | "@types/jest": "^24.0.18", 47 | "@types/nock": "^10.0.3", 48 | "@types/node": "^12.7.4", 49 | "@types/uuid": "^3.4.5", 50 | "@zeit/ncc": "^0.20.4", 51 | "jest": "^24.9.0", 52 | "nock": "^11.3.3", 53 | "ts-jest": "^24.0.2", 54 | "tslib": "^1.10.0", 55 | "typescript": "^3.6.2" 56 | }, 57 | "jest": { 58 | "testEnvironment": "node", 59 | "coveragePathIgnorePatterns": [ 60 | "/dist/" 61 | ], 62 | "moduleFileExtensions": [ 63 | "ts", 64 | "js", 65 | "json" 66 | ], 67 | "transform": { 68 | ".+\\.tsx?$": "ts-jest" 69 | }, 70 | "testMatch": [ 71 | "/tests/**/*.test.(ts|js)" 72 | ], 73 | "globals": { 74 | "ts-jest": { 75 | "babelConfig": false 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/register-models.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import Joi from '@hapi/joi' 4 | import * as core from '@actions/core' 5 | import { context } from '@actions/github' 6 | import colorHash from 'color-hash' 7 | import ActionRecord from './action-record' 8 | import octokit from './octokit' 9 | import Model, { ModelInput } from './model' 10 | 11 | /** 12 | * Create a label in the repo for the given model if 13 | * it doesn't exist. This will fail silently if it does. 14 | */ 15 | export async function createModelLabel (name: string) { 16 | // Create new label if it doesn't exist 17 | try { 18 | await octokit.issues.createLabel({ 19 | ...context.repo, 20 | name, 21 | color: colorHash.hex(name) 22 | }) 23 | } catch (err) { 24 | core.debug(err.message) 25 | // TODO: Handle errors. If this throws because the label 26 | // already exists, ignore the error. Else, throw. 27 | } 28 | } 29 | 30 | type ModelFn = (opts: { Joi: typeof Joi }) => ModelInput 31 | 32 | /** 33 | * Register all models in the `baseDir/models` folder. 34 | */ 35 | export async function registerModels (actionRecord: ActionRecord) { 36 | const cwd = process.env.GITHUB_WORKSPACE as string 37 | const baseDir = core.getInput('baseDir') || 'action-record' 38 | const modelsDir = path.join(cwd, baseDir, 'models') 39 | core.debug(`Loading the models directory: ${modelsDir}`) 40 | 41 | const modelFiles = fs.readdirSync(modelsDir) 42 | core.debug(`Found ${modelFiles.length} models`) 43 | 44 | return Promise.all(modelFiles.map(async modelFile => { 45 | // Require the exported object 46 | const pathToModelFile = path.join(modelsDir, modelFile) 47 | core.debug(`Requiring model file: ${pathToModelFile}`) 48 | const modelFn = require(pathToModelFile) as ModelFn 49 | const model = modelFn({ Joi }) 50 | 51 | // Create the model label 52 | await createModelLabel(model.name) 53 | 54 | // Add it to the ActionRecord class 55 | actionRecord.models[model.name] = new Model(model) 56 | })) 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

ActionRecord

2 | 3 |

4 | ⚠️This is extremely WIP. Please don't use it or open issues just yet!⚠️ 5 |

6 | 7 |

8 | 📑 An "ORM" for storing data in a GitHub repository using GitHub Actions
9 | Usage • 10 | FAQ 11 |

12 | 13 | ## Usage 14 | 15 | ActionRecord works by running JavaScript functions in the repository to decide how and where to store the provided raw data. 16 | 17 | Including it should be as simple as using the action in your `.github/workflows` file with the `GITHUB_TOKEN` secret: 18 | 19 | ```yaml 20 | steps: 21 | - uses: JasonEtco/action-record 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | ``` 25 | 26 | This will tell ActionRecord to run a JavaScript file called `/action-record/events/.js`, where `EVENT` is the the event name that triggered the workflow. 27 | 28 | ## Event handlers 29 | 30 | These should be JavaScript files that export a function that takes one argument, an instance of the [ActionRecord class](./src/action-record.ts): 31 | 32 | ```js 33 | module.exports = async action => { 34 | // Do stuff 35 | } 36 | ``` 37 | 38 | An example use-case is to store arbitrary data as issues, categorized by labels. For example, we can create a new issue with the label `user` to store any new user that pushes to a repo. We do this by defining a `user` model, and then using typical ORM methods in our specific event handler. ActionRecord will load all models from `action-record/models` before running the event handler, and put them onto `action.models`. 39 | 40 | ```js 41 | // action-record/models/user.js 42 | module.exports = ({ Joi }) => ({ 43 | name: 'user', 44 | schema: { 45 | login: Joi.string().meta({ unique: true }) 46 | } 47 | }) 48 | 49 | // action-record/events/push.js 50 | module.exports = async action => { 51 | await action.models.user.create({ 52 | login: action.context.payload.sender.login 53 | }) 54 | } 55 | ``` 56 | 57 | This will create a new issue with a label `user`: 58 | 59 | image 60 |
61 | 62 | ## Querying for data 63 | 64 | Need to query your "database"? No problem! Like most ORMs, each model gets `findOne` and `findAll` methods. These take an object argument to do some basic filtering. 65 | 66 | ```js 67 | // action-record/events/push.js 68 | module.exports = async action => { 69 | await action.models.user.create({ login: 'JasonEtco' }) 70 | const record = await action.models.user.findOne({ login: 'JasonEtco' }) 71 | console.log(record) 72 | // -> { login: 'JasonEtco', created_at: 1566405542797, action_record_id: '085aed5c-deac-4d57-bcd3-94fc10b9c50f', issue_number: 1 } 73 | } 74 | ``` 75 | 76 | ### Models 77 | 78 | Models function similar to any other ORM; they require a name and a schema (here, using [Joi](https://github.com/hapijs/joi)). You can even define "hooks": 79 | 80 | ```js 81 | // action-record/models/user.js 82 | module.exports = ({ Joi }) => ({ 83 | name: 'user' 84 | // A Joi schema that will be run to validate any new records 85 | schema: { 86 | login: Joi.string() 87 | }, 88 | hooks: { 89 | beforeCreate: async candidateRecord => {}, 90 | afterCreate: async newRecord => {} 91 | } 92 | }) 93 | ``` 94 | 95 | #### Available hooks 96 | 97 | Here's a list of all of the available hooks, in the order in which they run: 98 | 99 | ``` 100 | beforeCreate 101 | beforeValidate 102 | afterValidate 103 | beforeSave 104 | afterSave 105 | afterCreate 106 | ``` 107 | 108 | A common use-case might be to validate that a record doesn't already exist: 109 | 110 | ```js 111 | // action-record/models/user.js 112 | module.exports = ({ Joi, github }) => ({ 113 | name: 'user' 114 | // A Joi schema that will be run to validate any new records 115 | schema: { 116 | login: Joi.string() 117 | }, 118 | hooks: { 119 | async beforeCreate (candidateRecord) { 120 | const existingRecord = await this.findOne({ login: candidateRecord.login }) 121 | if (existingRecord) { 122 | throw new Error(`User with ${existingRecord.login} already exists!`) 123 | } 124 | }, 125 | } 126 | }) 127 | ``` 128 | 129 | ### Options 130 | 131 | ```yml 132 | steps: 133 | - uses: JasonEtco/action-record 134 | env: 135 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 136 | with: 137 | baseDir: action-record 138 | ``` 139 | 140 | 141 | ## FAQ 142 | 143 | **Should I use this in producti-** 144 | 145 | **No.** This was made as an experiment - there are far too many reasons not to actually use this. But, if you choose to, awesome! 146 | 147 | **This is dumb. You shouldn't use GitHub or Actions as a datastore.** 148 | 149 | Yes. That isn't a question, but I don't disagree. The point of this is to show that we _can_, not that we should. 150 | -------------------------------------------------------------------------------- /tests/model.test.ts: -------------------------------------------------------------------------------- 1 | import Joi from '@hapi/joi' 2 | import nock from 'nock' 3 | import uuid from 'uuid' 4 | import Model from '../src/model' 5 | import Instance from '../src/instance' 6 | 7 | describe('Model', () => { 8 | let model: Model 9 | 10 | beforeEach(() => { 11 | model = new Model({ 12 | name: 'user', 13 | schema: Joi.object({ 14 | login: Joi.string() 15 | }) 16 | }) 17 | 18 | nock('https://api.github.com') 19 | .get(/^\/search\/issues\?q=/).reply(200, { 20 | items: [{ 21 | number: 1, 22 | labels: [{ name: model.name }], 23 | created_at: '2009-07-12T20:10:41Z', 24 | body: Model.jsonToBody({ action_record_id: '29da0a67-062a-46f8-b397-7eac68513492', foo: true, login: 'JasonEtco' }) 25 | }, { 26 | number: 2, 27 | labels: [{ name: model.name }], 28 | created_at: '2009-07-12T20:10:41Z', 29 | body: Model.jsonToBody({ action_record_id: '66eb86bd-046b-40b4-9d74-e81c41360ae3', foo: false, login: 'SomeoneElse' }) 30 | }, { 31 | number: 3, 32 | labels: [{ name: model.name }], 33 | created_at: '2009-07-12T20:10:41Z', 34 | body: Model.jsonToBody({ action_record_id: '95820a71-1d88-41a7-b98c-b5e395cf4ec2', foo: true, login: 'Nope' }) 35 | }] 36 | }) 37 | .post(/.*\/.*\/issues/).reply(200, { 38 | number: 4, 39 | created_at: '2009-07-12T20:10:41Z' 40 | }) 41 | }) 42 | 43 | describe('#findOne', () => { 44 | it('returns the expected record', async () => { 45 | const record = await model.findOne({ login: 'JasonEtco' }) as Instance 46 | expect(record).toBeInstanceOf(Instance) 47 | expect(record.login).toBe('JasonEtco') 48 | expect(record).toMatchSnapshot() 49 | }) 50 | 51 | it('returns null if no record was found', async () => { 52 | const record = await model.findOne({ login: 'nope' }) 53 | expect(record).toBeNull() 54 | }) 55 | }) 56 | 57 | describe('#findAll', () => { 58 | it('returns the expected records', async () => { 59 | const records = await model.findAll() 60 | expect(records.length).toBe(3) 61 | expect(records.every(record => record instanceof Instance)).toBe(true) 62 | }) 63 | 64 | it('returns the expected records with a `where` object', async () => { 65 | const records = await model.findAll({ foo: true }) 66 | expect(records.length).toBe(2) 67 | expect(records.every(record => record.foo)).toBe(true) 68 | expect(records.every(record => record instanceof Instance)).toBe(true) 69 | }) 70 | 71 | it('returns an empty array if no records were found', async () => { 72 | const records = await model.findAll({ foo: 'pizza' }) 73 | expect(records).toEqual([]) 74 | }) 75 | }) 76 | 77 | describe('#create', () => { 78 | beforeEach(() => { 79 | process.env.GITHUB_REPOSITORY = 'JasonEtco/example' 80 | }) 81 | 82 | afterEach(() => { 83 | delete process.env.GITHUB_REPOSITORY 84 | }) 85 | 86 | it('creates and returns the expected record', async () => { 87 | jest.spyOn(uuid, 'v4').mockReturnValueOnce('new-uuid') 88 | const newRecord = await model.create({ login: 'matchai' }) 89 | expect(newRecord).toBeInstanceOf(Instance) 90 | expect(newRecord.login).toBe('matchai') 91 | expect(newRecord).toMatchSnapshot() 92 | }) 93 | 94 | it('throws the expected error if the data object does not match the schema', async () => { 95 | await expect(model.create({ foo: true })).rejects.toThrowErrorMatchingSnapshot() 96 | }) 97 | 98 | it('calls the expected hooks', async () => { 99 | // Register the hooks 100 | const hooks = { 101 | beforeCreate: jest.fn(), 102 | afterCreate: jest.fn() 103 | } 104 | 105 | model.registerHook('beforeCreate', hooks.beforeCreate) 106 | model.registerHook('afterCreate', hooks.afterCreate) 107 | 108 | // Create the record 109 | jest.spyOn(uuid, 'v4').mockReturnValueOnce('new-uuid') 110 | await model.create({ login: 'matchai' }) 111 | 112 | // Verify that beforeCreate was called correctly 113 | expect(hooks.beforeCreate).toHaveBeenCalled() 114 | expect(hooks.beforeCreate).toHaveBeenCalledTimes(1) 115 | expect(hooks.beforeCreate.mock.calls[0][0]).toEqual({ login: 'matchai' }) 116 | 117 | // Verify that afterCreate was called correctly 118 | expect(hooks.afterCreate).toHaveBeenCalled() 119 | expect(hooks.afterCreate).toHaveBeenCalledTimes(1) 120 | expect(hooks.afterCreate.mock.calls[0][0]).toBeInstanceOf(Instance) 121 | expect(hooks.afterCreate.mock.calls[0][0]).toMatchSnapshot() 122 | }) 123 | 124 | it('calls the expected hooks when the hooks were provided in the constructor', async () => { 125 | // Register the hooks 126 | const hooks = { 127 | beforeCreate: jest.fn(), 128 | afterCreate: jest.fn() 129 | } 130 | 131 | model = new Model({ 132 | name: 'user', 133 | schema: Joi.object({ 134 | login: Joi.string() 135 | }), 136 | hooks 137 | }) 138 | 139 | // Create the record 140 | jest.spyOn(uuid, 'v4').mockReturnValueOnce('new-uuid') 141 | await model.create({ login: 'matchai' }) 142 | 143 | // Verify that beforeCreate was called correctly 144 | expect(hooks.beforeCreate).toHaveBeenCalled() 145 | expect(hooks.beforeCreate).toHaveBeenCalledTimes(1) 146 | 147 | // Verify that afterCreate was called correctly 148 | expect(hooks.afterCreate).toHaveBeenCalled() 149 | expect(hooks.afterCreate).toHaveBeenCalledTimes(1) 150 | }) 151 | }) 152 | }) -------------------------------------------------------------------------------- /src/model.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import { Schema } from '@hapi/joi' 3 | import { IssuesCreateResponse } from '@octokit/rest' 4 | import { Hook, HookSingular } from 'before-after-hook' 5 | import uuid from 'uuid' 6 | import octokit from './octokit' 7 | import Instance, { IssueRecord } from './instance' 8 | 9 | type HookFn = (opts: any) => Promise 10 | 11 | interface Hooks { 12 | beforeValidate?: HookFn 13 | afterValidate?: HookFn 14 | beforeCreate?: HookFn 15 | afterCreate?: HookFn 16 | beforeSave?: HookFn 17 | afterSave?: HookFn 18 | } 19 | 20 | export interface ModelInput { 21 | name: string 22 | schema: Schema 23 | hooks?: Hooks 24 | } 25 | 26 | export default class Model { 27 | readonly name: string 28 | readonly schema: Schema 29 | private hooks: { [key: string]: HookSingular } 30 | private hookMap: { [key in keyof Hooks]: Function } 31 | 32 | constructor (model: ModelInput) { 33 | this.name = model.name 34 | this.schema = model.schema 35 | 36 | // An object of Hook instances 37 | this.hooks = { 38 | create: new Hook.Singular(), 39 | validate: new Hook.Singular(), 40 | save: new Hook.Singular() 41 | } 42 | 43 | // A map of hook names to actual setters 44 | this.hookMap = { 45 | beforeCreate: this.hooks.create.before, 46 | afterCreate: this.hooks.create.after, 47 | beforeValidate: this.hooks.validate.after, 48 | afterValidate: this.hooks.validate.after, 49 | beforeSave: this.hooks.save.after, 50 | afterSave: this.hooks.save.after, 51 | } 52 | 53 | // Actually register the hooks 54 | if (model.hooks) { 55 | for (const key in model.hooks) { 56 | const hookFn = model.hooks[key as keyof Hooks] 57 | if (!hookFn) throw new Error(`No hook function for ${key} was provided`) 58 | this.registerHook(key as keyof Hooks, hookFn) 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * Set a before or after hook for an operation. 65 | */ 66 | public registerHook (hookName: keyof Hooks, hookFn: HookFn) { 67 | const hookRegisterer = this.hookMap[hookName] 68 | if (!hookRegisterer) throw new Error(`${hookName} is not a valid hook.`) 69 | return hookRegisterer(hookFn.bind(this)) 70 | } 71 | 72 | /** 73 | * Convert a `where` object to a string for proper searching. 74 | */ 75 | private whereToStr (where: any) { 76 | const jsonStr = JSON.stringify(where, null, 2) 77 | return jsonStr.slice(1, jsonStr.length - 2) 78 | } 79 | 80 | /** 81 | * Call the search API to return all issues with this model's label. 82 | */ 83 | private async searchForIssues (): Promise { 84 | // Search for issues by this label 85 | const issues = await octokit.search.issuesAndPullRequests({ 86 | q: `is:issue is:open label:${this.name}` 87 | }) 88 | return issues.data.items 89 | } 90 | 91 | private parseDataFromIssueBody (body: string): any { 92 | const reg = /^`{3}\n([\s\S]+)\n`{3}/ 93 | const match = body.match(reg) 94 | if (match && match[1]) return JSON.parse(match[1]) 95 | return {} 96 | } 97 | 98 | private convertIssueToJson (issue: IssuesCreateResponse): IssueRecord { 99 | const json = this.parseDataFromIssueBody(issue.body) 100 | return { 101 | ...json, 102 | created_at: issue.created_at, 103 | issue_number: issue.number 104 | } 105 | } 106 | 107 | /** 108 | * Find one record that matches the provided filter object. 109 | */ 110 | async findOne (where: any) { 111 | const issues = await this.searchForIssues() 112 | const whereStr = this.whereToStr(where) 113 | const found = issues.find(issue => (issue.body as string).includes(whereStr)) 114 | if (found) return new Instance(this, this.convertIssueToJson(found)) 115 | return null 116 | } 117 | 118 | /** 119 | * Find all records that match the provided filter object. 120 | */ 121 | async findAll (where?: any): Promise { 122 | const issues = await this.searchForIssues() 123 | if (where) { 124 | const whereStr = this.whereToStr(where) 125 | const found = issues.filter(issue => (issue.body as string).includes(whereStr)) 126 | return found.map(item => new Instance(this, this.convertIssueToJson(item))) 127 | } else { 128 | return issues.map(item => new Instance(this, this.convertIssueToJson(item))) 129 | } 130 | } 131 | 132 | /** 133 | * Create a new record 134 | */ 135 | async create (opts: any): Promise { 136 | return this.hooks.validate(async () => { 137 | // Validate the provided object against the model's schema 138 | await this.schema.validate(opts) 139 | 140 | // Actually go to create the record 141 | return this.hooks.create(async () => { 142 | // Generate a UUID 143 | const id = uuid.v4() 144 | 145 | const data = { 146 | action_record_id: id, 147 | ...opts 148 | } 149 | 150 | // Save the new record to GitHub 151 | return this.hooks.save(async () => { 152 | // Create the new issue 153 | const newIssue = await octokit.issues.create({ 154 | ...context.repo, 155 | title: `[${this.name}]: ${id}`, 156 | body: Model.jsonToBody(data), 157 | labels: [this.name] 158 | }) 159 | 160 | // Return the new instance 161 | return new Instance(this, { 162 | ...data, 163 | created_at: newIssue.data.created_at, 164 | issue_number: newIssue.data.number 165 | }) 166 | }, opts) 167 | }, opts) 168 | }, opts) 169 | } 170 | 171 | static jsonToBody (data: any) { 172 | return '```\n' + JSON.stringify(data, null, 2) + '\n```' 173 | } 174 | } --------------------------------------------------------------------------------