├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── __tests__ └── fixtures │ ├── v2 │ └── metadata │ │ ├── tables.yaml │ │ └── version.yaml │ └── v3 │ └── metadata │ ├── databases │ ├── databases.yaml │ └── default │ │ └── tables │ │ ├── public_users.yaml │ │ └── tables.yaml │ └── version.yaml ├── action.yml ├── dist ├── index.js ├── index.js.map ├── licenses.txt └── sourcemap-register.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── diff │ ├── __tests__ │ │ ├── functions.test.ts │ │ ├── index.test.ts │ │ └── version.test.ts │ ├── functions.ts │ ├── index.ts │ ├── tables │ │ ├── __tests__ │ │ │ ├── diffTableEntries.test.ts │ │ │ ├── fixtures │ │ │ │ ├── empty.yaml │ │ │ │ ├── todos_and_users.yaml │ │ │ │ ├── users.yaml │ │ │ │ └── users_permissions │ │ │ │ │ ├── manager.yaml │ │ │ │ │ ├── manager_and_user_select_filtered.yaml │ │ │ │ │ ├── manager_and_user_select_full.yaml │ │ │ │ │ ├── user_select_filtered.yaml │ │ │ │ │ ├── user_select_full.yaml │ │ │ │ │ ├── user_select_full_all_columns.yaml │ │ │ │ │ ├── user_select_full_computed_fields.yaml │ │ │ │ │ └── user_select_full_id_column.yaml │ │ │ ├── formatTableEntries.test.ts │ │ │ └── utils.ts │ │ ├── index.ts │ │ ├── permissions │ │ │ ├── columns │ │ │ │ ├── __tests__ │ │ │ │ │ └── index.test.ts │ │ │ │ ├── functions.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── functions.ts │ │ │ ├── index.ts │ │ │ └── templates.ts │ │ ├── table.ts │ │ ├── templates.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── templates.ts │ ├── types.ts │ └── version.ts ├── load │ ├── AbstractMetadataLoader.ts │ ├── GitHubLoader.ts │ ├── WorkspaceLoader.ts │ ├── __tests__ │ │ ├── GitHubLoader.test.ts │ │ └── WorkspaceLoader.test.ts │ ├── consts.ts │ ├── functions.ts │ ├── types.ts │ └── yaml.ts └── main.ts ├── tsconfig.eslint.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | jest.config.js 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.eslint.json" 9 | }, 10 | "rules": { 11 | "filenames/match-regex": "off", 12 | "i18n-text/no-en": "off", 13 | "eslint-comments/no-use": "off", 14 | "import/no-namespace": "off", 15 | "no-unused-vars": "off", 16 | "@typescript-eslint/no-unused-vars": "error", 17 | "@typescript-eslint/explicit-member-accessibility": [ 18 | "error", 19 | {"accessibility": "no-public"} 20 | ], 21 | "@typescript-eslint/no-require-imports": "error", 22 | "@typescript-eslint/array-type": "error", 23 | "@typescript-eslint/await-thenable": "error", 24 | "@typescript-eslint/ban-ts-comment": "error", 25 | "camelcase": "off", 26 | "@typescript-eslint/consistent-type-assertions": "error", 27 | "@typescript-eslint/explicit-function-return-type": [ 28 | "error", 29 | {"allowExpressions": true} 30 | ], 31 | "@typescript-eslint/func-call-spacing": ["error", "never"], 32 | "@typescript-eslint/no-array-constructor": "error", 33 | "@typescript-eslint/no-empty-interface": "error", 34 | "@typescript-eslint/no-explicit-any": "error", 35 | "@typescript-eslint/no-extraneous-class": "error", 36 | "@typescript-eslint/no-for-in-array": "error", 37 | "@typescript-eslint/no-inferrable-types": "error", 38 | "@typescript-eslint/no-misused-new": "error", 39 | "@typescript-eslint/no-namespace": "error", 40 | "@typescript-eslint/no-non-null-assertion": "warn", 41 | "@typescript-eslint/no-unnecessary-qualifier": "error", 42 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 43 | "@typescript-eslint/no-useless-constructor": "error", 44 | "@typescript-eslint/no-var-requires": "error", 45 | "@typescript-eslint/prefer-for-of": "warn", 46 | "@typescript-eslint/prefer-function-type": "warn", 47 | "@typescript-eslint/prefer-includes": "error", 48 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 49 | "@typescript-eslint/promise-function-async": "error", 50 | "@typescript-eslint/require-array-sort-compare": "error", 51 | "@typescript-eslint/restrict-plus-operands": "error", 52 | "semi": "off", 53 | "@typescript-eslint/semi": ["error", "never"], 54 | "@typescript-eslint/type-annotation-spacing": "error", 55 | "@typescript-eslint/unbound-method": "error" 56 | }, 57 | "env": { 58 | "node": true, 59 | "es6": true, 60 | "jest/globals": true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: 'github-actions' 9 | directory: '/' 10 | schedule: 11 | interval: 'monthly' 12 | reviewers: 13 | - 'Fieldguide/ci' 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | ref: ${{ github.head_ref }} 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version-file: '.nvmrc' 17 | cache: 'npm' 18 | - run: npm install 19 | - run: npm run all 20 | - uses: stefanzweifel/git-auto-commit-action@v5 21 | with: 22 | commit_message: Build project 23 | test: 24 | runs-on: ubuntu-latest 25 | strategy: 26 | matrix: 27 | config: ['v2', 'v3'] 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version-file: '.nvmrc' 33 | - uses: ./ 34 | with: 35 | project_dir: '__tests__/fixtures/${{ matrix.config }}' 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [published, edited] 5 | jobs: 6 | git: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: Actions-R-Us/actions-tagger@v2 10 | env: 11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | with: 13 | publish_latest_tag: true 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib/**/* -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | dist/ 3 | lib/ 4 | node_modules/ 5 | **/fixtures/ 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "amatiasq.sort-imports" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "cSpell.words": [ 4 | "consts", 5 | "endregion", 6 | "Fieldguide", 7 | "hasura", 8 | "jsondiffpatch", 9 | "Octokit", 10 | "rowspan", 11 | "todos", 12 | "untracked", 13 | "urlcat" 14 | ], 15 | "files.exclude": { 16 | "dist/": true, 17 | "lib/": true 18 | }, 19 | "typescript.tsdk": "./node_modules/typescript/lib" 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Fieldguide, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![ci](https://github.com/Fieldguide/action-hasura-change-summary/actions/workflows/ci.yml/badge.svg)](https://github.com/Fieldguide/action-hasura-change-summary/actions/workflows/ci.yml) 2 | 3 | # Hasura Change Summary 4 | 5 | [GitHub Action](https://github.com/features/actions) to generate readable [Hasura](https://hasura.io/) metadata change summaries. 6 | 7 | Hasura Change Summary example comment 8 | 9 | ## Features 10 | 11 | This action currently supports changes to database table metadata including row-level and column-level permissions. 12 | 13 | [Other metadata](https://hasura.io/docs/latest/graphql/core/migrations/reference/metadata-format/) such as actions, cron triggers, and remote schemas are not currently supported. 14 | 15 | ## Usage 16 | 17 | For example, with marocchino's [Sticky Pull Request Comment](https://github.com/marocchino/sticky-pull-request-comment): 18 | 19 | ```yaml 20 | name: ci 21 | 22 | on: 23 | pull_request: 24 | paths: 25 | - 'metadata/**.yaml' 26 | 27 | jobs: 28 | hasura-change-summary: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout code 32 | uses: actions/checkout@v4 33 | 34 | - name: Detect Hasura metadata changes 35 | uses: Fieldguide/action-hasura-change-summary@v3 36 | id: hasura-change-summary 37 | with: 38 | hasura_endpoint: https://my-pr-${{ github.event.number }}-app.example.com 39 | 40 | - name: Create or update summary comment 41 | uses: marocchino/sticky-pull-request-comment@v2 42 | if: steps.hasura-change-summary.outputs.change_html 43 | with: 44 | header: hasura-change-summary 45 | message: ${{ steps.hasura-change-summary.outputs.change_html }} 46 | ``` 47 | 48 | ## Inputs 49 | 50 | | input | description | 51 | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 52 | | `github_token` | Repository `GITHUB_TOKEN` or personal access token secret; defaults to [`github.token`](https://docs.github.com/en/actions/security-guides/automatic-token-authentication) | 53 | | `project_dir` | Hasura project directory, relative to `GITHUB_WORKSPACE`; defaults to `.` | 54 | | `hasura_endpoint` | Hasura GraphQL engine http(s) endpoint, used for deep console links | 55 | 56 | ## Outputs 57 | 58 | | output | description | 59 | | ------------- | ------------------- | 60 | | `change_html` | HTML change summary | 61 | -------------------------------------------------------------------------------- /__tests__/fixtures/v2/metadata/tables.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/v2/metadata/version.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/v3/metadata/databases/databases.yaml: -------------------------------------------------------------------------------- 1 | - name: default 2 | kind: postgres 3 | tables: "!include default/tables/tables.yaml" 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/v3/metadata/databases/default/tables/public_users.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: users 3 | schema: public 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/v3/metadata/databases/default/tables/tables.yaml: -------------------------------------------------------------------------------- 1 | - "!include public_users.yaml" 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/v3/metadata/version.yaml: -------------------------------------------------------------------------------- 1 | version: 3 2 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Hasura Change Summary' 2 | description: 'Generate readable Hasura metadata change summaries' 3 | author: 'Fieldguide' 4 | inputs: 5 | github_token: 6 | required: false 7 | description: 'Repository GITHUB_TOKEN or personal access token secret' 8 | default: ${{ github.token }} 9 | project_dir: 10 | required: false 11 | description: 'Hasura project directory, relative to GITHUB_WORKSPACE' 12 | default: '.' 13 | hasura_endpoint: 14 | required: false 15 | description: 'Hasura GraphQL engine http(s) endpoint, used for deep console links' 16 | outputs: 17 | change_html: 18 | description: 'HTML change summary' 19 | runs: 20 | using: 'node20' 21 | main: 'dist/index.js' 22 | branding: 23 | icon: message-circle 24 | color: gray-dark 25 | -------------------------------------------------------------------------------- /dist/sourcemap-register.js: -------------------------------------------------------------------------------- 1 | (()=>{var e={650:e=>{var r=Object.prototype.toString;var n=typeof Buffer.alloc==="function"&&typeof Buffer.allocUnsafe==="function"&&typeof Buffer.from==="function";function isArrayBuffer(e){return r.call(e).slice(8,-1)==="ArrayBuffer"}function fromArrayBuffer(e,r,t){r>>>=0;var o=e.byteLength-r;if(o<0){throw new RangeError("'offset' is out of bounds")}if(t===undefined){t=o}else{t>>>=0;if(t>o){throw new RangeError("'length' is out of bounds")}}return n?Buffer.from(e.slice(r,r+t)):new Buffer(new Uint8Array(e.slice(r,r+t)))}function fromString(e,r){if(typeof r!=="string"||r===""){r="utf8"}if(!Buffer.isEncoding(r)){throw new TypeError('"encoding" must be a valid string encoding')}return n?Buffer.from(e,r):new Buffer(e,r)}function bufferFrom(e,r,t){if(typeof e==="number"){throw new TypeError('"value" argument must not be a number')}if(isArrayBuffer(e)){return fromArrayBuffer(e,r,t)}if(typeof e==="string"){return fromString(e,r)}return n?Buffer.from(e):new Buffer(e)}e.exports=bufferFrom},274:(e,r,n)=>{var t=n(339);var o=Object.prototype.hasOwnProperty;var i=typeof Map!=="undefined";function ArraySet(){this._array=[];this._set=i?new Map:Object.create(null)}ArraySet.fromArray=function ArraySet_fromArray(e,r){var n=new ArraySet;for(var t=0,o=e.length;t=0){return r}}else{var n=t.toSetString(e);if(o.call(this._set,n)){return this._set[n]}}throw new Error('"'+e+'" is not in the set.')};ArraySet.prototype.at=function ArraySet_at(e){if(e>=0&&e{var t=n(190);var o=5;var i=1<>1;return r?-n:n}r.encode=function base64VLQ_encode(e){var r="";var n;var i=toVLQSigned(e);do{n=i&a;i>>>=o;if(i>0){n|=u}r+=t.encode(n)}while(i>0);return r};r.decode=function base64VLQ_decode(e,r,n){var i=e.length;var s=0;var l=0;var c,p;do{if(r>=i){throw new Error("Expected more digits in base 64 VLQ value.")}p=t.decode(e.charCodeAt(r++));if(p===-1){throw new Error("Invalid base64 digit: "+e.charAt(r-1))}c=!!(p&u);p&=a;s=s+(p<{var n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("");r.encode=function(e){if(0<=e&&e{r.GREATEST_LOWER_BOUND=1;r.LEAST_UPPER_BOUND=2;function recursiveSearch(e,n,t,o,i,a){var u=Math.floor((n-e)/2)+e;var s=i(t,o[u],true);if(s===0){return u}else if(s>0){if(n-u>1){return recursiveSearch(u,n,t,o,i,a)}if(a==r.LEAST_UPPER_BOUND){return n1){return recursiveSearch(e,u,t,o,i,a)}if(a==r.LEAST_UPPER_BOUND){return u}else{return e<0?-1:e}}}r.search=function search(e,n,t,o){if(n.length===0){return-1}var i=recursiveSearch(-1,n.length,e,n,t,o||r.GREATEST_LOWER_BOUND);if(i<0){return-1}while(i-1>=0){if(t(n[i],n[i-1],true)!==0){break}--i}return i}},680:(e,r,n)=>{var t=n(339);function generatedPositionAfter(e,r){var n=e.generatedLine;var o=r.generatedLine;var i=e.generatedColumn;var a=r.generatedColumn;return o>n||o==n&&a>=i||t.compareByGeneratedPositionsInflated(e,r)<=0}function MappingList(){this._array=[];this._sorted=true;this._last={generatedLine:-1,generatedColumn:0}}MappingList.prototype.unsortedForEach=function MappingList_forEach(e,r){this._array.forEach(e,r)};MappingList.prototype.add=function MappingList_add(e){if(generatedPositionAfter(this._last,e)){this._last=e;this._array.push(e)}else{this._sorted=false;this._array.push(e)}};MappingList.prototype.toArray=function MappingList_toArray(){if(!this._sorted){this._array.sort(t.compareByGeneratedPositionsInflated);this._sorted=true}return this._array};r.H=MappingList},758:(e,r)=>{function swap(e,r,n){var t=e[r];e[r]=e[n];e[n]=t}function randomIntInRange(e,r){return Math.round(e+Math.random()*(r-e))}function doQuickSort(e,r,n,t){if(n{var t;var o=n(339);var i=n(345);var a=n(274).I;var u=n(449);var s=n(758).U;function SourceMapConsumer(e,r){var n=e;if(typeof e==="string"){n=o.parseSourceMapInput(e)}return n.sections!=null?new IndexedSourceMapConsumer(n,r):new BasicSourceMapConsumer(n,r)}SourceMapConsumer.fromSourceMap=function(e,r){return BasicSourceMapConsumer.fromSourceMap(e,r)};SourceMapConsumer.prototype._version=3;SourceMapConsumer.prototype.__generatedMappings=null;Object.defineProperty(SourceMapConsumer.prototype,"_generatedMappings",{configurable:true,enumerable:true,get:function(){if(!this.__generatedMappings){this._parseMappings(this._mappings,this.sourceRoot)}return this.__generatedMappings}});SourceMapConsumer.prototype.__originalMappings=null;Object.defineProperty(SourceMapConsumer.prototype,"_originalMappings",{configurable:true,enumerable:true,get:function(){if(!this.__originalMappings){this._parseMappings(this._mappings,this.sourceRoot)}return this.__originalMappings}});SourceMapConsumer.prototype._charIsMappingSeparator=function SourceMapConsumer_charIsMappingSeparator(e,r){var n=e.charAt(r);return n===";"||n===","};SourceMapConsumer.prototype._parseMappings=function SourceMapConsumer_parseMappings(e,r){throw new Error("Subclasses must implement _parseMappings")};SourceMapConsumer.GENERATED_ORDER=1;SourceMapConsumer.ORIGINAL_ORDER=2;SourceMapConsumer.GREATEST_LOWER_BOUND=1;SourceMapConsumer.LEAST_UPPER_BOUND=2;SourceMapConsumer.prototype.eachMapping=function SourceMapConsumer_eachMapping(e,r,n){var t=r||null;var i=n||SourceMapConsumer.GENERATED_ORDER;var a;switch(i){case SourceMapConsumer.GENERATED_ORDER:a=this._generatedMappings;break;case SourceMapConsumer.ORIGINAL_ORDER:a=this._originalMappings;break;default:throw new Error("Unknown order of iteration.")}var u=this.sourceRoot;a.map((function(e){var r=e.source===null?null:this._sources.at(e.source);r=o.computeSourceURL(u,r,this._sourceMapURL);return{source:r,generatedLine:e.generatedLine,generatedColumn:e.generatedColumn,originalLine:e.originalLine,originalColumn:e.originalColumn,name:e.name===null?null:this._names.at(e.name)}}),this).forEach(e,t)};SourceMapConsumer.prototype.allGeneratedPositionsFor=function SourceMapConsumer_allGeneratedPositionsFor(e){var r=o.getArg(e,"line");var n={source:o.getArg(e,"source"),originalLine:r,originalColumn:o.getArg(e,"column",0)};n.source=this._findSourceIndex(n.source);if(n.source<0){return[]}var t=[];var a=this._findMapping(n,this._originalMappings,"originalLine","originalColumn",o.compareByOriginalPositions,i.LEAST_UPPER_BOUND);if(a>=0){var u=this._originalMappings[a];if(e.column===undefined){var s=u.originalLine;while(u&&u.originalLine===s){t.push({line:o.getArg(u,"generatedLine",null),column:o.getArg(u,"generatedColumn",null),lastColumn:o.getArg(u,"lastGeneratedColumn",null)});u=this._originalMappings[++a]}}else{var l=u.originalColumn;while(u&&u.originalLine===r&&u.originalColumn==l){t.push({line:o.getArg(u,"generatedLine",null),column:o.getArg(u,"generatedColumn",null),lastColumn:o.getArg(u,"lastGeneratedColumn",null)});u=this._originalMappings[++a]}}}return t};r.SourceMapConsumer=SourceMapConsumer;function BasicSourceMapConsumer(e,r){var n=e;if(typeof e==="string"){n=o.parseSourceMapInput(e)}var t=o.getArg(n,"version");var i=o.getArg(n,"sources");var u=o.getArg(n,"names",[]);var s=o.getArg(n,"sourceRoot",null);var l=o.getArg(n,"sourcesContent",null);var c=o.getArg(n,"mappings");var p=o.getArg(n,"file",null);if(t!=this._version){throw new Error("Unsupported version: "+t)}if(s){s=o.normalize(s)}i=i.map(String).map(o.normalize).map((function(e){return s&&o.isAbsolute(s)&&o.isAbsolute(e)?o.relative(s,e):e}));this._names=a.fromArray(u.map(String),true);this._sources=a.fromArray(i,true);this._absoluteSources=this._sources.toArray().map((function(e){return o.computeSourceURL(s,e,r)}));this.sourceRoot=s;this.sourcesContent=l;this._mappings=c;this._sourceMapURL=r;this.file=p}BasicSourceMapConsumer.prototype=Object.create(SourceMapConsumer.prototype);BasicSourceMapConsumer.prototype.consumer=SourceMapConsumer;BasicSourceMapConsumer.prototype._findSourceIndex=function(e){var r=e;if(this.sourceRoot!=null){r=o.relative(this.sourceRoot,r)}if(this._sources.has(r)){return this._sources.indexOf(r)}var n;for(n=0;n1){v.source=l+_[1];l+=_[1];v.originalLine=i+_[2];i=v.originalLine;v.originalLine+=1;v.originalColumn=a+_[3];a=v.originalColumn;if(_.length>4){v.name=c+_[4];c+=_[4]}}m.push(v);if(typeof v.originalLine==="number"){d.push(v)}}}s(m,o.compareByGeneratedPositionsDeflated);this.__generatedMappings=m;s(d,o.compareByOriginalPositions);this.__originalMappings=d};BasicSourceMapConsumer.prototype._findMapping=function SourceMapConsumer_findMapping(e,r,n,t,o,a){if(e[n]<=0){throw new TypeError("Line must be greater than or equal to 1, got "+e[n])}if(e[t]<0){throw new TypeError("Column must be greater than or equal to 0, got "+e[t])}return i.search(e,r,o,a)};BasicSourceMapConsumer.prototype.computeColumnSpans=function SourceMapConsumer_computeColumnSpans(){for(var e=0;e=0){var t=this._generatedMappings[n];if(t.generatedLine===r.generatedLine){var i=o.getArg(t,"source",null);if(i!==null){i=this._sources.at(i);i=o.computeSourceURL(this.sourceRoot,i,this._sourceMapURL)}var a=o.getArg(t,"name",null);if(a!==null){a=this._names.at(a)}return{source:i,line:o.getArg(t,"originalLine",null),column:o.getArg(t,"originalColumn",null),name:a}}}return{source:null,line:null,column:null,name:null}};BasicSourceMapConsumer.prototype.hasContentsOfAllSources=function BasicSourceMapConsumer_hasContentsOfAllSources(){if(!this.sourcesContent){return false}return this.sourcesContent.length>=this._sources.size()&&!this.sourcesContent.some((function(e){return e==null}))};BasicSourceMapConsumer.prototype.sourceContentFor=function SourceMapConsumer_sourceContentFor(e,r){if(!this.sourcesContent){return null}var n=this._findSourceIndex(e);if(n>=0){return this.sourcesContent[n]}var t=e;if(this.sourceRoot!=null){t=o.relative(this.sourceRoot,t)}var i;if(this.sourceRoot!=null&&(i=o.urlParse(this.sourceRoot))){var a=t.replace(/^file:\/\//,"");if(i.scheme=="file"&&this._sources.has(a)){return this.sourcesContent[this._sources.indexOf(a)]}if((!i.path||i.path=="/")&&this._sources.has("/"+t)){return this.sourcesContent[this._sources.indexOf("/"+t)]}}if(r){return null}else{throw new Error('"'+t+'" is not in the SourceMap.')}};BasicSourceMapConsumer.prototype.generatedPositionFor=function SourceMapConsumer_generatedPositionFor(e){var r=o.getArg(e,"source");r=this._findSourceIndex(r);if(r<0){return{line:null,column:null,lastColumn:null}}var n={source:r,originalLine:o.getArg(e,"line"),originalColumn:o.getArg(e,"column")};var t=this._findMapping(n,this._originalMappings,"originalLine","originalColumn",o.compareByOriginalPositions,o.getArg(e,"bias",SourceMapConsumer.GREATEST_LOWER_BOUND));if(t>=0){var i=this._originalMappings[t];if(i.source===n.source){return{line:o.getArg(i,"generatedLine",null),column:o.getArg(i,"generatedColumn",null),lastColumn:o.getArg(i,"lastGeneratedColumn",null)}}}return{line:null,column:null,lastColumn:null}};t=BasicSourceMapConsumer;function IndexedSourceMapConsumer(e,r){var n=e;if(typeof e==="string"){n=o.parseSourceMapInput(e)}var t=o.getArg(n,"version");var i=o.getArg(n,"sections");if(t!=this._version){throw new Error("Unsupported version: "+t)}this._sources=new a;this._names=new a;var u={line:-1,column:0};this._sections=i.map((function(e){if(e.url){throw new Error("Support for url field in sections not implemented.")}var n=o.getArg(e,"offset");var t=o.getArg(n,"line");var i=o.getArg(n,"column");if(t{var t=n(449);var o=n(339);var i=n(274).I;var a=n(680).H;function SourceMapGenerator(e){if(!e){e={}}this._file=o.getArg(e,"file",null);this._sourceRoot=o.getArg(e,"sourceRoot",null);this._skipValidation=o.getArg(e,"skipValidation",false);this._sources=new i;this._names=new i;this._mappings=new a;this._sourcesContents=null}SourceMapGenerator.prototype._version=3;SourceMapGenerator.fromSourceMap=function SourceMapGenerator_fromSourceMap(e){var r=e.sourceRoot;var n=new SourceMapGenerator({file:e.file,sourceRoot:r});e.eachMapping((function(e){var t={generated:{line:e.generatedLine,column:e.generatedColumn}};if(e.source!=null){t.source=e.source;if(r!=null){t.source=o.relative(r,t.source)}t.original={line:e.originalLine,column:e.originalColumn};if(e.name!=null){t.name=e.name}}n.addMapping(t)}));e.sources.forEach((function(t){var i=t;if(r!==null){i=o.relative(r,t)}if(!n._sources.has(i)){n._sources.add(i)}var a=e.sourceContentFor(t);if(a!=null){n.setSourceContent(t,a)}}));return n};SourceMapGenerator.prototype.addMapping=function SourceMapGenerator_addMapping(e){var r=o.getArg(e,"generated");var n=o.getArg(e,"original",null);var t=o.getArg(e,"source",null);var i=o.getArg(e,"name",null);if(!this._skipValidation){this._validateMapping(r,n,t,i)}if(t!=null){t=String(t);if(!this._sources.has(t)){this._sources.add(t)}}if(i!=null){i=String(i);if(!this._names.has(i)){this._names.add(i)}}this._mappings.add({generatedLine:r.line,generatedColumn:r.column,originalLine:n!=null&&n.line,originalColumn:n!=null&&n.column,source:t,name:i})};SourceMapGenerator.prototype.setSourceContent=function SourceMapGenerator_setSourceContent(e,r){var n=e;if(this._sourceRoot!=null){n=o.relative(this._sourceRoot,n)}if(r!=null){if(!this._sourcesContents){this._sourcesContents=Object.create(null)}this._sourcesContents[o.toSetString(n)]=r}else if(this._sourcesContents){delete this._sourcesContents[o.toSetString(n)];if(Object.keys(this._sourcesContents).length===0){this._sourcesContents=null}}};SourceMapGenerator.prototype.applySourceMap=function SourceMapGenerator_applySourceMap(e,r,n){var t=r;if(r==null){if(e.file==null){throw new Error("SourceMapGenerator.prototype.applySourceMap requires either an explicit source file, "+'or the source map\'s "file" property. Both were omitted.')}t=e.file}var a=this._sourceRoot;if(a!=null){t=o.relative(a,t)}var u=new i;var s=new i;this._mappings.unsortedForEach((function(r){if(r.source===t&&r.originalLine!=null){var i=e.originalPositionFor({line:r.originalLine,column:r.originalColumn});if(i.source!=null){r.source=i.source;if(n!=null){r.source=o.join(n,r.source)}if(a!=null){r.source=o.relative(a,r.source)}r.originalLine=i.line;r.originalColumn=i.column;if(i.name!=null){r.name=i.name}}}var l=r.source;if(l!=null&&!u.has(l)){u.add(l)}var c=r.name;if(c!=null&&!s.has(c)){s.add(c)}}),this);this._sources=u;this._names=s;e.sources.forEach((function(r){var t=e.sourceContentFor(r);if(t!=null){if(n!=null){r=o.join(n,r)}if(a!=null){r=o.relative(a,r)}this.setSourceContent(r,t)}}),this)};SourceMapGenerator.prototype._validateMapping=function SourceMapGenerator_validateMapping(e,r,n,t){if(r&&typeof r.line!=="number"&&typeof r.column!=="number"){throw new Error("original.line and original.column are not numbers -- you probably meant to omit "+"the original mapping entirely and only map the generated position. If so, pass "+"null for the original mapping instead of an object with empty or null values.")}if(e&&"line"in e&&"column"in e&&e.line>0&&e.column>=0&&!r&&!n&&!t){return}else if(e&&"line"in e&&"column"in e&&r&&"line"in r&&"column"in r&&e.line>0&&e.column>=0&&r.line>0&&r.column>=0&&n){return}else{throw new Error("Invalid mapping: "+JSON.stringify({generated:e,source:n,original:r,name:t}))}};SourceMapGenerator.prototype._serializeMappings=function SourceMapGenerator_serializeMappings(){var e=0;var r=1;var n=0;var i=0;var a=0;var u=0;var s="";var l;var c;var p;var f;var g=this._mappings.toArray();for(var h=0,d=g.length;h0){if(!o.compareByGeneratedPositionsInflated(c,g[h-1])){continue}l+=","}}l+=t.encode(c.generatedColumn-e);e=c.generatedColumn;if(c.source!=null){f=this._sources.indexOf(c.source);l+=t.encode(f-u);u=f;l+=t.encode(c.originalLine-1-i);i=c.originalLine-1;l+=t.encode(c.originalColumn-n);n=c.originalColumn;if(c.name!=null){p=this._names.indexOf(c.name);l+=t.encode(p-a);a=p}}s+=l}return s};SourceMapGenerator.prototype._generateSourcesContent=function SourceMapGenerator_generateSourcesContent(e,r){return e.map((function(e){if(!this._sourcesContents){return null}if(r!=null){e=o.relative(r,e)}var n=o.toSetString(e);return Object.prototype.hasOwnProperty.call(this._sourcesContents,n)?this._sourcesContents[n]:null}),this)};SourceMapGenerator.prototype.toJSON=function SourceMapGenerator_toJSON(){var e={version:this._version,sources:this._sources.toArray(),names:this._names.toArray(),mappings:this._serializeMappings()};if(this._file!=null){e.file=this._file}if(this._sourceRoot!=null){e.sourceRoot=this._sourceRoot}if(this._sourcesContents){e.sourcesContent=this._generateSourcesContent(e.sources,e.sourceRoot)}return e};SourceMapGenerator.prototype.toString=function SourceMapGenerator_toString(){return JSON.stringify(this.toJSON())};r.h=SourceMapGenerator},351:(e,r,n)=>{var t;var o=n(591).h;var i=n(339);var a=/(\r?\n)/;var u=10;var s="$$$isSourceNode$$$";function SourceNode(e,r,n,t,o){this.children=[];this.sourceContents={};this.line=e==null?null:e;this.column=r==null?null:r;this.source=n==null?null:n;this.name=o==null?null:o;this[s]=true;if(t!=null)this.add(t)}SourceNode.fromStringWithSourceMap=function SourceNode_fromStringWithSourceMap(e,r,n){var t=new SourceNode;var o=e.split(a);var u=0;var shiftNextLine=function(){var e=getNextLine();var r=getNextLine()||"";return e+r;function getNextLine(){return u=0;r--){this.prepend(e[r])}}else if(e[s]||typeof e==="string"){this.children.unshift(e)}else{throw new TypeError("Expected a SourceNode, string, or an array of SourceNodes and strings. Got "+e)}return this};SourceNode.prototype.walk=function SourceNode_walk(e){var r;for(var n=0,t=this.children.length;n0){r=[];for(n=0;n{function getArg(e,r,n){if(r in e){return e[r]}else if(arguments.length===3){return n}else{throw new Error('"'+r+'" is a required argument.')}}r.getArg=getArg;var n=/^(?:([\w+\-.]+):)?\/\/(?:(\w+:\w+)@)?([\w.-]*)(?::(\d+))?(.*)$/;var t=/^data:.+\,.+$/;function urlParse(e){var r=e.match(n);if(!r){return null}return{scheme:r[1],auth:r[2],host:r[3],port:r[4],path:r[5]}}r.urlParse=urlParse;function urlGenerate(e){var r="";if(e.scheme){r+=e.scheme+":"}r+="//";if(e.auth){r+=e.auth+"@"}if(e.host){r+=e.host}if(e.port){r+=":"+e.port}if(e.path){r+=e.path}return r}r.urlGenerate=urlGenerate;function normalize(e){var n=e;var t=urlParse(e);if(t){if(!t.path){return e}n=t.path}var o=r.isAbsolute(n);var i=n.split(/\/+/);for(var a,u=0,s=i.length-1;s>=0;s--){a=i[s];if(a==="."){i.splice(s,1)}else if(a===".."){u++}else if(u>0){if(a===""){i.splice(s+1,u);u=0}else{i.splice(s,2);u--}}}n=i.join("/");if(n===""){n=o?"/":"."}if(t){t.path=n;return urlGenerate(t)}return n}r.normalize=normalize;function join(e,r){if(e===""){e="."}if(r===""){r="."}var n=urlParse(r);var o=urlParse(e);if(o){e=o.path||"/"}if(n&&!n.scheme){if(o){n.scheme=o.scheme}return urlGenerate(n)}if(n||r.match(t)){return r}if(o&&!o.host&&!o.path){o.host=r;return urlGenerate(o)}var i=r.charAt(0)==="/"?r:normalize(e.replace(/\/+$/,"")+"/"+r);if(o){o.path=i;return urlGenerate(o)}return i}r.join=join;r.isAbsolute=function(e){return e.charAt(0)==="/"||n.test(e)};function relative(e,r){if(e===""){e="."}e=e.replace(/\/$/,"");var n=0;while(r.indexOf(e+"/")!==0){var t=e.lastIndexOf("/");if(t<0){return r}e=e.slice(0,t);if(e.match(/^([^\/]+:\/)?\/*$/)){return r}++n}return Array(n+1).join("../")+r.substr(e.length+1)}r.relative=relative;var o=function(){var e=Object.create(null);return!("__proto__"in e)}();function identity(e){return e}function toSetString(e){if(isProtoString(e)){return"$"+e}return e}r.toSetString=o?identity:toSetString;function fromSetString(e){if(isProtoString(e)){return e.slice(1)}return e}r.fromSetString=o?identity:fromSetString;function isProtoString(e){if(!e){return false}var r=e.length;if(r<9){return false}if(e.charCodeAt(r-1)!==95||e.charCodeAt(r-2)!==95||e.charCodeAt(r-3)!==111||e.charCodeAt(r-4)!==116||e.charCodeAt(r-5)!==111||e.charCodeAt(r-6)!==114||e.charCodeAt(r-7)!==112||e.charCodeAt(r-8)!==95||e.charCodeAt(r-9)!==95){return false}for(var n=r-10;n>=0;n--){if(e.charCodeAt(n)!==36){return false}}return true}function compareByOriginalPositions(e,r,n){var t=strcmp(e.source,r.source);if(t!==0){return t}t=e.originalLine-r.originalLine;if(t!==0){return t}t=e.originalColumn-r.originalColumn;if(t!==0||n){return t}t=e.generatedColumn-r.generatedColumn;if(t!==0){return t}t=e.generatedLine-r.generatedLine;if(t!==0){return t}return strcmp(e.name,r.name)}r.compareByOriginalPositions=compareByOriginalPositions;function compareByGeneratedPositionsDeflated(e,r,n){var t=e.generatedLine-r.generatedLine;if(t!==0){return t}t=e.generatedColumn-r.generatedColumn;if(t!==0||n){return t}t=strcmp(e.source,r.source);if(t!==0){return t}t=e.originalLine-r.originalLine;if(t!==0){return t}t=e.originalColumn-r.originalColumn;if(t!==0){return t}return strcmp(e.name,r.name)}r.compareByGeneratedPositionsDeflated=compareByGeneratedPositionsDeflated;function strcmp(e,r){if(e===r){return 0}if(e===null){return 1}if(r===null){return-1}if(e>r){return 1}return-1}function compareByGeneratedPositionsInflated(e,r){var n=e.generatedLine-r.generatedLine;if(n!==0){return n}n=e.generatedColumn-r.generatedColumn;if(n!==0){return n}n=strcmp(e.source,r.source);if(n!==0){return n}n=e.originalLine-r.originalLine;if(n!==0){return n}n=e.originalColumn-r.originalColumn;if(n!==0){return n}return strcmp(e.name,r.name)}r.compareByGeneratedPositionsInflated=compareByGeneratedPositionsInflated;function parseSourceMapInput(e){return JSON.parse(e.replace(/^\)]}'[^\n]*\n/,""))}r.parseSourceMapInput=parseSourceMapInput;function computeSourceURL(e,r,n){r=r||"";if(e){if(e[e.length-1]!=="/"&&r[0]!=="/"){e+="/"}r=e+r}if(n){var t=urlParse(n);if(!t){throw new Error("sourceMapURL could not be parsed")}if(t.path){var o=t.path.lastIndexOf("/");if(o>=0){t.path=t.path.substring(0,o+1)}}r=join(urlGenerate(t),r)}return normalize(r)}r.computeSourceURL=computeSourceURL},997:(e,r,n)=>{n(591).h;r.SourceMapConsumer=n(952).SourceMapConsumer;n(351)},284:(e,r,n)=>{e=n.nmd(e);var t=n(997).SourceMapConsumer;var o=n(17);var i;try{i=n(147);if(!i.existsSync||!i.readFileSync){i=null}}catch(e){}var a=n(650);function dynamicRequire(e,r){return e.require(r)}var u=false;var s=false;var l=false;var c="auto";var p={};var f={};var g=/^data:application\/json[^,]+base64,/;var h=[];var d=[];function isInBrowser(){if(c==="browser")return true;if(c==="node")return false;return typeof window!=="undefined"&&typeof XMLHttpRequest==="function"&&!(window.require&&window.module&&window.process&&window.process.type==="renderer")}function hasGlobalProcessEventEmitter(){return typeof process==="object"&&process!==null&&typeof process.on==="function"}function globalProcessVersion(){if(typeof process==="object"&&process!==null){return process.version}else{return""}}function globalProcessStderr(){if(typeof process==="object"&&process!==null){return process.stderr}}function globalProcessExit(e){if(typeof process==="object"&&process!==null&&typeof process.exit==="function"){return process.exit(e)}}function handlerExec(e){return function(r){for(var n=0;n"}var n=this.getLineNumber();if(n!=null){r+=":"+n;var t=this.getColumnNumber();if(t){r+=":"+t}}}var o="";var i=this.getFunctionName();var a=true;var u=this.isConstructor();var s=!(this.isToplevel()||u);if(s){var l=this.getTypeName();if(l==="[object Object]"){l="null"}var c=this.getMethodName();if(i){if(l&&i.indexOf(l)!=0){o+=l+"."}o+=i;if(c&&i.indexOf("."+c)!=i.length-c.length-1){o+=" [as "+c+"]"}}else{o+=l+"."+(c||"")}}else if(u){o+="new "+(i||"")}else if(i){o+=i}else{o+=r;a=false}if(a){o+=" ("+r+")"}return o}function cloneCallSite(e){var r={};Object.getOwnPropertyNames(Object.getPrototypeOf(e)).forEach((function(n){r[n]=/^(?:is|get)/.test(n)?function(){return e[n].call(e)}:e[n]}));r.toString=CallSiteToString;return r}function wrapCallSite(e,r){if(r===undefined){r={nextPosition:null,curPosition:null}}if(e.isNative()){r.curPosition=null;return e}var n=e.getFileName()||e.getScriptNameOrSourceURL();if(n){var t=e.getLineNumber();var o=e.getColumnNumber()-1;var i=/^v(10\.1[6-9]|10\.[2-9][0-9]|10\.[0-9]{3,}|1[2-9]\d*|[2-9]\d|\d{3,}|11\.11)/;var a=i.test(globalProcessVersion())?0:62;if(t===1&&o>a&&!isInBrowser()&&!e.isEval()){o-=a}var u=mapSourcePosition({source:n,line:t,column:o});r.curPosition=u;e=cloneCallSite(e);var s=e.getFunctionName;e.getFunctionName=function(){if(r.nextPosition==null){return s()}return r.nextPosition.name||s()};e.getFileName=function(){return u.source};e.getLineNumber=function(){return u.line};e.getColumnNumber=function(){return u.column+1};e.getScriptNameOrSourceURL=function(){return u.source};return e}var l=e.isEval()&&e.getEvalOrigin();if(l){l=mapEvalOrigin(l);e=cloneCallSite(e);e.getEvalOrigin=function(){return l};return e}return e}function prepareStackTrace(e,r){if(l){p={};f={}}var n=e.name||"Error";var t=e.message||"";var o=n+": "+t;var i={nextPosition:null,curPosition:null};var a=[];for(var u=r.length-1;u>=0;u--){a.push("\n at "+wrapCallSite(r[u],i));i.nextPosition=i.curPosition}i.curPosition=i.nextPosition=null;return o+a.reverse().join("")}function getErrorSource(e){var r=/\n at [^(]+ \((.*):(\d+):(\d+)\)/.exec(e.stack);if(r){var n=r[1];var t=+r[2];var o=+r[3];var a=p[n];if(!a&&i&&i.existsSync(n)){try{a=i.readFileSync(n,"utf8")}catch(e){a=""}}if(a){var u=a.split(/(?:\r\n|\r|\n)/)[t-1];if(u){return n+":"+t+"\n"+u+"\n"+new Array(o).join(" ")+"^"}}}return null}function printErrorAndExit(e){var r=getErrorSource(e);var n=globalProcessStderr();if(n&&n._handle&&n._handle.setBlocking){n._handle.setBlocking(true)}if(r){console.error();console.error(r)}console.error(e.stack);globalProcessExit(1)}function shimEmitUncaughtException(){var e=process.emit;process.emit=function(r){if(r==="uncaughtException"){var n=arguments[1]&&arguments[1].stack;var t=this.listeners(r).length>0;if(n&&!t){return printErrorAndExit(arguments[1])}}return e.apply(this,arguments)}}var S=h.slice(0);var _=d.slice(0);r.wrapCallSite=wrapCallSite;r.getErrorSource=getErrorSource;r.mapSourcePosition=mapSourcePosition;r.retrieveSourceMap=v;r.install=function(r){r=r||{};if(r.environment){c=r.environment;if(["node","browser","auto"].indexOf(c)===-1){throw new Error("environment "+c+" was unknown. Available options are {auto, browser, node}")}}if(r.retrieveFile){if(r.overrideRetrieveFile){h.length=0}h.unshift(r.retrieveFile)}if(r.retrieveSourceMap){if(r.overrideRetrieveSourceMap){d.length=0}d.unshift(r.retrieveSourceMap)}if(r.hookRequire&&!isInBrowser()){var n=dynamicRequire(e,"module");var t=n.prototype._compile;if(!t.__sourceMapSupport){n.prototype._compile=function(e,r){p[r]=e;f[r]=undefined;return t.call(this,e,r)};n.prototype._compile.__sourceMapSupport=true}}if(!l){l="emptyCacheBetweenOperations"in r?r.emptyCacheBetweenOperations:false}if(!u){u=true;Error.prepareStackTrace=prepareStackTrace}if(!s){var o="handleUncaughtExceptions"in r?r.handleUncaughtExceptions:true;try{var i=dynamicRequire(e,"worker_threads");if(i.isMainThread===false){o=false}}catch(e){}if(o&&hasGlobalProcessEventEmitter()){s=true;shimEmitUncaughtException()}}};r.resetRetrieveHandlers=function(){h.length=0;d.length=0;h=S.slice(0);d=_.slice(0);v=handlerExec(d);m=handlerExec(h)}},147:e=>{"use strict";e.exports=require("fs")},17:e=>{"use strict";e.exports=require("path")}};var r={};function __webpack_require__(n){var t=r[n];if(t!==undefined){return t.exports}var o=r[n]={id:n,loaded:false,exports:{}};var i=true;try{e[n](o,o.exports,__webpack_require__);i=false}finally{if(i)delete r[n]}o.loaded=true;return o.exports}(()=>{__webpack_require__.nmd=e=>{e.paths=[];if(!e.children)e.children=[];return e}})();if(typeof __webpack_require__!=="undefined")__webpack_require__.ab=__dirname+"/";var n={};(()=>{__webpack_require__(284).install()})();module.exports=n})(); -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.ts$': 'ts-jest' 7 | }, 8 | verbose: true 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "action-hasura-change-summary", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "Generate readable Hasura metadata change summaries", 6 | "engines": { 7 | "node": ">=20.x <21" 8 | }, 9 | "main": "lib/main.js", 10 | "scripts": { 11 | "build": "tsc", 12 | "format": "prettier --write .", 13 | "format:check": "prettier --check .", 14 | "lint": "eslint . --ext .ts", 15 | "package": "ncc build --source-map --license licenses.txt", 16 | "test": "jest --watch", 17 | "test:ci": "jest", 18 | "all": "npm run build && npm run format:check && npm run lint && npm run package && npm run test:ci" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/Fieldguide/action-hasura-change-summary.git" 23 | }, 24 | "keywords": [ 25 | "actions", 26 | "hasura", 27 | "metadata", 28 | "change", 29 | "summary", 30 | "diff" 31 | ], 32 | "author": "Nick Amoscato ", 33 | "license": "MIT", 34 | "dependencies": { 35 | "@actions/core": "^1.10.0", 36 | "@actions/github": "^6.0.0", 37 | "@hasura/metadata": "^1.0.2", 38 | "js-yaml": "^4.1.0", 39 | "jsondiffpatch": "^0.4.1", 40 | "lodash": "^4.17.21", 41 | "mustache": "^4.2.0", 42 | "prettier": "^2.7.1", 43 | "urlcat": "^2.0.4" 44 | }, 45 | "devDependencies": { 46 | "@types/jest": "^28.1.6", 47 | "@types/js-yaml": "^4.0.5", 48 | "@types/lodash": "^4.14.189", 49 | "@types/mustache": "^4.2.1", 50 | "@types/node": "^20.17.30", 51 | "@typescript-eslint/parser": "^5.43.0", 52 | "@vercel/ncc": "^0.34.0", 53 | "eslint": "^8.28.0", 54 | "eslint-plugin-github": "^4.3.7", 55 | "eslint-plugin-jest": "^27.1.5", 56 | "html-validate": "^7.10.0", 57 | "jest": "^28.1.3", 58 | "ts-jest": "^28.0.7", 59 | "typescript": "^4.9.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/diff/__tests__/functions.test.ts: -------------------------------------------------------------------------------- 1 | import {tablesFromMetadata} from '../functions' 2 | 3 | describe('tablesFromMetadata', () => { 4 | test('converted from v2', () => { 5 | expect( 6 | tablesFromMetadata( 7 | { 8 | __converted_from: 2, 9 | version: 3, 10 | databases: [ 11 | { 12 | name: 'default', 13 | tables: [ 14 | { 15 | table: { 16 | schema: 'public', 17 | name: 'users' 18 | } 19 | } 20 | ] 21 | } 22 | ] 23 | }, 24 | false 25 | ) 26 | ).toStrictEqual([ 27 | { 28 | table: { 29 | schema: 'public', 30 | name: 'users' 31 | } 32 | } 33 | ]) 34 | }) 35 | 36 | test('v3', () => { 37 | expect( 38 | tablesFromMetadata( 39 | { 40 | version: 3, 41 | databases: [ 42 | { 43 | name: 'default', 44 | tables: [ 45 | { 46 | table: { 47 | schema: 'public', 48 | name: 'users' 49 | } 50 | } 51 | ] 52 | }, 53 | { 54 | name: 'foo', 55 | tables: [ 56 | { 57 | table: { 58 | schema: 'public', 59 | name: 'todos' 60 | } 61 | } 62 | ] 63 | } 64 | ] 65 | }, 66 | true 67 | ) 68 | ).toStrictEqual([ 69 | { 70 | table: { 71 | database: 'default', 72 | schema: 'public', 73 | name: 'users' 74 | } 75 | }, 76 | { 77 | table: { 78 | database: 'foo', 79 | schema: 'public', 80 | name: 'todos' 81 | } 82 | } 83 | ]) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /src/diff/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import 'html-validate/jest' 2 | 3 | import {format} from '..' 4 | import {tableEntryChange} from '../tables/__tests__/utils' 5 | 6 | describe('format', () => { 7 | describe('empty', () => { 8 | expect( 9 | format({ 10 | version: undefined, 11 | tables: { 12 | added: [], 13 | modified: [], 14 | deleted: [] 15 | } 16 | }) 17 | ).toStrictEqual('') 18 | }) 19 | 20 | describe('added and modified', () => { 21 | test('valid HTML', () => { 22 | expect( 23 | format({ 24 | version: 3, 25 | tables: { 26 | added: [ 27 | tableEntryChange({ 28 | schema: 'public', 29 | name: 'users', 30 | _links: { 31 | console: {href: 'URL'} 32 | } 33 | }) 34 | ], 35 | modified: [ 36 | tableEntryChange( 37 | { 38 | schema: 'public', 39 | name: 'todos' 40 | }, 41 | { 42 | select_permissions: { 43 | added: [ 44 | { 45 | role: 'user', 46 | columns: [] 47 | } 48 | ], 49 | modified: [], 50 | deleted: [] 51 | } 52 | } 53 | ) 54 | ], 55 | deleted: [] 56 | } 57 | }) 58 | ).toHTMLValidate({ 59 | rules: { 60 | 'no-deprecated-attr': 'off' 61 | } 62 | }) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/diff/__tests__/version.test.ts: -------------------------------------------------------------------------------- 1 | import {diffVersion, formatVersion} from '../version' 2 | 3 | describe('diffVersion', () => { 4 | test('v2 -> v2', () => { 5 | expect(diffVersion(2, 2)).toBeUndefined() 6 | }) 7 | 8 | test('v2 -> v3', () => { 9 | expect(diffVersion(2, undefined)).toStrictEqual(3) 10 | }) 11 | 12 | test('v3 -> v3', () => { 13 | expect(diffVersion(undefined, undefined)).toBeUndefined() 14 | }) 15 | }) 16 | 17 | describe('formatVersion', () => { 18 | test('not converted', () => { 19 | expect(formatVersion(undefined)).toStrictEqual('') 20 | }) 21 | 22 | test('converted', () => { 23 | expect(formatVersion(3)).toStrictEqual( 24 | `

Upgraded Config

25 |

26 | This project upgraded to config v3! Read about 27 | what has changed. 31 |

` 32 | ) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/diff/functions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeletePermissionEntry, 3 | InsertPermission, 4 | SelectPermission, 5 | SelectPermissionEntry 6 | } from '@hasura/metadata' 7 | import {isArray, isObject, isString} from 'lodash' 8 | import * as Mustache from 'mustache' 9 | import prettier from 'prettier' 10 | import {HasuraMetadataLatest} from '../load/types' 11 | import { 12 | ChangeType, 13 | DeltaAddition, 14 | DeltaDeletion, 15 | DeltaModificationConventional, 16 | PermissionEntry, 17 | TableEntry 18 | } from './types' 19 | 20 | export function isTableEntry(object: unknown): object is TableEntry { 21 | return ( 22 | isObject(object) && 23 | isObject((object as TableEntry).table) && 24 | isString((object as TableEntry).table.schema) && 25 | isString((object as TableEntry).table.name) 26 | ) 27 | } 28 | 29 | export function isPermissionEntry(object: unknown): object is PermissionEntry { 30 | return isObject(object) && isString((object as PermissionEntry).role) 31 | } 32 | 33 | export function isSelectPermissionEntry( 34 | object: PermissionEntry 35 | ): object is SelectPermissionEntry { 36 | return Boolean((object.permission as SelectPermission).computed_fields) 37 | } 38 | 39 | export function isDeletePermissionEntry( 40 | object: PermissionEntry 41 | ): object is DeletePermissionEntry { 42 | return !(object.permission as InsertPermission).columns 43 | } 44 | 45 | export function isAddition(delta: unknown): delta is DeltaAddition { 46 | return isArray(delta) && 1 === delta.length 47 | } 48 | 49 | export function isDeletion(delta: unknown): delta is DeltaDeletion { 50 | return ( 51 | isArray(delta) && 3 === delta.length && 0 === delta[1] && 0 === delta[2] 52 | ) 53 | } 54 | 55 | export function isConventionalModification( 56 | delta: unknown 57 | ): delta is DeltaModificationConventional { 58 | return isArray(delta) && 2 === delta.length 59 | } 60 | 61 | export function emptyChanges(): Record { 62 | return { 63 | added: [], 64 | modified: [], 65 | deleted: [] 66 | } 67 | } 68 | 69 | export function renderTemplate( 70 | template: string, 71 | view: Record, 72 | partials: Record = {} 73 | ): string { 74 | return prettier.format(Mustache.render(template, view, partials), { 75 | parser: 'html' 76 | }) 77 | } 78 | 79 | export function tab(message: string, level = 0): string { 80 | return ' '.repeat(1 + level) + message 81 | } 82 | 83 | export function tablesFromMetadata( 84 | metadata: HasuraMetadataLatest, 85 | qualifyTableEntries: boolean 86 | ): TableEntry[] { 87 | return metadata.databases.reduce((tables, database) => { 88 | return [ 89 | ...tables, 90 | ...database.tables.map(tableEntry => { 91 | return qualifyTableEntries 92 | ? qualifyTableEntry(tableEntry, database.name) 93 | : tableEntry 94 | }) 95 | ] 96 | }, []) 97 | } 98 | 99 | export function qualifyTableEntry( 100 | tableEntry: TableEntry, 101 | database: string 102 | ): TableEntry { 103 | return { 104 | ...tableEntry, 105 | table: {...tableEntry.table, database} 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/diff/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | import {Changes, DiffOptions} from './types' 4 | import {diffTableEntries, formatTableEntries} from './tables' 5 | import {diffVersion, formatVersion} from './version' 6 | import {renderTemplate, tablesFromMetadata} from './functions' 7 | 8 | import {CHANGE_TEMPLATE} from './templates' 9 | import {HasuraMetadataLatest} from './../load/types' 10 | 11 | export function diff( 12 | oldMetadata: HasuraMetadataLatest, 13 | newMetadata: HasuraMetadataLatest, 14 | options: DiffOptions = {} 15 | ): Changes { 16 | core.debug(`Diff options:\n${JSON.stringify(options, null, 2)}`) 17 | 18 | core.info('Diffing metadata version') 19 | const version = diffVersion( 20 | oldMetadata.__converted_from, 21 | newMetadata.__converted_from 22 | ) 23 | 24 | core.startGroup('Diffing table metadata') 25 | const qualifyTableEntries = 2 !== newMetadata.__converted_from 26 | const tables = diffTableEntries( 27 | tablesFromMetadata(oldMetadata, qualifyTableEntries), 28 | tablesFromMetadata(newMetadata, qualifyTableEntries), 29 | options 30 | ) 31 | core.endGroup() 32 | 33 | return {version, tables} 34 | } 35 | 36 | export function format(changes: Changes): string { 37 | const version = formatVersion(changes.version) 38 | const tables = formatTableEntries(changes.tables) 39 | 40 | if (!version && !tables) { 41 | return '' 42 | } 43 | 44 | return renderTemplate(CHANGE_TEMPLATE, {version, tables}) 45 | } 46 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/diffTableEntries.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DiffOptions, 3 | TableEntryChanges, 4 | TablePermissionColumnChanges 5 | } from '../../types' 6 | import {loadFixture, tableEntryChange} from './utils' 7 | 8 | import {TableEntry} from '@hasura/metadata' 9 | import {diffTableEntries} from '..' 10 | import {isString} from 'lodash' 11 | import {qualifyTableEntry} from '../../functions' 12 | 13 | describe('no change', () => { 14 | test('v2', () => { 15 | expect(diff('empty', 'empty')).toStrictEqual({ 16 | added: [], 17 | modified: [], 18 | deleted: [] 19 | }) 20 | }) 21 | 22 | test('v3', () => { 23 | expect( 24 | diff( 25 | loadTableEntryFixture('users', 'default'), 26 | loadTableEntryFixture('users', 'default') 27 | ) 28 | ).toStrictEqual({ 29 | added: [], 30 | modified: [], 31 | deleted: [] 32 | }) 33 | }) 34 | }) 35 | 36 | describe('added', () => { 37 | test('no endpoint', () => { 38 | expect( 39 | diff('empty', 'users_permissions/user_select_full') 40 | ).toStrictEqual({ 41 | added: [ 42 | { 43 | table: { 44 | database: undefined, 45 | schema: 'public', 46 | name: 'users' 47 | }, 48 | insert_permissions: { 49 | added: [], 50 | modified: [], 51 | deleted: [] 52 | }, 53 | select_permissions: { 54 | added: [ 55 | { 56 | role: 'user', 57 | columns: [ 58 | {name: 'created_at', isComputed: false, type: 'added'}, 59 | {name: 'id', isComputed: false, type: 'added'}, 60 | {name: 'last_login_at', isComputed: false, type: 'added'}, 61 | {name: 'name', isComputed: false, type: 'added'} 62 | ] 63 | } 64 | ], 65 | modified: [], 66 | deleted: [] 67 | }, 68 | update_permissions: { 69 | added: [], 70 | modified: [], 71 | deleted: [] 72 | }, 73 | delete_permissions: { 74 | added: [], 75 | modified: [], 76 | deleted: [] 77 | } 78 | } 79 | ], 80 | modified: [], 81 | deleted: [] 82 | }) 83 | }) 84 | 85 | describe('with configured endpoint', () => { 86 | test('v2', () => { 87 | expect( 88 | diff('empty', 'users', { 89 | hasuraEndpoint: 'http://localhost:8080/' 90 | }) 91 | ).toStrictEqual({ 92 | added: [ 93 | tableEntryChange({ 94 | database: undefined, 95 | schema: 'public', 96 | name: 'users', 97 | _links: { 98 | console: { 99 | href: 'http://localhost:8080/console/data/schema/public/tables/users/modify' 100 | } 101 | } 102 | }) 103 | ], 104 | modified: [], 105 | deleted: [] 106 | }) 107 | }) 108 | 109 | test('v3 with database', () => { 110 | expect( 111 | diff( 112 | loadTableEntryFixture('empty', 'default'), 113 | loadTableEntryFixture('users', 'default'), 114 | { 115 | hasuraEndpoint: 'http://localhost:8080/' 116 | } 117 | ) 118 | ).toStrictEqual({ 119 | added: [ 120 | tableEntryChange({ 121 | database: 'default', 122 | schema: 'public', 123 | name: 'users', 124 | _links: { 125 | console: { 126 | href: 'http://localhost:8080/console/data/default/schema/public/tables/users/modify' 127 | } 128 | } 129 | }) 130 | ], 131 | modified: [], 132 | deleted: [] 133 | }) 134 | }) 135 | }) 136 | }) 137 | 138 | describe('modified', () => { 139 | describe('add user select permissions', () => { 140 | for (const [oldFixture, newFixture] of [ 141 | ['users', 'users_permissions/user_select_full'], 142 | [ 143 | 'users_permissions/manager', 144 | 'users_permissions/manager_and_user_select_full' 145 | ] 146 | ]) { 147 | test(`${oldFixture} -> ${newFixture}`, () => { 148 | expect(diff(oldFixture, newFixture)).toStrictEqual({ 149 | added: [], 150 | modified: [ 151 | tableEntryChange( 152 | { 153 | database: undefined, 154 | schema: 'public', 155 | name: 'users' 156 | }, 157 | { 158 | select_permissions: { 159 | added: [ 160 | { 161 | role: 'user', 162 | columns: [ 163 | {name: 'created_at', isComputed: false, type: 'added'}, 164 | {name: 'id', isComputed: false, type: 'added'}, 165 | { 166 | name: 'last_login_at', 167 | isComputed: false, 168 | type: 'added' 169 | }, 170 | {name: 'name', isComputed: false, type: 'added'} 171 | ] 172 | } 173 | ], 174 | modified: [], 175 | deleted: [] 176 | } 177 | } 178 | ) 179 | ], 180 | deleted: [] 181 | }) 182 | }) 183 | } 184 | }) 185 | 186 | describe('add manager permissions', () => { 187 | for (const [oldFixture, newFixture] of [ 188 | ['users', 'users_permissions/manager'], 189 | [ 190 | 'users_permissions/user_select_full', 191 | 'users_permissions/manager_and_user_select_full' 192 | ], 193 | [ 194 | 'users_permissions/user_select_filtered', 195 | 'users_permissions/manager_and_user_select_filtered' 196 | ] 197 | ]) { 198 | test(`${oldFixture} -> ${newFixture}`, () => { 199 | expect(diff(oldFixture, newFixture)).toStrictEqual({ 200 | added: [], 201 | modified: [ 202 | tableEntryChange( 203 | { 204 | database: undefined, 205 | schema: 'public', 206 | name: 'users' 207 | }, 208 | { 209 | insert_permissions: { 210 | added: [ 211 | { 212 | role: 'manager', 213 | columns: [ 214 | {name: 'name', isComputed: false, type: 'added'} 215 | ] 216 | } 217 | ], 218 | modified: [], 219 | deleted: [] 220 | }, 221 | select_permissions: { 222 | added: [ 223 | { 224 | role: 'manager', 225 | columns: [ 226 | {name: 'created_at', isComputed: false, type: 'added'}, 227 | {name: 'id', isComputed: false, type: 'added'}, 228 | { 229 | name: 'last_login_at', 230 | isComputed: false, 231 | type: 'added' 232 | }, 233 | {name: 'name', isComputed: false, type: 'added'} 234 | ] 235 | } 236 | ], 237 | modified: [], 238 | deleted: [] 239 | }, 240 | update_permissions: { 241 | added: [ 242 | { 243 | role: 'manager', 244 | columns: [ 245 | {name: 'name', isComputed: false, type: 'added'} 246 | ] 247 | } 248 | ], 249 | modified: [], 250 | deleted: [] 251 | }, 252 | delete_permissions: { 253 | added: [ 254 | { 255 | role: 'manager', 256 | columns: [] 257 | } 258 | ], 259 | modified: [], 260 | deleted: [] 261 | } 262 | } 263 | ) 264 | ], 265 | deleted: [] 266 | }) 267 | }) 268 | } 269 | }) 270 | 271 | describe('filter user select permissions', () => { 272 | for (const [oldFixture, newFixture] of [ 273 | [ 274 | 'users_permissions/user_select_full', 275 | 'users_permissions/user_select_filtered' 276 | ], 277 | [ 278 | 'users_permissions/user_select_filtered', 279 | 'users_permissions/user_select_full' 280 | ], 281 | [ 282 | 'users_permissions/manager_and_user_select_full', 283 | 'users_permissions/manager_and_user_select_filtered' 284 | ] 285 | ]) { 286 | test(`${oldFixture} -> ${newFixture}`, () => { 287 | expect(diff(oldFixture, newFixture)).toStrictEqual({ 288 | added: [], 289 | modified: [ 290 | tableEntryChange( 291 | { 292 | database: undefined, 293 | schema: 'public', 294 | name: 'users' 295 | }, 296 | { 297 | select_permissions: { 298 | added: [], 299 | modified: [ 300 | { 301 | role: 'user', 302 | columns: [] 303 | } 304 | ], 305 | deleted: [] 306 | } 307 | } 308 | ) 309 | ], 310 | deleted: [] 311 | }) 312 | }) 313 | } 314 | }) 315 | 316 | describe('update user column select permissions', () => { 317 | const tests: [string, string, TablePermissionColumnChanges][] = [ 318 | [ 319 | 'users_permissions/user_select_full_id_column', 320 | 'users_permissions/user_select_full', 321 | [ 322 | {name: 'created_at', isComputed: false, type: 'added'}, 323 | {name: 'last_login_at', isComputed: false, type: 'added'}, 324 | {name: 'name', isComputed: false, type: 'added'} 325 | ] 326 | ], 327 | [ 328 | 'users_permissions/user_select_full', 329 | 'users_permissions/user_select_full_id_column', 330 | [ 331 | {name: 'created_at', isComputed: false, type: 'deleted'}, 332 | {name: 'last_login_at', isComputed: false, type: 'deleted'}, 333 | {name: 'name', isComputed: false, type: 'deleted'} 334 | ] 335 | ], 336 | [ 337 | 'users_permissions/user_select_full_all_columns', 338 | 'users_permissions/user_select_full_id_column', 339 | true 340 | ], 341 | [ 342 | 'users_permissions/user_select_full_id_column', 343 | 'users_permissions/user_select_full_computed_fields', 344 | [ 345 | { 346 | name: 'full_name', 347 | isComputed: true, 348 | type: 'added' 349 | } 350 | ] 351 | ], 352 | [ 353 | 'users_permissions/user_select_full_computed_fields', 354 | 'users_permissions/user_select_full_id_column', 355 | [ 356 | { 357 | name: 'full_name', 358 | isComputed: true, 359 | type: 'deleted' 360 | } 361 | ] 362 | ] 363 | ] 364 | 365 | for (const [oldFixture, newFixture, columns] of tests) { 366 | test(`${oldFixture} -> ${newFixture}`, () => { 367 | expect(diff(oldFixture, newFixture)).toStrictEqual({ 368 | added: [], 369 | modified: [ 370 | tableEntryChange( 371 | { 372 | database: undefined, 373 | schema: 'public', 374 | name: 'users' 375 | }, 376 | { 377 | select_permissions: { 378 | added: [], 379 | modified: [ 380 | { 381 | role: 'user', 382 | columns 383 | } 384 | ], 385 | deleted: [] 386 | } 387 | } 388 | ) 389 | ], 390 | deleted: [] 391 | }) 392 | }) 393 | } 394 | }) 395 | 396 | describe('remove user select permissions', () => { 397 | for (const [oldFixture, newFixture] of [ 398 | ['users_permissions/user_select_full', 'users'], 399 | [ 400 | 'users_permissions/manager_and_user_select_filtered', 401 | 'users_permissions/manager' 402 | ] 403 | ]) { 404 | test(`${oldFixture} -> ${newFixture}`, () => { 405 | expect(diff(oldFixture, newFixture)).toStrictEqual({ 406 | added: [], 407 | modified: [ 408 | tableEntryChange( 409 | { 410 | database: undefined, 411 | schema: 'public', 412 | name: 'users' 413 | }, 414 | { 415 | select_permissions: { 416 | added: [], 417 | modified: [], 418 | deleted: [ 419 | { 420 | role: 'user', 421 | columns: [ 422 | { 423 | name: 'created_at', 424 | isComputed: false, 425 | type: 'deleted' 426 | }, 427 | {name: 'id', isComputed: false, type: 'deleted'}, 428 | { 429 | name: 'last_login_at', 430 | isComputed: false, 431 | type: 'deleted' 432 | }, 433 | {name: 'name', isComputed: false, type: 'deleted'} 434 | ] 435 | } 436 | ] 437 | } 438 | } 439 | ) 440 | ], 441 | deleted: [] 442 | }) 443 | }) 444 | } 445 | }) 446 | }) 447 | 448 | test('modified and deleted with table index changes', () => { 449 | expect( 450 | diff('todos_and_users', 'users_permissions/user_select_filtered') 451 | ).toStrictEqual({ 452 | added: [], 453 | modified: [ 454 | tableEntryChange( 455 | { 456 | database: undefined, 457 | schema: 'public', 458 | name: 'users' 459 | }, 460 | { 461 | select_permissions: { 462 | added: [], 463 | modified: [ 464 | { 465 | role: 'user', 466 | columns: [] 467 | } 468 | ], 469 | deleted: [] 470 | } 471 | } 472 | ) 473 | ], 474 | deleted: [ 475 | tableEntryChange({ 476 | database: undefined, 477 | schema: 'public', 478 | name: 'todos' 479 | }) 480 | ] 481 | }) 482 | }) 483 | 484 | describe('deleted', () => { 485 | test('no endpoint', () => { 486 | expect(diff('users', 'empty')).toStrictEqual({ 487 | added: [], 488 | modified: [], 489 | deleted: [ 490 | tableEntryChange({ 491 | database: undefined, 492 | schema: 'public', 493 | name: 'users' 494 | }) 495 | ] 496 | }) 497 | }) 498 | 499 | test('with configured endpoint', () => { 500 | expect( 501 | diff('users', 'empty', { 502 | hasuraEndpoint: 'http://localhost:8080/' 503 | }) 504 | ).toStrictEqual({ 505 | added: [], 506 | modified: [], 507 | deleted: [ 508 | tableEntryChange({ 509 | database: undefined, 510 | schema: 'public', 511 | name: 'users' 512 | }) 513 | ] 514 | }) 515 | }) 516 | }) 517 | 518 | function diff( 519 | oldFixture: string | TableEntry[], 520 | newFixture: string | TableEntry[], 521 | options?: DiffOptions 522 | ): TableEntryChanges { 523 | const oldTableEntries = isString(oldFixture) 524 | ? loadTableEntryFixture(oldFixture) 525 | : oldFixture 526 | const newTableEntries = isString(newFixture) 527 | ? loadTableEntryFixture(newFixture) 528 | : newFixture 529 | 530 | return diffTableEntries(oldTableEntries, newTableEntries, options) 531 | } 532 | 533 | function loadTableEntryFixture( 534 | fixture: string, 535 | database?: string 536 | ): TableEntry[] { 537 | const tables = loadFixture(`/${fixture}.yaml`) 538 | 539 | if (database) { 540 | return tables.map(table => qualifyTableEntry(table, database)) 541 | } 542 | 543 | return tables 544 | } 545 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/empty.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/todos_and_users.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: todos 4 | - table: 5 | schema: public 6 | name: users 7 | select_permissions: 8 | - role: user 9 | permission: 10 | columns: 11 | - id 12 | - name 13 | - created_at 14 | - last_login_at 15 | filter: {} 16 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/users.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/users_permissions/manager.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | insert_permissions: 5 | - role: manager 6 | permission: 7 | check: {} 8 | columns: 9 | - name 10 | backend_only: false 11 | select_permissions: 12 | - role: manager 13 | permission: 14 | columns: 15 | - name 16 | - created_at 17 | - last_login_at 18 | - id 19 | filter: {} 20 | update_permissions: 21 | - role: manager 22 | permission: 23 | columns: 24 | - name 25 | filter: {} 26 | check: null 27 | delete_permissions: 28 | - role: manager 29 | permission: 30 | filter: {} 31 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/users_permissions/manager_and_user_select_filtered.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | insert_permissions: 5 | - role: manager 6 | permission: 7 | check: {} 8 | columns: 9 | - name 10 | backend_only: false 11 | select_permissions: 12 | - role: manager 13 | permission: 14 | columns: 15 | - name 16 | - created_at 17 | - last_login_at 18 | - id 19 | filter: {} 20 | - role: user 21 | permission: 22 | columns: 23 | - id 24 | - name 25 | - created_at 26 | - last_login_at 27 | filter: 28 | id: 29 | _eq: X-Hasura-User-Id 30 | update_permissions: 31 | - role: manager 32 | permission: 33 | columns: 34 | - name 35 | filter: {} 36 | check: null 37 | delete_permissions: 38 | - role: manager 39 | permission: 40 | filter: {} 41 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/users_permissions/manager_and_user_select_full.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | insert_permissions: 5 | - role: manager 6 | permission: 7 | check: {} 8 | columns: 9 | - name 10 | backend_only: false 11 | select_permissions: 12 | - role: manager 13 | permission: 14 | columns: 15 | - name 16 | - created_at 17 | - last_login_at 18 | - id 19 | filter: {} 20 | - role: user 21 | permission: 22 | columns: 23 | - name 24 | - created_at 25 | - last_login_at 26 | - id 27 | filter: {} 28 | update_permissions: 29 | - role: manager 30 | permission: 31 | columns: 32 | - name 33 | filter: {} 34 | check: null 35 | delete_permissions: 36 | - role: manager 37 | permission: 38 | filter: {} 39 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/users_permissions/user_select_filtered.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | select_permissions: 5 | - role: user 6 | permission: 7 | columns: 8 | - id 9 | - name 10 | - created_at 11 | - last_login_at 12 | filter: 13 | id: 14 | _eq: X-Hasura-User-Id 15 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/users_permissions/user_select_full.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | select_permissions: 5 | - role: user 6 | permission: 7 | columns: 8 | - id 9 | - name 10 | - created_at 11 | - last_login_at 12 | filter: {} 13 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/users_permissions/user_select_full_all_columns.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | select_permissions: 5 | - role: user 6 | permission: 7 | columns: "*" 8 | filter: {} 9 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/users_permissions/user_select_full_computed_fields.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | computed_fields: 5 | - name: full_name 6 | definition: 7 | function: 8 | schema: public 9 | name: user_full_name 10 | comment: "" 11 | select_permissions: 12 | - role: user 13 | permission: 14 | columns: 15 | - id 16 | computed_fields: 17 | - full_name 18 | filter: {} 19 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/fixtures/users_permissions/user_select_full_id_column.yaml: -------------------------------------------------------------------------------- 1 | - table: 2 | schema: public 3 | name: users 4 | select_permissions: 5 | - role: user 6 | permission: 7 | columns: 8 | - id 9 | filter: {} 10 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/formatTableEntries.test.ts: -------------------------------------------------------------------------------- 1 | import {formatTableEntries} from '..' 2 | import {tableEntryChange} from './utils' 3 | 4 | test('added', () => { 5 | expect( 6 | formatTableEntries({ 7 | added: [ 8 | tableEntryChange({ 9 | schema: 'public', 10 | name: 'users' 11 | }), 12 | tableEntryChange({ 13 | schema: 'public', 14 | name: 'todos', 15 | _links: { 16 | console: {href: 'URL'} 17 | } 18 | }) 19 | ], 20 | modified: [], 21 | deleted: [] 22 | }) 23 | ).toStrictEqual( 24 | `

Tracked Tables (2)

25 |
    26 |
  • 27 |

    28 | public.users 29 |

    30 |
  • 31 |
  • 32 |

    33 | public.todos 34 |

    35 |
  • 36 |
` 37 | ) 38 | }) 39 | 40 | test('added and modified', () => { 41 | expect( 42 | formatTableEntries({ 43 | added: [ 44 | tableEntryChange({ 45 | schema: 'public', 46 | name: 'users', 47 | _links: { 48 | console: {href: 'URL'} 49 | } 50 | }) 51 | ], 52 | modified: [ 53 | tableEntryChange( 54 | { 55 | schema: 'public', 56 | name: 'todos' 57 | }, 58 | { 59 | select_permissions: { 60 | added: [ 61 | { 62 | role: 'user', 63 | columns: [ 64 | {name: 'created_at', isComputed: false, type: 'added'}, 65 | {name: 'name', isComputed: false, type: 'added'} 66 | ] 67 | }, 68 | { 69 | role: 'manager', 70 | columns: [ 71 | {name: 'created_at', isComputed: false, type: 'added'}, 72 | {name: 'name', isComputed: false, type: 'added'} 73 | ] 74 | } 75 | ], 76 | modified: [], 77 | deleted: [] 78 | }, 79 | update_permissions: { 80 | added: [], 81 | modified: [ 82 | { 83 | role: 'user', 84 | columns: [] 85 | } 86 | ], 87 | deleted: [] 88 | } 89 | } 90 | ) 91 | ], 92 | deleted: [] 93 | }) 94 | ).toStrictEqual( 95 | `

Tracked Tables (1)

96 | 103 |

Updated Tables (1)

104 |
    105 |
  • 106 |

    107 | public.todos 108 | permissions: 109 |

    110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 |
    insertselectupdatedelete
    manager
    user/
    137 |
    138 | 4 added column permissions 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 |
    insertselectupdate
    manager➕ created_at
    ➕ name
    user
    162 |
    163 |
  • 164 |
` 165 | ) 166 | }) 167 | 168 | test('deleted', () => { 169 | expect( 170 | formatTableEntries({ 171 | added: [], 172 | modified: [], 173 | deleted: [ 174 | tableEntryChange({ 175 | database: 'default', 176 | schema: 'public', 177 | name: 'users' 178 | }) 179 | ] 180 | }) 181 | ).toStrictEqual(`

Untracked Tables (1)

182 |
    183 |
  • 184 |

    185 | default.public.users 186 |

    187 |
  • 188 |
`) 189 | }) 190 | -------------------------------------------------------------------------------- /src/diff/tables/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TableChange, 3 | TableEntryChange, 4 | TablePermissionsChanges 5 | } from '../../types' 6 | 7 | import {emptyTablePermissionsChanges} from '../permissions' 8 | import {join} from 'path' 9 | import {load} from 'js-yaml' 10 | import {readFileSync} from 'fs' 11 | 12 | export function loadFixture(path: string): T { 13 | const fixture = load(readFileSync(join(__dirname, 'fixtures', path), 'utf8')) 14 | 15 | return fixture as T 16 | } 17 | 18 | export function tableEntryChange( 19 | table: TableChange, 20 | permissions?: Partial 21 | ): TableEntryChange { 22 | return { 23 | ...emptyTablePermissionsChanges(), 24 | ...permissions, 25 | table 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/diff/tables/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as jsondiffpatch from 'jsondiffpatch' 3 | 4 | import { 5 | DiffOptions, 6 | TableEntry, 7 | TableEntryChange, 8 | TableEntryChanges 9 | } from '../types' 10 | import { 11 | changeFromQualifiedTable, 12 | formatTableEntryChange, 13 | hashFromTable, 14 | tableEntryPredicate 15 | } from './table' 16 | import {diffTablePermissions, emptyTablePermissionsChanges} from './permissions' 17 | import {emptyChanges, isAddition, isDeletion, isTableEntry} from '../functions' 18 | 19 | import {forEach} from 'lodash' 20 | 21 | const tableEntryDiffPatcher = jsondiffpatch.create({ 22 | objectHash(object: unknown, index: number) { 23 | if (isTableEntry(object)) { 24 | return hashFromTable(object.table) 25 | } 26 | 27 | return `index:${index}` 28 | } 29 | }) 30 | 31 | /** 32 | * Compute changes between table entries. 33 | */ 34 | export function diffTableEntries( 35 | oldTables: TableEntry[], 36 | newTables: TableEntry[], 37 | options: DiffOptions = {} 38 | ): TableEntryChanges { 39 | const tablesDelta = tableEntryDiffPatcher.diff(oldTables, newTables) 40 | const changes = emptyChanges() 41 | 42 | if (undefined === tablesDelta) { 43 | return changes 44 | } 45 | 46 | forEach(tablesDelta, (delta: unknown, index: string) => { 47 | const tableIndex = Number(index) 48 | 49 | if (isAddition(delta)) { 50 | const tableEntry = delta[0] 51 | const {table} = tableEntry 52 | 53 | core.info(`+ ${table.schema}.${table.name}`) 54 | changes.added.push({ 55 | ...diffTablePermissions({table}, tableEntry), 56 | table: changeFromQualifiedTable(table, options) 57 | }) 58 | } else if (isDeletion(delta)) { 59 | const table = delta[0].table 60 | 61 | core.info(`- ${table.schema}.${table.name}`) 62 | changes.deleted.push({ 63 | ...emptyTablePermissionsChanges(), 64 | table: changeFromQualifiedTable(table, {}) 65 | }) 66 | } else if (isFinite(tableIndex)) { 67 | const newTableEntry = newTables[tableIndex] 68 | const {table} = newTableEntry 69 | const oldTableEntry = oldTables.find(tableEntryPredicate(table)) 70 | 71 | if (!oldTableEntry) { 72 | throw new Error( 73 | `Error finding old table entry ${table.schema}.${table.name} at new index ${tableIndex}` 74 | ) 75 | } 76 | 77 | core.info(`+/- ${table.schema}.${table.name}`) 78 | changes.modified.push({ 79 | ...diffTablePermissions(oldTableEntry, newTableEntry), 80 | table: changeFromQualifiedTable(table, options) 81 | }) 82 | } 83 | }) 84 | 85 | return changes 86 | } 87 | 88 | export function formatTableEntries(changes: TableEntryChanges): string { 89 | core.info('Formatting table changes') 90 | 91 | return ( 92 | formatTableEntryChange('Tracked Tables', changes.added) + 93 | formatTableEntryChange('Updated Tables', changes.modified) + 94 | formatTableEntryChange('Untracked Tables', changes.deleted) 95 | ).trim() 96 | } 97 | -------------------------------------------------------------------------------- /src/diff/tables/permissions/columns/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import {columnPermissionsViewFromTableChanges} from '..' 2 | import {emptyTablePermissionsChanges} from '../..' 3 | 4 | describe('columnPermissionsViewFromTableChanges', () => { 5 | test('no permission changes', () => { 6 | expect( 7 | columnPermissionsViewFromTableChanges(emptyTablePermissionsChanges()) 8 | ).toStrictEqual(null) 9 | }) 10 | 11 | test('no column changes', () => { 12 | expect( 13 | columnPermissionsViewFromTableChanges({ 14 | ...emptyTablePermissionsChanges(), 15 | select_permissions: { 16 | added: [], 17 | modified: [ 18 | { 19 | role: 'user', 20 | columns: [] 21 | } 22 | ], 23 | deleted: [] 24 | } 25 | }) 26 | ).toStrictEqual(null) 27 | }) 28 | 29 | test('add computed column', () => { 30 | expect( 31 | columnPermissionsViewFromTableChanges({ 32 | ...emptyTablePermissionsChanges(), 33 | select_permissions: { 34 | added: [], 35 | modified: [ 36 | { 37 | role: 'user', 38 | columns: [ 39 | { 40 | name: 'full_name', 41 | isComputed: true, 42 | type: 'added' 43 | } 44 | ] 45 | } 46 | ], 47 | deleted: [] 48 | } 49 | }) 50 | ).toStrictEqual({ 51 | summary: '1 added column permissions', 52 | table: { 53 | headRow: ['', 'insert', 'select', 'update'], 54 | body: [ 55 | { 56 | role: 'user', 57 | cells: [ 58 | { 59 | content: '', 60 | rowspan: false 61 | }, 62 | { 63 | content: '➕ full_name', 64 | rowspan: true 65 | }, 66 | { 67 | content: '', 68 | rowspan: false 69 | } 70 | ] 71 | } 72 | ] 73 | } 74 | }) 75 | }) 76 | 77 | test('remove computed column', () => { 78 | expect( 79 | columnPermissionsViewFromTableChanges({ 80 | ...emptyTablePermissionsChanges(), 81 | select_permissions: { 82 | added: [], 83 | modified: [ 84 | { 85 | role: 'user', 86 | columns: [ 87 | { 88 | name: 'full_name', 89 | isComputed: true, 90 | type: 'deleted' 91 | } 92 | ] 93 | } 94 | ], 95 | deleted: [] 96 | } 97 | }) 98 | ).toStrictEqual({ 99 | summary: '1 removed column permissions', 100 | table: { 101 | headRow: ['', 'insert', 'select', 'update'], 102 | body: [ 103 | { 104 | role: 'user', 105 | cells: [ 106 | { 107 | content: '', 108 | rowspan: false 109 | }, 110 | { 111 | content: '➖ full_name', 112 | rowspan: true 113 | }, 114 | { 115 | content: '', 116 | rowspan: false 117 | } 118 | ] 119 | } 120 | ] 121 | } 122 | }) 123 | }) 124 | 125 | test('mixed change types', () => { 126 | expect( 127 | columnPermissionsViewFromTableChanges({ 128 | ...emptyTablePermissionsChanges(), 129 | select_permissions: { 130 | added: [], 131 | modified: [ 132 | { 133 | role: 'user', 134 | columns: [ 135 | {name: 'id', isComputed: false, type: 'added'}, 136 | {name: 'name', isComputed: false, type: 'deleted'} 137 | ] 138 | } 139 | ], 140 | deleted: [] 141 | } 142 | }) 143 | ).toStrictEqual({ 144 | summary: '2 updated column permissions', 145 | table: { 146 | headRow: ['', 'insert', 'select', 'update'], 147 | body: [ 148 | { 149 | role: 'user', 150 | cells: [ 151 | { 152 | content: '', 153 | rowspan: false 154 | }, 155 | { 156 | content: '➕ id
➖ name', 157 | rowspan: true 158 | }, 159 | { 160 | content: '', 161 | rowspan: false 162 | } 163 | ] 164 | } 165 | ] 166 | } 167 | }) 168 | }) 169 | 170 | test('non-explicit column changes', () => { 171 | expect( 172 | columnPermissionsViewFromTableChanges({ 173 | ...emptyTablePermissionsChanges(), 174 | select_permissions: { 175 | added: [], 176 | modified: [ 177 | { 178 | role: 'user', 179 | columns: true 180 | } 181 | ], 182 | deleted: [] 183 | } 184 | }) 185 | ).toStrictEqual({ 186 | summary: '1+ updated column permissions', 187 | table: { 188 | headRow: ['', 'insert', 'select', 'update'], 189 | body: [ 190 | { 191 | role: 'user', 192 | cells: [ 193 | { 194 | content: '', 195 | rowspan: false 196 | }, 197 | { 198 | content: '/', 199 | rowspan: true 200 | }, 201 | { 202 | content: '', 203 | rowspan: false 204 | } 205 | ] 206 | } 207 | ] 208 | } 209 | }) 210 | }) 211 | 212 | test('consistent deletes across roles', () => { 213 | expect( 214 | columnPermissionsViewFromTableChanges({ 215 | ...emptyTablePermissionsChanges(), 216 | update_permissions: { 217 | added: [], 218 | modified: [], 219 | deleted: [ 220 | { 221 | role: 'manager', 222 | columns: [ 223 | {name: 'id', isComputed: false, type: 'deleted'}, 224 | {name: 'name', isComputed: false, type: 'deleted'} 225 | ] 226 | }, 227 | { 228 | role: 'user', 229 | columns: [ 230 | {name: 'id', isComputed: false, type: 'deleted'}, 231 | {name: 'name', isComputed: false, type: 'deleted'} 232 | ] 233 | } 234 | ] 235 | } 236 | }) 237 | ).toStrictEqual({ 238 | summary: '4 removed column permissions', 239 | table: { 240 | headRow: ['', 'insert', 'select', 'update'], 241 | body: [ 242 | { 243 | role: 'manager', 244 | cells: [ 245 | { 246 | content: '', 247 | rowspan: false 248 | }, 249 | { 250 | content: '', 251 | rowspan: false 252 | }, 253 | { 254 | content: '➖ id
➖ name', 255 | rowspan: true 256 | } 257 | ] 258 | }, 259 | { 260 | role: 'user', 261 | cells: [ 262 | { 263 | content: '', 264 | rowspan: false 265 | }, 266 | { 267 | content: '', 268 | rowspan: false 269 | } 270 | ] 271 | } 272 | ] 273 | } 274 | }) 275 | }) 276 | 277 | test('inconsistent deletes across roles', () => { 278 | expect( 279 | columnPermissionsViewFromTableChanges({ 280 | ...emptyTablePermissionsChanges(), 281 | update_permissions: { 282 | added: [], 283 | modified: [], 284 | deleted: [ 285 | { 286 | role: 'manager', 287 | columns: [ 288 | {name: 'id', isComputed: false, type: 'deleted'}, 289 | {name: 'name', isComputed: false, type: 'deleted'} 290 | ] 291 | }, 292 | { 293 | role: 'user', 294 | columns: [ 295 | {name: 'id', isComputed: false, type: 'deleted'}, 296 | {name: 'updated_at', isComputed: false, type: 'deleted'} 297 | ] 298 | } 299 | ] 300 | } 301 | }) 302 | ).toStrictEqual({ 303 | summary: '4 removed column permissions', 304 | table: { 305 | headRow: ['', 'insert', 'select', 'update'], 306 | body: [ 307 | { 308 | role: 'manager', 309 | cells: [ 310 | { 311 | content: '', 312 | rowspan: false 313 | }, 314 | { 315 | content: '', 316 | rowspan: false 317 | }, 318 | { 319 | content: '➖ id
➖ name', 320 | rowspan: false 321 | } 322 | ] 323 | }, 324 | { 325 | role: 'user', 326 | cells: [ 327 | { 328 | content: '', 329 | rowspan: false 330 | }, 331 | { 332 | content: '', 333 | rowspan: false 334 | }, 335 | { 336 | content: 337 | '➖ id
➖ updated_at', 338 | rowspan: false 339 | } 340 | ] 341 | } 342 | ] 343 | } 344 | }) 345 | }) 346 | 347 | test('mix of consistent and inconsistent changes across operation', () => { 348 | expect( 349 | columnPermissionsViewFromTableChanges({ 350 | ...emptyTablePermissionsChanges(), 351 | select_permissions: { 352 | added: [ 353 | { 354 | role: 'manager', 355 | columns: [ 356 | { 357 | name: 'name', 358 | isComputed: false, 359 | type: 'added' 360 | } 361 | ] 362 | }, 363 | { 364 | role: 'user', 365 | columns: [ 366 | { 367 | name: 'name', 368 | isComputed: false, 369 | type: 'added' 370 | } 371 | ] 372 | } 373 | ], 374 | modified: [], 375 | deleted: [] 376 | }, 377 | update_permissions: { 378 | added: [ 379 | { 380 | role: 'user', // intentionally second role 381 | columns: [ 382 | { 383 | name: 'name', 384 | isComputed: false, 385 | type: 'added' 386 | } 387 | ] 388 | } 389 | ], 390 | modified: [], 391 | deleted: [] 392 | } 393 | }) 394 | ).toStrictEqual({ 395 | summary: '3 added column permissions', 396 | table: { 397 | headRow: ['', 'insert', 'select', 'update'], 398 | body: [ 399 | { 400 | role: 'manager', 401 | cells: [ 402 | { 403 | content: '', 404 | rowspan: false 405 | }, 406 | { 407 | content: '➕ name', 408 | rowspan: true 409 | }, 410 | { 411 | content: '', 412 | rowspan: false 413 | } 414 | ] 415 | }, 416 | { 417 | role: 'user', 418 | cells: [ 419 | { 420 | content: '', 421 | rowspan: false 422 | }, 423 | { 424 | content: '➕ name', 425 | rowspan: false 426 | } 427 | ] 428 | } 429 | ] 430 | } 431 | }) 432 | }) 433 | }) 434 | -------------------------------------------------------------------------------- /src/diff/tables/permissions/columns/functions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeType, 3 | ChangeTypes, 4 | TablePermission, 5 | TablePermissionColumnChange, 6 | TablePermissionColumnChanges, 7 | TablePermissions, 8 | TablePermissionsChanges 9 | } from '../../../types' 10 | import {assertNeverChangeType, iconFromChangeType} from '../../utils' 11 | import { 12 | ColumnChangeCount, 13 | ColumnPermissionChange, 14 | ColumnPermissionChangeCell, 15 | PermissionColumnChanges 16 | } from './types' 17 | 18 | import {isEqual} from 'lodash' 19 | 20 | /** 21 | * Iterate through `tablePermissions` per role and operation that include column changes. 22 | */ 23 | export function* columnChangeIterator( 24 | tablePermissions: TablePermissionsChanges 25 | ): Generator { 26 | for (const permission of TablePermissions) { 27 | for (const changeType of ChangeTypes) { 28 | for (const {role, columns} of tablePermissions[permission][changeType]) { 29 | if (true === columns || columns.length) { 30 | yield { 31 | permission, 32 | role, 33 | columns 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | export function viewSummaryFromChanges( 42 | changeCount: ColumnChangeCount, 43 | changeTypes: Set 44 | ): string { 45 | const count = 46 | String(changeCount.value || 1) + (changeCount.isLowerBound ? '+' : '') 47 | 48 | const verb = viewSummaryVerbFromChangeTypes(changeTypes) 49 | 50 | return `${count} ${verb} column permissions` 51 | } 52 | 53 | export function viewSummaryVerbFromChangeTypes( 54 | changeTypes: Set 55 | ): string { 56 | if (1 !== changeTypes.size) { 57 | return 'updated' 58 | } 59 | 60 | const changeType = changeTypes.values().next().value as ChangeType 61 | 62 | switch (changeType) { 63 | case 'added': 64 | return 'added' 65 | case 'modified': 66 | return 'updated' 67 | case 'deleted': 68 | return 'removed' 69 | default: 70 | return assertNeverChangeType(changeType) 71 | } 72 | } 73 | 74 | /** 75 | * Return HTML table cell content illustrating permission `columnChanges`. 76 | */ 77 | export function tableCellFromColumnChanges( 78 | columnChanges: TablePermissionColumnChanges, 79 | rowspan: boolean 80 | ): ColumnPermissionChangeCell { 81 | if (true === columnChanges) { 82 | return {content: iconFromChangeType('modified'), rowspan} 83 | } 84 | 85 | const content = columnChanges.map(contentFromColumnChange).join('
') 86 | 87 | return {content, rowspan} 88 | } 89 | 90 | /** 91 | * Return HTML content describing the specified column change. 92 | */ 93 | export function contentFromColumnChange({ 94 | name, 95 | isComputed, 96 | type 97 | }: TablePermissionColumnChange): string { 98 | const icon = iconFromChangeType(type) 99 | 100 | if (isComputed) { 101 | name = `${name}` 102 | } 103 | 104 | switch (type) { 105 | case 'added': 106 | return `${icon} ${name}` 107 | case 'deleted': 108 | return `${icon} ${name}` 109 | default: 110 | assertNeverChangeType(type) 111 | } 112 | } 113 | 114 | /** 115 | * Determine if the `permission` column changes are equivalent across all roles. 116 | */ 117 | export function isPermissionConsistentAcrossRoles( 118 | roleColumnChangesMap: Map>, 119 | permission: TablePermission 120 | ): boolean { 121 | let firstColumnChanges: TablePermissionColumnChanges | undefined 122 | /** number of roles with consistent column changes */ 123 | let consistentRoles = 1 124 | 125 | for (const columnChanges of roleColumnChangesMap.values()) { 126 | if (!firstColumnChanges) { 127 | firstColumnChanges = columnChanges[permission] 128 | } else if (isEqual(firstColumnChanges, columnChanges[permission])) { 129 | consistentRoles++ 130 | } else { 131 | return false 132 | } 133 | } 134 | 135 | return ( 136 | undefined !== firstColumnChanges && 137 | roleColumnChangesMap.size === consistentRoles 138 | ) 139 | } 140 | -------------------------------------------------------------------------------- /src/diff/tables/permissions/columns/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | import { 4 | ChangeType, 5 | PermissionEntry, 6 | TablePermission, 7 | TablePermissionColumn, 8 | TablePermissionColumnChange, 9 | TablePermissionColumnChanges, 10 | TablePermissionColumns, 11 | TablePermissions, 12 | TablePermissionsChanges 13 | } from '../../../types' 14 | import {ColumnChangeCount, PermissionColumnChanges} from './types' 15 | import { 16 | columnChangeIterator, 17 | isPermissionConsistentAcrossRoles, 18 | tableCellFromColumnChanges, 19 | viewSummaryFromChanges 20 | } from './functions' 21 | import {compact, isArray} from 'lodash' 22 | import {compareStrings, tableHeadingFromPermission} from '../functions' 23 | import { 24 | isDeletePermissionEntry, 25 | isSelectPermissionEntry, 26 | tab 27 | } from '../../../functions' 28 | 29 | /** 30 | * Return normalized columns and computed fields from the specified permission `entry`. 31 | */ 32 | export function columnsFromPermissionEntry( 33 | entry: PermissionEntry 34 | ): TablePermissionColumns { 35 | if ( 36 | isSelectPermissionEntry(entry) && 37 | isArray(entry.permission.columns) && 38 | entry.permission.computed_fields 39 | ) { 40 | return [ 41 | ...entry.permission.columns.map(name => ({ 42 | name, 43 | isComputed: false 44 | })), 45 | ...entry.permission.computed_fields.map(name => ({ 46 | name, 47 | isComputed: true 48 | })) 49 | ] 50 | } 51 | 52 | if (isDeletePermissionEntry(entry)) { 53 | // this type predicate narrowing must exist _after_ isSelectPermissionEntry given that 54 | // SelectPermissionEntry and UpdatePermissionEntry effectively extend DeletePermissionEntry, 55 | // and TypeScript does not yet have the concept of "exact" types 56 | return [] 57 | } 58 | 59 | if (!isArray(entry.permission.columns)) { 60 | return entry.permission.columns 61 | } 62 | 63 | return entry.permission.columns.map(name => ({ 64 | name, 65 | isComputed: false 66 | })) 67 | } 68 | 69 | /** 70 | * Compute changes between insert, select, or update table column permissions. 71 | */ 72 | export function diffColumnPermissions( 73 | oldColumns: TablePermissionColumns, 74 | newColumns: TablePermissionColumns, 75 | tabLevel: number 76 | ): TablePermissionColumnChanges { 77 | if (!isArray(oldColumns) || !isArray(newColumns)) { 78 | if (oldColumns === newColumns) { 79 | return [] 80 | } 81 | 82 | core.info( 83 | tab( 84 | `+/- columns: ${JSON.stringify(oldColumns)} -> ${JSON.stringify( 85 | newColumns 86 | )}`, 87 | tabLevel 88 | ) 89 | ) 90 | 91 | return true 92 | } 93 | 94 | /** column changes, mapped by name */ 95 | const columnChangeHash = oldColumns.reduce< 96 | Map 97 | >((map, oldColumn) => { 98 | return map.set(oldColumn.name, {...oldColumn, type: 'deleted'}) 99 | }, new Map()) 100 | 101 | for (const newColumn of newColumns) { 102 | if (!columnChangeHash.delete(newColumn.name)) { 103 | columnChangeHash.set(newColumn.name, {...newColumn, type: 'added'}) 104 | } 105 | } 106 | 107 | if (columnChangeHash.size) { 108 | core.info(tab(`+/- ${columnChangeHash.size} columns`, tabLevel)) 109 | } 110 | 111 | return Array.from(columnChangeHash.values()).sort((a, b) => { 112 | return compareStrings(a.name, b.name) 113 | }) 114 | } 115 | 116 | /** 117 | * Return the mustache `COLUMN_PERMISSIONS_TEMPLATE` view data. 118 | */ 119 | export function columnPermissionsViewFromTableChanges( 120 | tablePermissions: TablePermissionsChanges 121 | ): null | Record { 122 | /** changed columns, mapped by permission operator and role */ 123 | const roleColumnChangesMap = new Map< 124 | string, 125 | Partial 126 | >() 127 | 128 | const columnChangeCount: ColumnChangeCount = {value: 0, isLowerBound: false} 129 | const columnChangeTypes = new Set() 130 | 131 | for (const {permission, role, columns} of columnChangeIterator( 132 | tablePermissions 133 | )) { 134 | roleColumnChangesMap.set(role, { 135 | ...roleColumnChangesMap.get(role), 136 | [permission]: columns 137 | }) 138 | 139 | if (isArray(columns)) { 140 | columnChangeCount.value += columns.length 141 | 142 | for (const {type} of columns) { 143 | columnChangeTypes.add(type) 144 | } 145 | } else { 146 | columnChangeCount.isLowerBound = true 147 | columnChangeTypes.add('modified') 148 | } 149 | } 150 | 151 | if (!roleColumnChangesMap.size) { 152 | return null 153 | } 154 | 155 | const permissionIsConsistentAcrossRolesMap = TablePermissions.reduce< 156 | Map 157 | >((map, permission) => { 158 | return map.set( 159 | permission, 160 | isPermissionConsistentAcrossRoles(roleColumnChangesMap, permission) 161 | ) 162 | }, new Map()) 163 | 164 | const tablePermissionKeys = TablePermissions.filter( 165 | permission => 'delete_permissions' !== permission 166 | ) 167 | 168 | /** table rows per role */ 169 | const body = Array.from(roleColumnChangesMap.entries()) 170 | .sort(([a], [b]) => a.localeCompare(b)) 171 | .map(([role, permissionChanges], rowIndex) => ({ 172 | role, 173 | cells: compact( 174 | tablePermissionKeys.map(permission => { 175 | const isConsistentAcrossRoles = 176 | permissionIsConsistentAcrossRolesMap.get(permission) ?? false 177 | 178 | if (isConsistentAcrossRoles && rowIndex > 0) { 179 | return null // leverage previous role rowspan 180 | } 181 | 182 | return tableCellFromColumnChanges( 183 | permissionChanges[permission] ?? [], 184 | isConsistentAcrossRoles 185 | ) 186 | }) 187 | ) 188 | })) 189 | 190 | return { 191 | summary: viewSummaryFromChanges(columnChangeCount, columnChangeTypes), 192 | table: { 193 | headRow: ['', ...tablePermissionKeys.map(tableHeadingFromPermission)], 194 | body 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/diff/tables/permissions/columns/types.ts: -------------------------------------------------------------------------------- 1 | import {TablePermission, TablePermissionColumnChanges} from '../../../types' 2 | 3 | export type PermissionColumnChanges = Record< 4 | TablePermission, 5 | TablePermissionColumnChanges 6 | > 7 | 8 | export interface ColumnPermissionChange { 9 | permission: TablePermission 10 | role: string 11 | columns: TablePermissionColumnChanges 12 | } 13 | 14 | export interface ColumnPermissionChangeCell { 15 | rowspan: boolean 16 | content: string 17 | } 18 | 19 | export interface ColumnChangeCount { 20 | value: number 21 | isLowerBound: boolean 22 | } 23 | -------------------------------------------------------------------------------- /src/diff/tables/permissions/functions.ts: -------------------------------------------------------------------------------- 1 | import {PermissionEntry, TablePermission} from '../../types' 2 | 3 | export function sortStrings(strings: string[]): string[] { 4 | return [...strings].sort(compareStrings) 5 | } 6 | 7 | export function compareStrings(a: string, b: string): number { 8 | return a.localeCompare(b) 9 | } 10 | 11 | export function hashFromPermission(permission: PermissionEntry): string { 12 | return `permission:${permission.role}` 13 | } 14 | 15 | export function permissionEntryPredicate( 16 | permissionEntry: PermissionEntry 17 | ): (permission: PermissionEntry) => boolean { 18 | const permissionEntryHash = hashFromPermission(permissionEntry) 19 | 20 | return (permission: PermissionEntry): boolean => { 21 | return hashFromPermission(permission) === permissionEntryHash 22 | } 23 | } 24 | 25 | /** 26 | * Return `` content identifying a `permission` operation. 27 | */ 28 | export function tableHeadingFromPermission( 29 | permission: TablePermission 30 | ): string { 31 | return permission.split('_')[0] 32 | } 33 | -------------------------------------------------------------------------------- /src/diff/tables/permissions/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as jsondiffpatch from 'jsondiffpatch' 3 | 4 | import { 5 | ChangeType, 6 | ChangeTypes, 7 | PermissionEntry, 8 | TableEntry, 9 | TablePermissionChange, 10 | TablePermissionChanges, 11 | TablePermissions, 12 | TablePermissionsChanges 13 | } from '../../types' 14 | import { 15 | columnPermissionsViewFromTableChanges, 16 | columnsFromPermissionEntry, 17 | diffColumnPermissions 18 | } from './columns' 19 | import { 20 | emptyChanges, 21 | isAddition, 22 | isDeletePermissionEntry, 23 | isDeletion, 24 | isPermissionEntry, 25 | tab 26 | } from '../../functions' 27 | import {forEach, isArray} from 'lodash' 28 | import { 29 | hashFromPermission, 30 | permissionEntryPredicate, 31 | sortStrings, 32 | tableHeadingFromPermission 33 | } from './functions' 34 | 35 | import {PermissionsChangeType} from '../types' 36 | import {iconFromChangeType} from '../utils' 37 | 38 | const permissionEntryDiffPatcher = jsondiffpatch.create({ 39 | objectHash(object: unknown, index: number) { 40 | if (isPermissionEntry(object)) { 41 | return hashFromPermission(object) 42 | } 43 | 44 | return `index:${index}` 45 | } 46 | }) 47 | 48 | /** 49 | * Compute changes between insert, select, update, and delete table permissions. 50 | */ 51 | export function diffTablePermissions( 52 | oldTable: TableEntry, 53 | newTable: TableEntry 54 | ): TablePermissionsChanges { 55 | return TablePermissions.reduce( 56 | (changes, permission) => { 57 | core.info(tab(permission)) 58 | changes[permission] = diffPermissions( 59 | oldTable[permission] ?? [], 60 | newTable[permission] ?? [], 61 | 1 62 | ) 63 | 64 | return changes 65 | }, 66 | emptyTablePermissionsChanges() 67 | ) 68 | } 69 | 70 | /** 71 | * Compute changes between a table operation's permissions. 72 | * 73 | * @param oldPermissions set of old permissions per role 74 | * @param oldPermissions set of new permissions per role 75 | */ 76 | export function diffPermissions( 77 | oldPermissions: PermissionEntry[], 78 | newPermissions: PermissionEntry[], 79 | tabLevel = 0 80 | ): TablePermissionChanges { 81 | const permissionsDelta = permissionEntryDiffPatcher.diff( 82 | oldPermissions.map(normalizePermissionEntry), 83 | newPermissions.map(normalizePermissionEntry) 84 | ) 85 | const changes = emptyChanges() 86 | 87 | forEach(permissionsDelta, (delta: unknown, index: string) => { 88 | const permissionIndex = Number(index) 89 | 90 | if (isAddition(delta)) { 91 | const role = delta[0].role 92 | 93 | core.info(tab(`+ ${role}`, tabLevel)) 94 | changes.added.push({ 95 | role, 96 | columns: diffColumnPermissions( 97 | [], 98 | columnsFromPermissionEntry(delta[0]), 99 | 1 + tabLevel 100 | ) 101 | }) 102 | } else if (isDeletion(delta)) { 103 | const role = delta[0].role 104 | 105 | core.info(tab(`- ${role}`, tabLevel)) 106 | changes.deleted.push({ 107 | role, 108 | columns: diffColumnPermissions( 109 | columnsFromPermissionEntry(delta[0]), 110 | [], 111 | 1 + tabLevel 112 | ) 113 | }) 114 | } else if (isFinite(permissionIndex)) { 115 | const newPermissionEntry = newPermissions[permissionIndex] 116 | const {role} = newPermissions[permissionIndex] 117 | const oldPermissionEntry = oldPermissions.find( 118 | permissionEntryPredicate(newPermissionEntry) 119 | ) 120 | 121 | if (!oldPermissionEntry) { 122 | throw new Error( 123 | `Error finding old "${role}" permission entry at new index ${permissionIndex}` 124 | ) 125 | } 126 | 127 | core.info(tab(`+/- ${role}`, tabLevel)) 128 | changes.modified.push({ 129 | role, 130 | columns: diffColumnPermissions( 131 | columnsFromPermissionEntry(oldPermissionEntry), 132 | columnsFromPermissionEntry(newPermissionEntry), 133 | 1 + tabLevel 134 | ) 135 | }) 136 | } 137 | }) 138 | 139 | return changes 140 | } 141 | 142 | export function emptyTablePermissionsChanges(): TablePermissionsChanges { 143 | return { 144 | insert_permissions: emptyChanges(), 145 | select_permissions: emptyChanges(), 146 | update_permissions: emptyChanges(), 147 | delete_permissions: emptyChanges() 148 | } 149 | } 150 | 151 | /** 152 | * Return the permission `entry` with predictably sorted column names. 153 | */ 154 | export function normalizePermissionEntry( 155 | entry: PermissionEntry 156 | ): PermissionEntry { 157 | if (isDeletePermissionEntry(entry) || !isArray(entry.permission.columns)) { 158 | return entry 159 | } 160 | 161 | return { 162 | ...entry, 163 | permission: { 164 | ...entry.permission, 165 | columns: sortStrings(entry.permission.columns) 166 | } 167 | } 168 | } 169 | 170 | /** 171 | * Return the mustache `PERMISSIONS_TEMPLATE` view data. 172 | */ 173 | export function viewFromTablePermissionChanges( 174 | tablePermissions: TablePermissionsChanges 175 | ): null | Record { 176 | /** change type, mapped by permission operation and role */ 177 | const rolePermissionChangesMap = TablePermissions.reduce< 178 | Map> 179 | >((map, permission) => { 180 | for (const changeType of ChangeTypes) { 181 | for (const change of tablePermissions[permission][changeType]) { 182 | map.set(change.role, { 183 | ...map.get(change.role), 184 | [permission]: changeType 185 | }) 186 | } 187 | } 188 | 189 | return map 190 | }, new Map()) 191 | 192 | if (!rolePermissionChangesMap.size) { 193 | return null 194 | } 195 | 196 | /** table rows per role */ 197 | const body = Array.from(rolePermissionChangesMap.entries()) 198 | .sort(([a], [b]) => a.localeCompare(b)) 199 | .map(([role, permissionChanges]) => ({ 200 | role, 201 | cells: TablePermissions.map(permission => { 202 | return tableCellFromChangeType(permissionChanges[permission]) 203 | }) 204 | })) 205 | 206 | return { 207 | table: { 208 | headRow: ['', ...TablePermissions.map(tableHeadingFromPermission)], 209 | body 210 | }, 211 | columnPermissions: columnPermissionsViewFromTableChanges(tablePermissions) 212 | } 213 | } 214 | 215 | /** 216 | * Return HTML table cell content illustrating permission `changeType`. 217 | */ 218 | export function tableCellFromChangeType(changeType?: ChangeType): string { 219 | return changeType ? iconFromChangeType(changeType) : '' 220 | } 221 | -------------------------------------------------------------------------------- /src/diff/tables/permissions/templates.ts: -------------------------------------------------------------------------------- 1 | export const PERMISSIONS_TEMPLATE = `{{#table}} 2 | 3 | 4 | 5 | {{#headRow}} 6 | 7 | {{/headRow}} 8 | 9 | 10 | 11 | {{#body}} 12 | 13 | 14 | {{#cells}} 15 | 16 | {{/cells}} 17 | 18 | {{/body}} 19 | 20 |
{{.}}
{{role}}{{{.}}}
21 | {{/table}} 22 | {{#columnPermissions}} 23 | {{> columnPermissions}} 24 | {{/columnPermissions}} 25 | ` 26 | 27 | export const COLUMN_PERMISSIONS_TEMPLATE = `
28 | {{summary}} 29 | {{#table}} 30 | 31 | 32 | 33 | {{#headRow}} 34 | 35 | {{/headRow}} 36 | 37 | 38 | 39 | {{#body}} 40 | 41 | 42 | {{#cells}} 43 | {{{content}}} 44 | {{/cells}} 45 | 46 | {{/body}} 47 | 48 |
{{.}}
{{role}}
49 | {{/table}} 50 |
51 | ` 52 | -------------------------------------------------------------------------------- /src/diff/tables/table.ts: -------------------------------------------------------------------------------- 1 | import { 2 | COLUMN_PERMISSIONS_TEMPLATE, 3 | PERMISSIONS_TEMPLATE 4 | } from './permissions/templates' 5 | import { 6 | DiffOptions, 7 | QualifiedTable, 8 | TableChange, 9 | TableEntryChange 10 | } from '../types' 11 | 12 | import {DEFAULT_DATABASE_NAME} from '../../load/consts' 13 | import {TABLE_TEMPLATE} from './templates' 14 | import {TableEntry} from '@hasura/metadata' 15 | import {consoleLinkFromUrl} from './utils' 16 | import {renderTemplate} from '../functions' 17 | import urlcat from 'urlcat' 18 | import {viewFromTablePermissionChanges} from './permissions' 19 | 20 | export function changeFromQualifiedTable( 21 | {database, schema, name}: QualifiedTable, 22 | {hasuraEndpoint}: DiffOptions 23 | ): TableChange { 24 | const change: TableChange = {database, schema, name} 25 | 26 | if (hasuraEndpoint) { 27 | change._links = consoleLinkFromUrl( 28 | urlcat( 29 | hasuraEndpoint, 30 | `/console/data/${ 31 | database ? ':database/' : '' 32 | }schema/:schema/tables/:name/modify`, 33 | {database, schema, name} 34 | ) 35 | ) 36 | } 37 | 38 | return change 39 | } 40 | 41 | export function formatTableEntryChange( 42 | heading: string, 43 | tableEntries: TableEntryChange[] 44 | ): string { 45 | if (0 === tableEntries.length) { 46 | return '' 47 | } 48 | 49 | return renderTemplate( 50 | TABLE_TEMPLATE, 51 | { 52 | heading, 53 | tables: tableEntries.map(({table, ...tablePermissions}) => ({ 54 | table, 55 | permissions: viewFromTablePermissionChanges(tablePermissions) 56 | })) 57 | }, 58 | { 59 | permissions: PERMISSIONS_TEMPLATE, 60 | columnPermissions: COLUMN_PERMISSIONS_TEMPLATE 61 | } 62 | ) 63 | } 64 | 65 | export function hashFromTable({ 66 | database = DEFAULT_DATABASE_NAME, 67 | schema, 68 | name 69 | }: QualifiedTable): string { 70 | return `table:${database}:${schema}:${name}` 71 | } 72 | 73 | export function tableEntryPredicate( 74 | table: QualifiedTable 75 | ): (tableEntry: TableEntry) => boolean { 76 | const tableHash = hashFromTable(table) 77 | 78 | return (tableEntry: TableEntry): boolean => { 79 | return hashFromTable(tableEntry.table) === tableHash 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/diff/tables/templates.ts: -------------------------------------------------------------------------------- 1 | export const TABLE_TEMPLATE = `

{{heading}} ({{tables.length}})

2 | 17 | ` 18 | -------------------------------------------------------------------------------- /src/diff/tables/types.ts: -------------------------------------------------------------------------------- 1 | import {ChangeType, TablePermission} from '../types' 2 | 3 | export type PermissionsChangeType = Record 4 | -------------------------------------------------------------------------------- /src/diff/tables/utils.ts: -------------------------------------------------------------------------------- 1 | import {ChangeType, ConsoleLink} from './../types' 2 | 3 | export function consoleLinkFromUrl(url: string): ConsoleLink { 4 | return { 5 | console: {href: url} 6 | } 7 | } 8 | 9 | export function iconFromChangeType(changeType: ChangeType): string { 10 | switch (changeType) { 11 | case 'added': 12 | return '➕' 13 | case 'modified': 14 | return '/' 15 | case 'deleted': 16 | return '➖' 17 | default: 18 | assertNeverChangeType(changeType) 19 | } 20 | } 21 | 22 | export function assertNeverChangeType(changeType: never): never { 23 | throw new Error(`Unexpected change type: ${changeType}`) 24 | } 25 | -------------------------------------------------------------------------------- /src/diff/templates.ts: -------------------------------------------------------------------------------- 1 | export const CHANGE_TEMPLATE = `

🤖 Hasura Change Summary compared a subset of table metadata including permissions:

2 | {{{version}}} 3 | {{{tables}}} 4 | ` 5 | 6 | export const VERSION_TEMPLATE = `

Upgraded Config

7 |

This project upgraded to config v{{version}}! Read about what has changed.

` 8 | -------------------------------------------------------------------------------- /src/diff/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Columns, 3 | DeletePermissionEntry, 4 | InsertPermissionEntry, 5 | QualifiedTable as QualifiedTableBase, 6 | SelectPermissionEntry, 7 | TableEntry as TableEntryBase, 8 | UpdatePermissionEntry 9 | } from '@hasura/metadata' 10 | 11 | /* #region metadata */ 12 | export interface QualifiedTable extends QualifiedTableBase { 13 | database?: string 14 | } 15 | 16 | export interface TableEntry extends TableEntryBase { 17 | table: QualifiedTable 18 | } 19 | /* #endregion */ 20 | 21 | /* #region changes */ 22 | export const ChangeTypes = ['added', 'modified', 'deleted'] as const 23 | 24 | export type ChangeType = typeof ChangeTypes[number] 25 | 26 | export interface ConsoleLink { 27 | console: { 28 | href: string 29 | } 30 | } 31 | 32 | /** 33 | * Resembles Hypertext Application Language (HAL) 34 | * 35 | * @see https://stateless.group/hal_specification.html 36 | */ 37 | export interface LinkableChange { 38 | _links?: ConsoleLink 39 | } 40 | 41 | export const TablePermissions = [ 42 | 'insert_permissions', 43 | 'select_permissions', 44 | 'update_permissions', 45 | 'delete_permissions' 46 | ] as const 47 | 48 | export type TablePermission = typeof TablePermissions[number] 49 | 50 | /* #region TablePermissionColumns */ 51 | export interface TablePermissionColumn { 52 | name: string 53 | isComputed: boolean 54 | } 55 | 56 | export type TablePermissionColumnAll = Columns.Empty 57 | 58 | export type TablePermissionColumns = 59 | | TablePermissionColumn[] 60 | | TablePermissionColumnAll 61 | /* #endregion */ 62 | 63 | /* #region TablePermissionColumnChanges */ 64 | export interface TablePermissionColumnChange extends TablePermissionColumn { 65 | type: 'added' | 'deleted' 66 | } 67 | 68 | /** `true` denotes column permissions changed in some way, but the explicit set of columns is unknown */ 69 | export type TablePermissionColumnChanges = TablePermissionColumnChange[] | true 70 | /* #endregion */ 71 | 72 | export interface TablePermissionChange { 73 | role: string 74 | columns: TablePermissionColumnChanges 75 | } 76 | 77 | export type TablePermissionChanges = Record 78 | 79 | export type TablePermissionsChanges = Record< 80 | TablePermission, 81 | TablePermissionChanges 82 | > 83 | 84 | export type TableChange = QualifiedTable & LinkableChange 85 | 86 | export interface TableEntryChange extends TablePermissionsChanges { 87 | table: TableChange 88 | } 89 | 90 | export type TableEntryChanges = Record 91 | 92 | export type VersionChange = 3 | undefined 93 | 94 | export interface Changes { 95 | version: VersionChange 96 | tables: TableEntryChanges 97 | } 98 | /* #endregion */ 99 | 100 | export type PermissionEntry = 101 | | InsertPermissionEntry 102 | | SelectPermissionEntry 103 | | UpdatePermissionEntry 104 | | DeletePermissionEntry 105 | 106 | /* #region jsondiffpatch */ 107 | export type DeltaAddition = [T] 108 | 109 | export type DeltaDeletion = [T, 0, 0] 110 | 111 | export type DeltaModificationConventional = [T, T] 112 | 113 | /** @see https://github.com/benjamine/jsondiffpatch/blob/master/docs/deltas.md */ 114 | export type Delta = 115 | | DeltaAddition 116 | | DeltaDeletion 117 | | DeltaModificationConventional 118 | /* #endregion */ 119 | 120 | export interface DiffOptions { 121 | /** Hasura GraphQL engine http(s) endpoint, used for deep console links */ 122 | hasuraEndpoint?: string 123 | } 124 | -------------------------------------------------------------------------------- /src/diff/version.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | import {ConvertedMetadataVersion} from '../load/types' 4 | import {VERSION_TEMPLATE} from './templates' 5 | import {VersionChange} from './types' 6 | import {renderTemplate} from './functions' 7 | 8 | export function diffVersion( 9 | oldConvertedFrom: ConvertedMetadataVersion, 10 | newConvertedFrom: ConvertedMetadataVersion 11 | ): VersionChange { 12 | if (2 === oldConvertedFrom && !newConvertedFrom) { 13 | return 3 14 | } 15 | } 16 | 17 | export function formatVersion(version: VersionChange): string { 18 | if (!version) { 19 | return '' 20 | } 21 | 22 | core.info('Formatting version change') 23 | 24 | return renderTemplate(VERSION_TEMPLATE, {version}).trim() 25 | } 26 | -------------------------------------------------------------------------------- /src/load/AbstractMetadataLoader.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | import { 4 | HasuraMetadata, 5 | HasuraMetadataLatest, 6 | MetadataLoader, 7 | MetadataProperty 8 | } from './types' 9 | import { 10 | convertMetadataToLatest, 11 | metadataFilenameFromProperty, 12 | metadataFromVersion, 13 | metadataFromVersionContents 14 | } from './functions' 15 | 16 | import {join} from 'path' 17 | import {load} from './yaml' 18 | 19 | export abstract class AbstractMetadataLoader implements MetadataLoader { 20 | protected abstract readFile(path: string): Promise 21 | 22 | async load( 23 | projectDir: string, 24 | emptyFallback = false 25 | ): Promise { 26 | let metadata 27 | 28 | try { 29 | core.info('Initializing metadata from version') 30 | metadata = metadataFromVersionContents( 31 | await this.readFile(this.filePathFromProperty(projectDir, 'version')) 32 | ) 33 | } catch (error) { 34 | if (emptyFallback) { 35 | return convertMetadataToLatest(metadataFromVersion(3)) 36 | } 37 | 38 | throw error 39 | } 40 | 41 | const metadataProperties = Object.keys(metadata).filter( 42 | key => 'version' !== key 43 | ) as MetadataProperty[] 44 | 45 | for (const property of metadataProperties) { 46 | core.info(`Parsing ${property} YAML metadata`) 47 | metadata[property as keyof HasuraMetadata] = await load( 48 | this.filePathFromProperty(projectDir, property), 49 | this.readFile.bind(this) 50 | ) 51 | } 52 | 53 | return convertMetadataToLatest(metadata) 54 | } 55 | 56 | protected metadataPathFromProject(projectDir: string): string { 57 | return join(projectDir, 'metadata') 58 | } 59 | 60 | private filePathFromProperty( 61 | projectDir: string, 62 | property: MetadataProperty 63 | ): string { 64 | return join( 65 | this.metadataPathFromProject(projectDir), 66 | metadataFilenameFromProperty(property) 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/load/GitHubLoader.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | import {basename, dirname} from 'path' 4 | import { 5 | isTreeEntryBlob, 6 | MetadataContentsGraphqlResponse, 7 | TreeEntryBlob 8 | } from './types' 9 | 10 | import {Context} from '@actions/github/lib/context' 11 | import {GitHub} from '@actions/github/lib/utils' 12 | import {isArray} from 'lodash' 13 | import {AbstractMetadataLoader} from './AbstractMetadataLoader' 14 | import {METADATA_CONTENTS_GRAPHQL_QUERY} from './consts' 15 | 16 | export class GitHubLoader extends AbstractMetadataLoader { 17 | private directoryContents: Map = new Map() 18 | 19 | constructor( 20 | private octokit: InstanceType, 21 | private repo: Context['repo'], 22 | private baseRef: string 23 | ) { 24 | super() 25 | } 26 | 27 | protected async readFile(path: string): Promise { 28 | const entries = await this.fetchDirectoryContents(dirname(path)) 29 | 30 | const filename = basename(path) 31 | const entry = entries.find(({name}) => name === filename) 32 | 33 | if (!entry) { 34 | throw new Error(`Error reading file: ${path}`) 35 | } 36 | 37 | return entry.object.text 38 | } 39 | 40 | private async fetchDirectoryContents( 41 | directory: string 42 | ): Promise { 43 | if (!this.directoryContents.has(directory)) { 44 | const objectExpression = `${this.baseRef}:${directory}` 45 | 46 | core.debug(`Fetching directory contents: ${objectExpression}`) 47 | const {repository} = 48 | await this.octokit.graphql( 49 | METADATA_CONTENTS_GRAPHQL_QUERY, 50 | { 51 | ...this.repo, 52 | objectExpression 53 | } 54 | ) 55 | 56 | const entries = repository.object?.entries 57 | 58 | this.directoryContents.set( 59 | directory, 60 | isArray(entries) ? entries.filter(isTreeEntryBlob) : [] 61 | ) 62 | } 63 | 64 | return this.directoryContents.get(directory) ?? [] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/load/WorkspaceLoader.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | import {AbstractMetadataLoader} from './AbstractMetadataLoader' 4 | import {join} from 'path' 5 | import {readFileSync} from 'fs' 6 | 7 | export class WorkspaceLoader extends AbstractMetadataLoader { 8 | constructor(private workspacePath: string) { 9 | super() 10 | } 11 | 12 | protected metadataPathFromProject(projectDir: string): string { 13 | return join(this.workspacePath, super.metadataPathFromProject(projectDir)) 14 | } 15 | 16 | protected async readFile(path: string): Promise { 17 | core.debug(`Reading file: ${path}`) 18 | return readFileSync(path, 'utf8') 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/load/__tests__/GitHubLoader.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import {HasuraMetadata, TreeEntry} from '../types' 3 | import {lstatSync, readFileSync, readdirSync} from 'fs' 4 | 5 | import {GitHubLoader} from '../GitHubLoader' 6 | import type {RequestParameters} from '@octokit/types' 7 | import {isString} from 'lodash' 8 | import {join} from 'path' 9 | 10 | describe('GitHubLoader', () => { 11 | let target: GitHubLoader 12 | let metadata: HasuraMetadata 13 | let octokit: any 14 | 15 | beforeEach(() => { 16 | octokit = {} 17 | 18 | target = new GitHubLoader( 19 | octokit, 20 | { 21 | owner: 'OWNER', 22 | repo: 'REPO' 23 | }, 24 | 'main' 25 | ) 26 | }) 27 | 28 | describe('load v2', () => { 29 | describe('empty', () => { 30 | beforeEach(async () => { 31 | octokit.graphql = jest.fn(() => ({ 32 | repository: { 33 | object: null 34 | } 35 | })) 36 | 37 | metadata = await target.load('src', true) 38 | }) 39 | 40 | it('should make GraphQL call', () => { 41 | expect(octokit.graphql).toHaveBeenCalledWith(expect.any(String), { 42 | owner: 'OWNER', 43 | repo: 'REPO', 44 | objectExpression: 'main:src/metadata' 45 | }) 46 | }) 47 | 48 | it('should return empty metadata', () => { 49 | expect(metadata).toStrictEqual({ 50 | version: 3, 51 | databases: [] 52 | }) 53 | }) 54 | }) 55 | 56 | describe('existing', () => { 57 | beforeEach(async () => { 58 | octokit.graphql = jest.fn(() => ({ 59 | repository: { 60 | object: { 61 | entries: treeEntryFixtures( 62 | join('__tests__', 'fixtures', 'v2', 'metadata') 63 | ) 64 | } 65 | } 66 | })) 67 | 68 | metadata = await target.load('.') 69 | }) 70 | 71 | it('should make GraphQL call', () => { 72 | expect(octokit.graphql).toHaveBeenCalledWith(expect.any(String), { 73 | owner: 'OWNER', 74 | repo: 'REPO', 75 | objectExpression: 'main:metadata' 76 | }) 77 | }) 78 | 79 | it('should return metadata', () => { 80 | expect(metadata).toStrictEqual({ 81 | __converted_from: 2, 82 | version: 3, 83 | databases: [ 84 | { 85 | name: 'default', 86 | tables: [ 87 | { 88 | table: { 89 | schema: 'public', 90 | name: 'users' 91 | } 92 | } 93 | ] 94 | } 95 | ] 96 | }) 97 | }) 98 | }) 99 | }) 100 | 101 | describe('load v3', () => { 102 | beforeEach(async () => { 103 | octokit.graphql = jest.fn( 104 | (_query: string, {objectExpression}: RequestParameters) => { 105 | if (!isString(objectExpression)) { 106 | throw new Error('objectExpression is not a string') 107 | } 108 | 109 | const directory = objectExpression.slice('main:'.length) 110 | 111 | return { 112 | repository: { 113 | object: { 114 | entries: treeEntryFixtures( 115 | join('__tests__', 'fixtures', 'v3', directory) 116 | ) 117 | } 118 | } 119 | } 120 | } 121 | ) 122 | 123 | metadata = await target.load('.') 124 | }) 125 | 126 | const expectedObjectExpression: string[] = [ 127 | 'main:metadata', 128 | 'main:metadata/databases', 129 | 'main:metadata/databases/default/tables' 130 | ] 131 | 132 | it(`should make ${expectedObjectExpression.length} GraphQL calls`, () => { 133 | for (const objectExpression of expectedObjectExpression) { 134 | expect(octokit.graphql).toHaveBeenCalledWith(expect.any(String), { 135 | owner: 'OWNER', 136 | repo: 'REPO', 137 | objectExpression 138 | }) 139 | } 140 | 141 | expect(octokit.graphql.mock.calls.length).toStrictEqual( 142 | expectedObjectExpression.length 143 | ) 144 | }) 145 | 146 | it('should return metadata', () => { 147 | expect(metadata).toStrictEqual({ 148 | version: 3, 149 | databases: [ 150 | { 151 | name: 'default', 152 | kind: 'postgres', 153 | tables: [ 154 | { 155 | table: { 156 | schema: 'public', 157 | name: 'users' 158 | } 159 | } 160 | ] 161 | } 162 | ] 163 | }) 164 | }) 165 | }) 166 | }) 167 | 168 | function treeEntryFixtures(directory: string): TreeEntry[] { 169 | return readdirSync(directory).map>(name => { 170 | const path = join(directory, name) 171 | 172 | if (lstatSync(path).isFile()) { 173 | return { 174 | name, 175 | object: {text: readFileSync(path, 'utf8')} 176 | } 177 | } 178 | 179 | return {name, object: {}} 180 | }) 181 | } 182 | -------------------------------------------------------------------------------- /src/load/__tests__/WorkspaceLoader.test.ts: -------------------------------------------------------------------------------- 1 | import {WorkspaceLoader} from '../WorkspaceLoader' 2 | 3 | describe('WorkspaceLoader', () => { 4 | let target: WorkspaceLoader 5 | 6 | beforeEach(() => { 7 | target = new WorkspaceLoader('./') 8 | }) 9 | 10 | test('load v2', async () => { 11 | const metadata = await target.load('./__tests__/fixtures/v2') 12 | 13 | expect(metadata).toStrictEqual({ 14 | __converted_from: 2, 15 | version: 3, 16 | databases: [ 17 | { 18 | name: 'default', 19 | tables: [ 20 | { 21 | table: { 22 | schema: 'public', 23 | name: 'users' 24 | } 25 | } 26 | ] 27 | } 28 | ] 29 | }) 30 | }) 31 | 32 | test('load v3', async () => { 33 | const metadata = await target.load('./__tests__/fixtures/v3') 34 | 35 | expect(metadata).toStrictEqual({ 36 | version: 3, 37 | databases: [ 38 | { 39 | name: 'default', 40 | kind: 'postgres', 41 | tables: [ 42 | { 43 | table: { 44 | schema: 'public', 45 | name: 'users' 46 | } 47 | } 48 | ] 49 | } 50 | ] 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/load/consts.ts: -------------------------------------------------------------------------------- 1 | import {Database, MetadataProperty} from './types' 2 | 3 | export const METADATA_PROPERTIES: MetadataProperty[] = ['databases'] 4 | 5 | export const DEFAULT_DATABASE_NAME = 'default' 6 | 7 | export const DEFAULT_DATABASE: Database = { 8 | name: DEFAULT_DATABASE_NAME, 9 | tables: [] 10 | } 11 | 12 | export const METADATA_CONTENTS_GRAPHQL_QUERY = ` 13 | query metadataContents($owner: String!, $repo: String!, $objectExpression: String!) { 14 | repository(owner: $owner, name: $repo) { 15 | object(expression: $objectExpression) { 16 | ... on Tree { 17 | entries { 18 | name 19 | object { 20 | ... on Blob { 21 | text 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | }` 29 | -------------------------------------------------------------------------------- /src/load/functions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HasuraMetadata, 3 | HasuraMetadataLatest, 4 | MetadataProperty, 5 | isMetadataV2, 6 | isMetadataV3 7 | } from './types' 8 | 9 | import {DEFAULT_DATABASE} from './consts' 10 | import {isObject} from 'lodash' 11 | import {load} from 'js-yaml' 12 | 13 | export function metadataFilenameFromProperty( 14 | property: MetadataProperty 15 | ): string { 16 | const filename = `${property}.yaml` 17 | 18 | if ('databases' === property) { 19 | return `${property}/${filename}` 20 | } 21 | 22 | return filename 23 | } 24 | 25 | export function metadataFromVersionContents( 26 | fileContents: string 27 | ): HasuraMetadata { 28 | const metadata = load(fileContents) as HasuraMetadata 29 | 30 | if (!isObject(metadata) || !isFinite(metadata.version)) { 31 | throw new Error('Invalid version metadata file') 32 | } 33 | 34 | return metadataFromVersion(metadata.version) 35 | } 36 | 37 | export function metadataFromVersion(version: number): HasuraMetadata { 38 | switch (version) { 39 | case 2: 40 | return { 41 | version, 42 | tables: [] 43 | } 44 | case 3: 45 | return { 46 | version, 47 | databases: [] 48 | } 49 | default: 50 | throw new Error('Unsupported metadata version') 51 | } 52 | } 53 | 54 | export function convertMetadataToLatest( 55 | metadata: HasuraMetadata 56 | ): HasuraMetadataLatest { 57 | if (isMetadataV2(metadata)) { 58 | return { 59 | __converted_from: 2, 60 | version: 3, 61 | databases: [ 62 | { 63 | ...DEFAULT_DATABASE, 64 | tables: metadata.tables 65 | } 66 | ] 67 | } 68 | } 69 | 70 | if (isMetadataV3(metadata)) { 71 | return metadata 72 | } 73 | 74 | return assertNever(metadata) 75 | } 76 | 77 | function assertNever(metadata: never): never { 78 | throw new Error(`Unexpected metadata: ${JSON.stringify(metadata)}`) 79 | } 80 | -------------------------------------------------------------------------------- /src/load/types.ts: -------------------------------------------------------------------------------- 1 | import {HasuraMetadataV2, TableEntry} from '@hasura/metadata' 2 | import {isObject, isString} from 'lodash' 3 | 4 | export interface Database { 5 | name: string 6 | tables: TableEntry[] 7 | } 8 | 9 | export interface HasuraMetadataV3 extends Omit { 10 | version: 3 11 | databases: Database[] 12 | } 13 | 14 | export type ConvertedMetadataVersion = 2 | undefined 15 | 16 | export interface HasuraMetadataLatest extends HasuraMetadataV3 { 17 | __converted_from?: ConvertedMetadataVersion 18 | } 19 | 20 | export type HasuraMetadata = HasuraMetadataV2 | HasuraMetadataV3 21 | 22 | type KeysOfUnion = T extends T ? keyof T : never 23 | 24 | export type MetadataProperty = KeysOfUnion 25 | 26 | export interface MetadataLoader { 27 | /** 28 | * @param emptyFallback default to empty metadata when version cannot be read 29 | */ 30 | load( 31 | projectDir: string, 32 | emptyFallback: boolean 33 | ): Promise 34 | } 35 | 36 | export type FileReader = (path: string) => Promise 37 | 38 | export function isMetadataV2( 39 | metadata: HasuraMetadata 40 | ): metadata is HasuraMetadataV2 { 41 | return 2 === metadata.version 42 | } 43 | 44 | export function isMetadataV3( 45 | metadata: HasuraMetadata 46 | ): metadata is HasuraMetadataV3 { 47 | return 3 === metadata.version 48 | } 49 | 50 | export interface TreeEntry { 51 | name: string 52 | object: T 53 | } 54 | 55 | export type TreeEntryBlob = TreeEntry<{ 56 | text: string 57 | }> 58 | 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | export function isTreeEntryBlob(entry: TreeEntry): entry is TreeEntryBlob { 61 | return ( 62 | isObject(entry) && 63 | isObject(entry.object) && 64 | isString((entry.object as {text: string}).text) 65 | ) 66 | } 67 | 68 | export interface MetadataContentsGraphqlResponse { 69 | repository: { 70 | object?: { 71 | entries?: TreeEntryBlob[] 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/load/yaml.ts: -------------------------------------------------------------------------------- 1 | import {dirname, join} from 'path' 2 | import {isArray, isObject, isString} from 'lodash' 3 | 4 | import {FileReader} from './types' 5 | import yaml from 'js-yaml' 6 | 7 | const INCLUDE_PREFIX = '!include ' 8 | 9 | export async function load(path: string, read: FileReader): Promise { 10 | const str = await read(path) 11 | 12 | return loadContent(yaml.load(str), dirname(path), read) 13 | } 14 | 15 | export async function loadContent( 16 | content: unknown, 17 | directory: string, 18 | read: FileReader 19 | ): Promise { 20 | const loadArrayValue = async (value: unknown): Promise => { 21 | const includePath = includePathFromValue(value) 22 | 23 | if (includePath) { 24 | const path = join(directory, includePath) 25 | 26 | return loadContent(yaml.load(await read(path)), dirname(path), read) 27 | } 28 | 29 | return loadContent(value, directory, read) 30 | } 31 | 32 | if (isArray(content)) { 33 | const result = [] 34 | 35 | for (const value of content) { 36 | result.push(await loadArrayValue(value)) 37 | } 38 | 39 | return result as unknown as T 40 | } 41 | 42 | if (isObject(content)) { 43 | for (const [key, value] of Object.entries(content)) { 44 | const includePath = includePathFromValue(value) 45 | 46 | if (includePath) { 47 | const path = join(directory, includePath) 48 | 49 | ;(content as Record)[key] = await loadContent( 50 | yaml.load(await read(path)), 51 | dirname(path), 52 | read 53 | ) 54 | } 55 | } 56 | } 57 | 58 | return content as T 59 | } 60 | 61 | export function includePathFromValue(value: unknown): string | undefined { 62 | if (isString(value) && value.startsWith(INCLUDE_PREFIX)) { 63 | return value.substring(INCLUDE_PREFIX.length) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | 4 | import {diff, format} from './diff' 5 | 6 | import {GitHubLoader} from './load/GitHubLoader' 7 | import {WorkspaceLoader} from './load/WorkspaceLoader' 8 | 9 | async function run(): Promise { 10 | try { 11 | const projectDir = core.getInput('project_dir') 12 | 13 | core.startGroup(`Loading old metadata in: ${projectDir}`) 14 | const oldMetadata = await new GitHubLoader( 15 | github.getOctokit(core.getInput('github_token')), 16 | github.context.repo, 17 | process.env.GITHUB_BASE_REF ?? '' 18 | ).load(projectDir, true) 19 | core.endGroup() 20 | 21 | core.startGroup(`Loading new metadata in: ${projectDir}`) 22 | const newMetadata = await new WorkspaceLoader( 23 | process.env.GITHUB_WORKSPACE ?? '' 24 | ).load(projectDir) 25 | core.endGroup() 26 | 27 | const changes = diff(oldMetadata, newMetadata, { 28 | hasuraEndpoint: core.getInput('hasura_endpoint') 29 | }) 30 | const changeHtml = format(changes) 31 | 32 | core.info('Writing job summary') 33 | await core.summary.addRaw(changeHtml).addEOL().write() 34 | 35 | core.setOutput('change_html', changeHtml) 36 | } catch (error) { 37 | core.setFailed(error instanceof Error ? error.message : String(error)) 38 | 39 | if (error instanceof Error && error.stack) { 40 | core.debug(error.stack) 41 | } 42 | } 43 | } 44 | 45 | run() 46 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 4 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 5 | "outDir": "./lib" /* Redirect output structure to the directory. */, 6 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 7 | "strict": true /* Enable all strict type-checking options. */, 8 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | }, 11 | "exclude": ["node_modules", "**/*.test.ts"] 12 | } 13 | --------------------------------------------------------------------------------