├── apps
├── website
│ ├── public
│ │ ├── robots.txt
│ │ ├── _redirects
│ │ ├── favicon.ico
│ │ └── assets
│ │ │ └── config.json
│ ├── src
│ │ ├── components
│ │ │ ├── Layout
│ │ │ │ ├── index.ts
│ │ │ │ ├── components
│ │ │ │ │ ├── ThemeModeToggle.tsx
│ │ │ │ │ └── UserSection.tsx
│ │ │ │ └── Layout.tsx
│ │ │ ├── Table
│ │ │ │ ├── index.ts
│ │ │ │ └── Table.tsx
│ │ │ ├── LinkNoStyles.tsx
│ │ │ ├── PathCell.tsx
│ │ │ └── ThemeProvider.tsx
│ │ ├── pages
│ │ │ ├── ReportPage
│ │ │ │ ├── index.ts
│ │ │ │ └── components
│ │ │ │ │ ├── ReportHeader
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── components
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── ReviewRecord.tsx
│ │ │ │ │ └── ReportHeader.tsx
│ │ │ │ │ ├── ReportTable
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── components
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── SizeCell.tsx
│ │ │ │ │ │ ├── StatusCell.tsx
│ │ │ │ │ │ └── ChangeSizeCell.tsx
│ │ │ │ │ └── ReportTable.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── TabTitle.tsx
│ │ │ ├── ReportsPage
│ │ │ │ ├── index.ts
│ │ │ │ ├── components
│ │ │ │ │ ├── ReportsChart
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── utils.ts
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── ColorCell.tsx
│ │ │ │ │ │ │ └── CustomTooltip.tsx
│ │ │ │ │ │ └── ReportsChart.tsx
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── SubprojectsAutocomplete.tsx
│ │ │ │ │ └── ReportsResult.tsx
│ │ │ │ └── ReportsPage.tsx
│ │ │ ├── index.ts
│ │ │ └── HomePage.tsx
│ │ ├── hooks
│ │ │ ├── index.ts
│ │ │ ├── useQueryParams.ts
│ │ │ └── useOnMount.ts
│ │ ├── consts
│ │ │ ├── commitRecords.ts
│ │ │ └── theme.ts
│ │ ├── services
│ │ │ └── FetchError.ts
│ │ ├── models
│ │ │ └── UserModel.ts
│ │ ├── typings
│ │ │ ├── styled.d.ts
│ │ │ └── vite-env.d.ts
│ │ ├── utils
│ │ │ ├── objectUtils.ts
│ │ │ └── textUtils.ts
│ │ ├── Router.tsx
│ │ ├── stores
│ │ │ ├── UserStore.ts
│ │ │ └── ConfigStore.ts
│ │ └── main.tsx
│ ├── .env.development
│ ├── tsconfig.app.json
│ ├── tsconfig.spec.json
│ ├── .eslintrc.json
│ ├── .bundlemonrc.json
│ ├── tsconfig.json
│ ├── index.html
│ ├── vite.config.ts
│ ├── package.json
│ └── project.json
├── service
│ ├── .gitignore
│ ├── .vercelignore
│ ├── public
│ │ └── index.html
│ ├── src
│ │ ├── utils
│ │ │ ├── utils.ts
│ │ │ ├── promiseUtils.ts
│ │ │ ├── website.ts
│ │ │ ├── projectUtils.ts
│ │ │ ├── hashUtils.ts
│ │ │ ├── linkUtils.ts
│ │ │ └── reportUtils.ts
│ │ ├── types
│ │ │ ├── schemas
│ │ │ │ ├── index.ts
│ │ │ │ ├── subprojects.ts
│ │ │ │ ├── auth.ts
│ │ │ │ ├── projects.ts
│ │ │ │ ├── common.ts
│ │ │ │ ├── githubOutput.ts
│ │ │ │ └── commitRecords.ts
│ │ │ └── auth.ts
│ │ ├── consts
│ │ │ ├── __tests__
│ │ │ │ ├── commitRecords.spec.ts
│ │ │ │ └── __snapshots__
│ │ │ │ │ └── commitRecords.spec.ts.snap
│ │ │ └── commitRecords.ts
│ │ ├── controllers
│ │ │ ├── usersController.ts
│ │ │ ├── subprojectsController.ts
│ │ │ ├── authController.ts
│ │ │ ├── utils
│ │ │ │ └── markdownReportGenerator.ts
│ │ │ └── projectsController.ts
│ │ ├── routes
│ │ │ ├── api
│ │ │ │ ├── index.ts
│ │ │ │ └── __tests__
│ │ │ │ │ └── usersRoutes.spec.ts
│ │ │ ├── __tests__
│ │ │ │ └── isAliveRoute.spec.ts
│ │ │ └── index.ts
│ │ ├── middlewares
│ │ │ └── auth.ts
│ │ ├── types.ts
│ │ ├── framework
│ │ │ ├── mongo
│ │ │ │ ├── init.ts
│ │ │ │ ├── commitRecords
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── __tests__
│ │ │ │ │ │ └── utils.spec.ts
│ │ │ │ └── client.ts
│ │ │ └── env.ts
│ │ └── local-certs
│ │ │ ├── cert.pem
│ │ │ └── key.pem
│ ├── .eslintrc.json
│ ├── docker-compose.test.yml
│ ├── vercel
│ │ └── serverless.ts
│ ├── vercel.json
│ ├── tsconfig.app.json
│ ├── .development.env
│ ├── tsconfig.spec.json
│ ├── scripts
│ │ ├── generateSecretKey.js
│ │ ├── deleteProjectRecords.ts
│ │ ├── replaceProjectBranch.ts
│ │ ├── generateSchemas.js
│ │ └── generateLocalData.ts
│ ├── tests
│ │ ├── setup.ts
│ │ ├── utils.ts
│ │ ├── app.ts
│ │ └── projectUtils.ts
│ ├── jest.config.ts
│ ├── tsconfig.json
│ └── package.json
└── platform
│ ├── README.md
│ ├── tests
│ ├── consts.ts
│ ├── website.spec.ts
│ └── isAlive.spec.ts
│ ├── .eslintrc.json
│ ├── jest.config.ts
│ ├── .dockerignore
│ ├── tsconfig.spec.json
│ ├── tsconfig.json
│ ├── Dockerfile
│ ├── project.json
│ └── package.json
├── packages
├── bundlemon
│ ├── lib
│ │ ├── cli
│ │ │ ├── __tests__
│ │ │ │ ├── assets
│ │ │ │ │ ├── empty.json
│ │ │ │ │ ├── bad-format.js
│ │ │ │ │ ├── bad-format.json
│ │ │ │ │ ├── bad-format.yaml
│ │ │ │ │ └── success.json
│ │ │ │ └── configFile.spec.ts
│ │ │ ├── types.ts
│ │ │ ├── configFile.ts
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── main
│ │ │ ├── analyzer
│ │ │ │ ├── __tests__
│ │ │ │ │ ├── fixtures
│ │ │ │ │ │ ├── getAllPaths
│ │ │ │ │ │ │ └── 1
│ │ │ │ │ │ │ │ ├── a.html
│ │ │ │ │ │ │ │ ├── a.js
│ │ │ │ │ │ │ │ └── s
│ │ │ │ │ │ │ │ ├── c
│ │ │ │ │ │ │ │ └── ac.css
│ │ │ │ │ │ │ │ └── g.aa.bbb
│ │ │ │ │ │ └── getFilesGroupByPattern
│ │ │ │ │ │ │ ├── index.html
│ │ │ │ │ │ │ ├── service.js
│ │ │ │ │ │ │ └── static
│ │ │ │ │ │ │ ├── js
│ │ │ │ │ │ │ ├── other.js
│ │ │ │ │ │ │ ├── about.hjasj2u.js
│ │ │ │ │ │ │ ├── login.a2j21i.js
│ │ │ │ │ │ │ ├── main.jh2j2ks.js
│ │ │ │ │ │ │ ├── test.jks22892s.chunk.js
│ │ │ │ │ │ │ └── test2.js2k2kxj.chunk.js
│ │ │ │ │ │ │ └── styles
│ │ │ │ │ │ │ ├── other.css
│ │ │ │ │ │ │ └── main.hjsj2ks.css
│ │ │ │ │ ├── __snapshots__
│ │ │ │ │ │ └── pathUtils.spec.ts.snap
│ │ │ │ │ └── getFileSize.spec.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── getFileSize.ts
│ │ │ │ ├── analyzeLocalFiles.ts
│ │ │ │ └── fileDetailsUtils.ts
│ │ │ ├── outputs
│ │ │ │ ├── index.ts
│ │ │ │ ├── outputs
│ │ │ │ │ ├── __tests__
│ │ │ │ │ │ └── fixtures
│ │ │ │ │ │ │ ├── not-a-function-custom-output.js
│ │ │ │ │ │ │ ├── sync-custom-output.js
│ │ │ │ │ │ │ ├── async-custom-output-throw.js
│ │ │ │ │ │ │ ├── sync-custom-output-throw.js
│ │ │ │ │ │ │ └── async-custom-output.js
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── json.ts
│ │ │ │ │ ├── custom.ts
│ │ │ │ │ └── console.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── __tests__
│ │ │ │ │ └── utils.spec.ts
│ │ │ ├── report
│ │ │ │ ├── index.ts
│ │ │ │ └── generateReport.ts
│ │ │ ├── utils
│ │ │ │ ├── ci
│ │ │ │ │ ├── providers
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── codefresh.ts
│ │ │ │ │ │ ├── circleCI.ts
│ │ │ │ │ │ ├── travis.ts
│ │ │ │ │ │ └── github.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── validationUtils.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── __tests__
│ │ │ │ │ └── configUtils.ts
│ │ │ ├── index.ts
│ │ │ ├── initializer.ts
│ │ │ └── types.ts
│ │ └── common
│ │ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── consts.spec.ts.snap
│ │ │ └── consts.spec.ts
│ │ │ ├── consts.ts
│ │ │ └── logger.ts
│ ├── bin
│ │ └── bundlemon.ts
│ ├── .eslintrc.json
│ ├── jest.config.ts
│ ├── tsconfig.json
│ ├── tsconfig.lib.json
│ ├── README.md
│ ├── tsconfig.spec.json
│ ├── package.json
│ └── project.json
├── bundlemon-utils
│ ├── .eslintrc.json
│ ├── jest.config.ts
│ ├── lib
│ │ ├── __tests__
│ │ │ ├── consts.spec.ts
│ │ │ └── __snapshots__
│ │ │ │ └── consts.spec.ts.snap
│ │ ├── index.ts
│ │ ├── consts.ts
│ │ └── diffReport
│ │ │ └── utils.ts
│ ├── README.md
│ ├── tsconfig.json
│ ├── tsconfig.lib.json
│ ├── tsconfig.spec.json
│ ├── package.json
│ └── project.json
└── bundlemon-markdown-output
│ ├── .eslintrc.json
│ ├── lib
│ ├── index.ts
│ ├── types.ts
│ ├── markdownUtils.ts
│ └── __tests__
│ │ └── markdownUtils.spec.ts
│ ├── jest.config.ts
│ ├── README.md
│ ├── tsconfig.json
│ ├── tsconfig.lib.json
│ ├── tsconfig.spec.json
│ ├── package.json
│ └── project.json
├── assets
├── check-run.png
├── history.png
├── pr-comment.png
├── build-status-pass.png
├── history-hover-commit.png
└── build-status-fail-max-size.png
├── .prettierrc.yml
├── .eslintignore
├── .github
├── codeql
│ └── codeql-config.yml
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── FUNDING.yml
└── workflows
│ ├── codeql.yml
│ ├── build-web.yml
│ └── publish-packages.yml
├── .prettierignore
├── jest.preset.js
├── .husky
└── pre-commit
├── jest.config.ts
├── codecov.yml
├── docs
├── self-hosted
│ ├── docker-compose.yaml
│ └── README.md
├── cli-flags.md
├── types.md
├── customPathLabels.md
├── migration-v1-to-v2.md
└── privacy.md
├── tsconfig.base.json
├── LICENSE
├── migrations.json
├── CONTRIBUTING.md
├── package.json
├── .eslintrc.json
└── .gitignore
/apps/website/public/robots.txt:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/website/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
2 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/cli/__tests__/assets/empty.json:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/service/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | private
3 | .vercel
4 | api/
5 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './main';
2 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getAllPaths/1/a.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getAllPaths/1/a.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/website/src/components/Layout/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Layout';
2 |
--------------------------------------------------------------------------------
/apps/website/src/components/Table/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Table';
2 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/cli/__tests__/assets/bad-format.js:
--------------------------------------------------------------------------------
1 | module.exports = ;
2 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/cli/__tests__/assets/bad-format.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [
3 | }
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getAllPaths/1/s/c/ac.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getAllPaths/1/s/g.aa.bbb:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './ReportPage';
2 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './ReportsPage';
2 |
--------------------------------------------------------------------------------
/assets/check-run.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/check-run.png
--------------------------------------------------------------------------------
/assets/history.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/history.png
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/index.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/service.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.prettierrc.yml:
--------------------------------------------------------------------------------
1 | trailingComma: es5
2 | singleQuote: true
3 | printWidth: 120
4 | tabWidth: 2
5 |
--------------------------------------------------------------------------------
/apps/service/.vercelignore:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | !vercel.json
4 | !api
5 | !public
6 | api/package.json
7 |
--------------------------------------------------------------------------------
/assets/pr-comment.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/pr-comment.png
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/other.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/service/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/cli/__tests__/assets/bad-format.yaml:
--------------------------------------------------------------------------------
1 | files:
2 | - 'a.js'
3 | - 'b.js
4 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/styles/other.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .nx
3 | dist/
4 | **/__tests__/**/assets
5 | .vercel
6 | apps/service/api/
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/about.hjasj2u.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/login.a2j21i.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/main.jh2j2ks.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportHeader/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './ReportHeader';
2 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportTable/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './ReportTable';
2 |
--------------------------------------------------------------------------------
/assets/build-status-pass.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/build-status-pass.png
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/test.jks22892s.chunk.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/test2.js2k2kxj.chunk.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/styles/main.hjsj2ks.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/index.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 | export * from './outputManager';
3 |
--------------------------------------------------------------------------------
/apps/website/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/apps/website/public/favicon.ico
--------------------------------------------------------------------------------
/assets/history-hover-commit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/history-hover-commit.png
--------------------------------------------------------------------------------
/packages/bundlemon/bin/bundlemon.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import run from '../lib/cli';
4 |
5 | run();
6 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/cli/__tests__/assets/success.json:
--------------------------------------------------------------------------------
1 | {
2 | "baseDir": "build",
3 | "verbose": true
4 | }
5 |
--------------------------------------------------------------------------------
/packages/bundlemon/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["../../.eslintrc.json"],
3 | "ignorePatterns": ["!**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/assets/build-status-fail-max-size.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/build-status-fail-max-size.png
--------------------------------------------------------------------------------
/packages/bundlemon-utils/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["../../.eslintrc.json"],
3 | "ignorePatterns": ["!**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/.github/codeql/codeql-config.yml:
--------------------------------------------------------------------------------
1 | name: 'CodeQL config'
2 |
3 | paths-ignore:
4 | - packages/bundlemon/lib/cli/__tests__/assets
5 |
--------------------------------------------------------------------------------
/apps/website/.env.development:
--------------------------------------------------------------------------------
1 | VITE_BUNDLEMON_SERVICE_URL=https://localhost:3333
2 | VITE_GITHUB_APP_ID="Iv1.d691ef09ea414f92"
3 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/not-a-function-custom-output.js:
--------------------------------------------------------------------------------
1 | module.exports = 'not a function';
2 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/report/index.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 | export { generateReport } from './generateReport';
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Add files here to ignore them from prettier formatting
2 | /dist
3 | /coverage
4 | /.nx/cache
5 | /.nx/workspace-data
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["../../.eslintrc.json"],
3 | "ignorePatterns": ["!**/*"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/lib/index.ts:
--------------------------------------------------------------------------------
1 | export { generateReportMarkdown } from './generator';
2 | export * from './types';
3 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/lib/types.ts:
--------------------------------------------------------------------------------
1 | export interface GenerateReportMarkdownOptions {
2 | showLinkToReport?: boolean;
3 | }
4 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/index.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | export { analyzeLocalFiles } from './analyzeLocalFiles';
4 |
--------------------------------------------------------------------------------
/apps/platform/README.md:
--------------------------------------------------------------------------------
1 | # BundleMon platform
2 |
3 | BundleMon platform docker image contains both the service & the website (UI) of BundleMon.
4 |
--------------------------------------------------------------------------------
/apps/platform/tests/consts.ts:
--------------------------------------------------------------------------------
1 | export const BASE_URL = 'http://localhost:3333';
2 | export const BASE_URL_NO_WEBSITE = 'http://localhost:4444';
3 |
--------------------------------------------------------------------------------
/apps/website/public/assets/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "bundlemonServiceUrl": "https://api.bundlemon.dev",
3 | "githubAppClientId": "Iv1.0647108b3f7bfbd3"
4 | }
5 |
--------------------------------------------------------------------------------
/apps/website/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { default as useOnMount } from './useOnMount';
2 | export { default as useQueryParams } from './useQueryParams';
3 |
--------------------------------------------------------------------------------
/jest.preset.js:
--------------------------------------------------------------------------------
1 | const nxPreset = require('@nx/jest/preset').default;
2 |
3 | module.exports = { ...nxPreset, testEnvironment: 'node', verbose: true };
4 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as TabTitle } from './TabTitle';
2 | export { default as ReportTable } from './ReportTable';
3 |
--------------------------------------------------------------------------------
/apps/website/src/consts/commitRecords.ts:
--------------------------------------------------------------------------------
1 | export enum CommitRecordsQueryResolution {
2 | All = 'all',
3 | Days = 'days',
4 | Weeks = 'weeks',
5 | Months = 'months',
6 | }
7 |
--------------------------------------------------------------------------------
/apps/service/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | export function roundDecimals(num: number, decimals: number) {
2 | return Number(Math.round(Number(num + 'e' + decimals)) + 'e-' + decimals);
3 | }
4 |
--------------------------------------------------------------------------------
/packages/bundlemon/jest.config.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | displayName: 'bundlemon',
3 | preset: '../../jest.preset.js',
4 | coverageDirectory: '../../coverage/packages/bundlemon',
5 | };
6 |
--------------------------------------------------------------------------------
/apps/platform/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["../../.eslintrc.json"],
3 | "ignorePatterns": ["!**/*"],
4 | "rules": {
5 | "@typescript-eslint/no-explicit-any": "off"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/apps/service/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["../../.eslintrc.json"],
3 | "ignorePatterns": ["!**/*"],
4 | "rules": {
5 | "@typescript-eslint/no-explicit-any": "off"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/apps/website/src/hooks/useQueryParams.ts:
--------------------------------------------------------------------------------
1 | import { useLocation } from 'react-router';
2 |
3 | export default function useQueryparams() {
4 | return new URLSearchParams(useLocation().search);
5 | }
6 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportTable/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as StatusCell } from './StatusCell';
2 | export { default as ChangeSizeCell } from './ChangeSizeCell';
3 |
--------------------------------------------------------------------------------
/apps/service/src/types/schemas/index.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | export * from './common';
4 | export * from './commitRecords';
5 | export * from './githubOutput';
6 | export * from './auth';
7 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/components/ReportsChart/index.ts:
--------------------------------------------------------------------------------
1 | import { lazy } from 'react';
2 |
3 | const ReportsChart = lazy(() => import('./ReportsChart'));
4 |
5 | export default ReportsChart;
6 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportHeader/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ReviewReportModal } from './ReviewReportModal';
2 | export { default as ReviewRecord } from './ReviewRecord';
3 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/jest.config.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | displayName: 'bundlemon-utils',
3 | preset: '../../jest.preset.js',
4 | coverageDirectory: '../../coverage/packages/bundlemon-utils',
5 | };
6 |
--------------------------------------------------------------------------------
/apps/platform/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from '@jest/types';
2 |
3 | export default async (): Promise => ({
4 | displayName: 'platform',
5 | preset: '../../jest.preset.js',
6 | });
7 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/lib/__tests__/consts.spec.ts:
--------------------------------------------------------------------------------
1 | import * as consts from '../consts';
2 |
3 | describe('consts', () => {
4 | test('snapshot', () => {
5 | expect(consts).toMatchSnapshot();
6 | });
7 | });
8 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/lib/index.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | export * from './consts';
4 | export { generateDiffReport } from './diffReport';
5 | export * from './types';
6 | export * from './textUtils';
7 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/sync-custom-output.js:
--------------------------------------------------------------------------------
1 | const output = (report) => {
2 | console.log('Hello from fixture!');
3 | return report;
4 | };
5 |
6 | module.exports = output;
7 |
--------------------------------------------------------------------------------
/apps/website/src/hooks/useOnMount.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export default function (effect: () => void) {
4 | // eslint-disable-next-line react-hooks/exhaustive-deps
5 | useEffect(effect, []);
6 | }
7 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/cli/types.ts:
--------------------------------------------------------------------------------
1 | import type { Compression } from 'bundlemon-utils';
2 |
3 | export interface CliOptions {
4 | config?: string;
5 | subProject?: string;
6 | defaultCompression?: Compression;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/async-custom-output-throw.js:
--------------------------------------------------------------------------------
1 | const output = (report) => {
2 | console.log('Hello from fixture!');
3 | return report;
4 | };
5 |
6 | module.exports = output;
7 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/sync-custom-output-throw.js:
--------------------------------------------------------------------------------
1 | const output = (report) => {
2 | console.log('Hello from fixture!');
3 | return report;
4 | };
5 |
6 | module.exports = output;
7 |
--------------------------------------------------------------------------------
/apps/service/docker-compose.test.yml:
--------------------------------------------------------------------------------
1 | version: '3.3'
2 |
3 | services:
4 | mongo:
5 | image: mongo:7.0
6 | ports:
7 | - '51651:27017'
8 | logging:
9 | driver: 'none'
10 | command: --quiet
11 |
--------------------------------------------------------------------------------
/apps/service/src/consts/__tests__/commitRecords.spec.ts:
--------------------------------------------------------------------------------
1 | import * as consts from '../commitRecords';
2 |
3 | describe('consts', () => {
4 | test('snapshot', () => {
5 | expect(consts).toMatchSnapshot();
6 | });
7 | });
8 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/jest.config.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | displayName: 'bundlemon-markdown-output',
3 | preset: '../../jest.preset.js',
4 | coverageDirectory: '../../coverage/packages/bundlemon-markdown-output',
5 | };
6 |
--------------------------------------------------------------------------------
/apps/platform/.dockerignore:
--------------------------------------------------------------------------------
1 | # Ignore everything
2 | *
3 |
4 | # Allow files and directories
5 | !/dist
6 |
7 | # Ignore unnecessary files inside allowed directories
8 | # This should go after the allowed directories
9 | **/.DS_Store
10 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/async-custom-output.js:
--------------------------------------------------------------------------------
1 | const asyncOutput = (report) => {
2 | console.log('Hello from fixture!');
3 | return Promise.resolve(report);
4 | };
5 |
6 | module.exports = asyncOutput;
7 |
--------------------------------------------------------------------------------
/apps/platform/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "types": ["jest", "node"]
6 | },
7 | "include": ["jest.config.ts", "tests/**/*", "scripts/**/*"]
8 | }
9 |
--------------------------------------------------------------------------------
/apps/service/src/controllers/usersController.ts:
--------------------------------------------------------------------------------
1 | import type { RouteHandlerMethod } from 'fastify';
2 |
3 | export const meController: RouteHandlerMethod = async (req) => {
4 | const { auth, ...user } = req.getUser();
5 |
6 | return user;
7 | };
8 |
--------------------------------------------------------------------------------
/apps/website/src/services/FetchError.ts:
--------------------------------------------------------------------------------
1 | class FetchError extends Error {
2 | constructor(
3 | public message: string,
4 | public statusCode: number
5 | ) {
6 | super(message);
7 | }
8 | }
9 |
10 | export default FetchError;
11 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/README.md:
--------------------------------------------------------------------------------
1 | # bundlemon-utils
2 |
3 | [](https://www.npmjs.com/package/bundlemon-utils)
4 |
5 | **Full documentation available [here](https://github.com/LironEr/bundlemon)**
6 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | # This loads nvm.sh and sets the correct PATH before running hook
5 | export NVM_DIR="$HOME/.nvm"
6 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
7 |
8 |
9 | yarn lint-staged
10 |
--------------------------------------------------------------------------------
/apps/website/src/models/UserModel.ts:
--------------------------------------------------------------------------------
1 | class UserModel {
2 | provider: string;
3 | name: string;
4 |
5 | constructor(user: any) {
6 | this.provider = user.provider;
7 | this.name = user.name;
8 | }
9 | }
10 |
11 | export default UserModel;
12 |
--------------------------------------------------------------------------------
/apps/service/src/types/schemas/subprojects.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | import type { BaseGetRequestSchema, ProjectIdParams } from './common';
4 |
5 | export interface GetSubprojectsRequestSchema extends BaseGetRequestSchema {
6 | params: ProjectIdParams;
7 | }
8 |
--------------------------------------------------------------------------------
/apps/service/vercel/serverless.ts:
--------------------------------------------------------------------------------
1 | import init from '../src/app';
2 |
3 | export default async (req: any, res: any) => {
4 | const app = await init({ isServerless: true });
5 |
6 | await app.ready();
7 |
8 | app.server.emit('request', req, res);
9 | };
10 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/components/types.ts:
--------------------------------------------------------------------------------
1 | export interface PathRecord {
2 | friendlyName?: string;
3 | path: string;
4 | color: string;
5 | minSize: number;
6 | maxSize: number;
7 | isSelected: boolean;
8 | latestSize?: number;
9 | }
10 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from '@jest/types';
2 | import { getJestProjectsAsync } from '@nx/jest';
3 |
4 | export default async (): Promise => ({
5 | projects: [...(await getJestProjectsAsync()), '/jest.config.ts'],
6 | });
7 |
--------------------------------------------------------------------------------
/apps/service/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "buildCommand": "yarn add sodium-native@^4.0.0",
4 | "outputDirectory": "public",
5 | "rewrites": [
6 | {
7 | "source": "/(.*)",
8 | "destination": "/api/serverless.js"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/apps/website/src/components/LinkNoStyles.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Link } from 'react-router-dom';
3 |
4 | const LinkNoStyles = styled(Link)`
5 | color: inherit;
6 | text-decoration: none;
7 | `;
8 |
9 | export default LinkNoStyles;
10 |
--------------------------------------------------------------------------------
/apps/service/src/types/schemas/auth.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | import type { BaseRequestSchema } from './common';
4 |
5 | export interface LoginRequestSchema extends BaseRequestSchema {
6 | body: {
7 | provider: 'github';
8 | code: string;
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/README.md:
--------------------------------------------------------------------------------
1 | # bundlemon-markdown-output
2 |
3 | [](https://www.npmjs.com/package/bundlemon-markdown-output)
4 |
5 | **Full documentation available [here](https://github.com/LironEr/bundlemon)**
6 |
--------------------------------------------------------------------------------
/packages/bundlemon/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "files": [],
4 | "include": [],
5 | "references": [
6 | {
7 | "path": "./tsconfig.lib.json"
8 | },
9 | {
10 | "path": "./tsconfig.spec.json"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/apps/service/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "types": ["node"]
6 | },
7 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
8 | "include": ["src/**/*.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/apps/website/src/typings/styled.d.ts:
--------------------------------------------------------------------------------
1 | import '@emotion/react';
2 | import type { Theme as MuiTheme } from '@mui/material/styles';
3 |
4 | declare module '@emotion/react' {
5 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
6 | export interface Theme extends MuiTheme {}
7 | }
8 |
--------------------------------------------------------------------------------
/apps/website/src/typings/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_BUNDLEMON_SERVICE_URL: string;
5 | readonly VITE_GITHUB_APP_ID: string;
6 | }
7 |
8 | interface ImportMeta {
9 | readonly env: ImportMetaEnv;
10 | }
11 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "files": [],
4 | "include": [],
5 | "references": [
6 | {
7 | "path": "./tsconfig.lib.json"
8 | },
9 | {
10 | "path": "./tsconfig.spec.json"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/apps/website/src/utils/objectUtils.ts:
--------------------------------------------------------------------------------
1 | export function removeEmptyValuesFromObject(obj: Record) {
2 | const newObj = { ...obj };
3 |
4 | for (const key in newObj) {
5 | if (!newObj[key]) {
6 | delete newObj[key];
7 | }
8 | }
9 |
10 | return newObj;
11 | }
12 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | comment: false
2 |
3 | flags:
4 | packages:
5 | paths:
6 | - packages/
7 | service:
8 | paths:
9 | - service/
10 | coverage:
11 | status:
12 | patch: off
13 | project:
14 | default:
15 | only_pulls: true
16 | target: auto
17 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/ci/providers/index.ts:
--------------------------------------------------------------------------------
1 | import github from './github';
2 | import codefresh from './codefresh';
3 | import travis from './travis';
4 | import circleCI from './circleCI';
5 |
6 | const providers = [github, codefresh, travis, circleCI];
7 |
8 | export default providers;
9 |
--------------------------------------------------------------------------------
/apps/service/.development.env:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 | HTTP_SCHEMA=http
3 | ROOT_DOMAIN=localhost:4000
4 | MONGO_URL=mongodb://localhost:51651
5 | MONGO_DB_NAME=dev
6 | SECRET_SESSION_KEY=74925e5027a05d9e31082271747a92b11a3b6988fc303bbb2aae330bef92b3a7
7 | SHOULD_SERVE_WEBSITE=false
8 | API_PATH_PREFIX=/
9 |
--------------------------------------------------------------------------------
/apps/service/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "types": ["jest", "node"]
6 | },
7 | "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts", "tests/**/*", "scripts/**/*"]
8 | }
9 |
--------------------------------------------------------------------------------
/apps/website/src/consts/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@mui/material/styles';
2 |
3 | export const lightTheme = createTheme({
4 | palette: {
5 | mode: 'light',
6 | },
7 | });
8 |
9 | export const darkTheme = createTheme({
10 | palette: {
11 | mode: 'dark',
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "files": [],
4 | "include": [],
5 | "references": [
6 | {
7 | "path": "./tsconfig.lib.json"
8 | },
9 | {
10 | "path": "./tsconfig.spec.json"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/apps/service/scripts/generateSecretKey.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 |
3 | const sodium = require('sodium-native');
4 | const buf = Buffer.allocUnsafe(sodium.crypto_secretbox_KEYBYTES);
5 | sodium.randombytes_buf(buf);
6 | const str = buf.toString('hex');
7 |
8 | console.log(str);
9 |
--------------------------------------------------------------------------------
/packages/bundlemon/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "declaration": true,
6 | "types": ["node", "jest"]
7 | },
8 | "exclude": ["**/*.spec.ts", "jest.config.ts"],
9 | "include": ["**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/apps/service/tests/setup.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | const path = require('path');
3 |
4 | require('dotenv').config({
5 | path: path.resolve(__dirname, '../.development.env'),
6 | });
7 |
8 | process.env.MONGO_DB_NAME = 'test';
9 | process.env.ROOT_DOMAIN = 'localhost';
10 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "declaration": true,
6 | "types": ["node", "jest"]
7 | },
8 | "exclude": ["**/*.spec.ts", "jest.config.ts"],
9 | "include": ["**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/apps/service/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from '@jest/types';
2 |
3 | export default async (): Promise => ({
4 | displayName: 'service',
5 | preset: '../../jest.preset.js',
6 | coverageDirectory: '../../coverage/apps/service',
7 | setupFiles: ['/tests/setup.ts'],
8 | });
9 |
--------------------------------------------------------------------------------
/apps/service/src/routes/api/index.ts:
--------------------------------------------------------------------------------
1 | import v1Routes from './v1';
2 |
3 | import type { FastifyPluginCallback } from 'fastify';
4 |
5 | const apiRoutes: FastifyPluginCallback = (app, _opts, done) => {
6 | app.register(v1Routes, { prefix: '/v1' });
7 |
8 | done();
9 | };
10 |
11 | export default apiRoutes;
12 |
--------------------------------------------------------------------------------
/packages/bundlemon/README.md:
--------------------------------------------------------------------------------
1 | # BundleMon
2 |
3 | [](https://www.npmjs.com/package/bundlemon)
4 | [](https://github.com/LironEr/bundlemon)
5 |
6 | **Full documentation available [here](https://github.com/LironEr/bundlemon)**
7 |
--------------------------------------------------------------------------------
/packages/bundlemon/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "module": "commonjs",
6 | "types": ["jest", "node"]
7 | },
8 | "include": ["jest.config.ts", "lib/**/*.test.ts", "lib/**/*.spec.ts", "lib/**/*.d.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "declaration": true,
6 | "types": ["node", "jest"]
7 | },
8 | "exclude": ["**/*.spec.ts", "jest.config.ts"],
9 | "include": ["**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportTable/components/SizeCell.tsx:
--------------------------------------------------------------------------------
1 | import bytes from 'bytes';
2 |
3 | interface SizeCellProps {
4 | size?: number;
5 | }
6 |
7 | const SizeCell = ({ size }: SizeCellProps) => {
8 | return {size ? bytes(size) : '-'} ;
9 | };
10 |
11 | export default SizeCell;
12 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/__snapshots__/pathUtils.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`getFiles getAllPaths empty directory 1`] = `[]`;
4 |
5 | exports[`getFiles getRegexHash 1`] = `"(?[a-zA-Z0-9]+)"`;
6 |
7 | exports[`getFiles getRegexHash 2`] = `"(?[a-zA-Z0-9]+)"`;
8 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "module": "commonjs",
6 | "types": ["jest", "node", "jest-extended", "standard-version"]
7 | },
8 | "include": ["**/*.spec.ts", "**/*.spec.js", "**/*.d.ts", "jest.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "module": "commonjs",
6 | "types": ["jest", "node", "jest-extended", "standard-version"]
7 | },
8 | "include": ["**/*.spec.ts", "**/*.spec.js", "**/*.d.ts", "jest.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/apps/platform/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "module": "ES2022",
5 | "baseUrl": ".",
6 | "paths": {
7 | "@tests/*": ["./tests/*"]
8 | }
9 | },
10 | "references": [
11 | {
12 | "path": "./tsconfig.spec.json"
13 | }
14 | ],
15 | "files": [],
16 | "include": []
17 | }
18 |
--------------------------------------------------------------------------------
/apps/website/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | import { lazy } from 'react';
2 |
3 | export { default as HomePage } from './HomePage';
4 | export const CreateProjectPage = lazy(() => import('./CreateProjectPage'));
5 | export const ReportPage = lazy(() => import('./ReportPage'));
6 | export const ReportsPage = lazy(() => import('./ReportsPage'));
7 | export const LoginPage = lazy(() => import('./LoginPage'));
8 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 |
12 | **Describe the solution you'd like**
13 |
14 | **Describe alternatives you've considered**
15 |
16 | **Additional context**
17 |
--------------------------------------------------------------------------------
/docs/self-hosted/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | bundlemon:
3 | image: ghcr.io/lironer/bundlemon-platform:v1
4 | ports:
5 | - '8080:8080'
6 | environment:
7 | MONGO_URL: mongodb://mongo:27017
8 | depends_on:
9 | - mongo
10 |
11 | mongo:
12 | image: mongo:7.0
13 | ports:
14 | - '27017:27017'
15 | logging:
16 | driver: 'none'
17 | command: --quiet
18 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportTable/components/StatusCell.tsx:
--------------------------------------------------------------------------------
1 | import { Chip } from '@mui/material';
2 | import { Status } from 'bundlemon-utils';
3 |
4 | interface StatusCellProps {
5 | status: Status;
6 | }
7 |
8 | const StatusCell = ({ status }: StatusCellProps) => {
9 | return ;
10 | };
11 |
12 | export default StatusCell;
13 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/outputs/index.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 | import consoleOutput from './console';
3 | import githubOutput from './github';
4 | import jsonOutput from './json';
5 | import customOutput from './custom';
6 |
7 | import type { Output } from '../types';
8 |
9 | const outputs: Output[] = [consoleOutput, githubOutput, jsonOutput, customOutput];
10 |
11 | export const getAllOutputs = (): Output[] => outputs;
12 |
--------------------------------------------------------------------------------
/apps/website/src/utils/textUtils.ts:
--------------------------------------------------------------------------------
1 | export function textEllipsis(str: string, maxLength: number, { side = 'end', ellipsis = '...' } = {}) {
2 | if (str.length > maxLength) {
3 | switch (side) {
4 | case 'start':
5 | return ellipsis + str.slice(-(maxLength - ellipsis.length));
6 | case 'end':
7 | default:
8 | return str.slice(0, maxLength - ellipsis.length) + ellipsis;
9 | }
10 | }
11 | return str;
12 | }
13 |
--------------------------------------------------------------------------------
/apps/platform/tests/website.spec.ts:
--------------------------------------------------------------------------------
1 | import { BASE_URL, BASE_URL_NO_WEBSITE } from './consts';
2 |
3 | describe('serve website', () => {
4 | test('platform', async () => {
5 | const response = await fetch(BASE_URL);
6 |
7 | expect(response.status).toEqual(200);
8 | });
9 |
10 | test('no website', async () => {
11 | const response = await fetch(BASE_URL_NO_WEBSITE);
12 |
13 | expect(response.status).toEqual(404);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/apps/service/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "module": "ES2022",
5 | "baseUrl": ".",
6 | "paths": {
7 | "@tests/*": ["./tests/*"],
8 | "@/*": ["src/*"]
9 | }
10 | },
11 | "references": [
12 | {
13 | "path": "./tsconfig.app.json"
14 | },
15 | {
16 | "path": "./tsconfig.spec.json"
17 | }
18 | ],
19 | "files": [],
20 | "include": []
21 | }
22 |
--------------------------------------------------------------------------------
/apps/platform/tests/isAlive.spec.ts:
--------------------------------------------------------------------------------
1 | import { BASE_URL, BASE_URL_NO_WEBSITE } from './consts';
2 |
3 | describe('is alive', () => {
4 | test('platform', async () => {
5 | const response = await fetch(`${BASE_URL}/is-alive`);
6 |
7 | expect(response.status).toEqual(200);
8 | });
9 |
10 | test('no website', async () => {
11 | const response = await fetch(`${BASE_URL_NO_WEBSITE}/is-alive`);
12 |
13 | expect(response.status).toEqual(200);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/apps/service/src/controllers/subprojectsController.ts:
--------------------------------------------------------------------------------
1 | import { getSubprojects } from '../framework/mongo/commitRecords';
2 |
3 | import type { FastifyValidatedRoute } from '../types/schemas';
4 | import type { GetSubprojectsRequestSchema } from '../types/schemas/subprojects';
5 |
6 | export const getSubprojectsController: FastifyValidatedRoute = async (req, res) => {
7 | const subprojects = await getSubprojects(req.params.projectId);
8 |
9 | res.send(subprojects);
10 | };
11 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/ci/types.ts:
--------------------------------------------------------------------------------
1 | export interface CIEnvVars {
2 | ci: boolean;
3 | raw?: Record;
4 | provider?: 'github' | 'codefresh' | 'travis' | 'circleci';
5 | owner?: string;
6 | repo?: string;
7 | branch?: string;
8 | commitSha?: string;
9 | targetBranch?: string;
10 | prNumber?: string;
11 | buildId?: string;
12 | commitMsg?: string;
13 | }
14 |
15 | export interface Provider {
16 | isItMe: boolean;
17 | getVars: () => CIEnvVars;
18 | }
19 |
--------------------------------------------------------------------------------
/apps/service/src/types/schemas/projects.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | import { ProjectProvider } from 'bundlemon-utils';
4 | import { GitDetails } from '../../types';
5 |
6 | type GithubProviderAuthQuery = {
7 | runId: string;
8 | commitSha: string;
9 | };
10 |
11 | type GithubProviderRequest = {
12 | body: Omit & { provider: ProjectProvider.GitHub };
13 | query: GithubProviderAuthQuery;
14 | };
15 |
16 | export type GetOrCreateProjectIdRequestSchema = GithubProviderRequest;
17 |
--------------------------------------------------------------------------------
/apps/service/src/utils/promiseUtils.ts:
--------------------------------------------------------------------------------
1 | // tasks: Record>
2 | export async function promiseAllObject(tasks: any) {
3 | const items = await Promise.all(
4 | Object.keys(tasks).map(async (key) => {
5 | const val = await Promise.resolve(tasks[key]);
6 |
7 | return { key, val };
8 | })
9 | );
10 |
11 | const result: Record = {};
12 |
13 | items.forEach((item) => {
14 | result[item.key] = item.val;
15 | });
16 |
17 | return result;
18 | }
19 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "types": ["node"]
6 | },
7 | "exclude": [
8 | "src/**/*.spec.ts",
9 | "src/**/*.test.ts",
10 | "src/**/*.spec.tsx",
11 | "src/**/*.test.tsx",
12 | "src/**/*.spec.js",
13 | "src/**/*.test.js",
14 | "src/**/*.spec.jsx",
15 | "src/**/*.test.jsx"
16 | ],
17 | "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
18 | }
19 |
--------------------------------------------------------------------------------
/apps/service/src/types/auth.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | declare module '@fastify/secure-session' {
4 | interface SessionData {
5 | user: UserSessionData;
6 | }
7 | }
8 |
9 | export interface LoggedInUser {
10 | provider: 'github';
11 | name: string;
12 | }
13 |
14 | export interface UserSessionData extends LoggedInUser {
15 | auth: {
16 | token: string;
17 | };
18 | }
19 |
20 | declare module 'fastify' {
21 | interface FastifyRequest {
22 | getUser: () => UserSessionData;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/types.ts:
--------------------------------------------------------------------------------
1 | import type { Report } from 'bundlemon-utils';
2 | import type { NormalizedConfig } from '../types';
3 |
4 | export interface OutputCreateParams {
5 | config: NormalizedConfig;
6 | options: unknown;
7 | }
8 |
9 | export interface OutputInstance {
10 | generate(report: Report): Promise | void;
11 | }
12 |
13 | export interface Output {
14 | name: string;
15 | create: (params: OutputCreateParams) => OutputInstance | undefined | Promise;
16 | }
17 |
--------------------------------------------------------------------------------
/apps/service/src/routes/__tests__/isAliveRoute.spec.ts:
--------------------------------------------------------------------------------
1 | import { FastifyInstance } from 'fastify';
2 | import { createTestApp } from '../../../tests/app';
3 |
4 | describe('is alive route', () => {
5 | let app: FastifyInstance;
6 |
7 | beforeAll(async () => {
8 | app = await createTestApp();
9 | });
10 |
11 | test('success', async () => {
12 | const response = await app.inject({
13 | method: 'GET',
14 | url: '/is-alive',
15 | });
16 |
17 | expect(response.statusCode).toEqual(200);
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a bug report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 |
12 |
13 | **Expected behavior**
14 |
15 |
16 | **Environment**
17 | BundleMon version: ``
18 |
19 | Please run this command inside your project and paste its contents here (it automatically copies to your clipboard)
20 | `npx envinfo --system --binaries --markdown | npx clipboard-cli`
21 |
22 |
23 | **Additional context**
24 |
--------------------------------------------------------------------------------
/apps/service/src/consts/commitRecords.ts:
--------------------------------------------------------------------------------
1 | export enum CommitRecordsQueryResolution {
2 | All = 'all',
3 | Days = 'days',
4 | Weeks = 'weeks',
5 | Months = 'months',
6 | }
7 |
8 | export enum BaseRecordCompareTo {
9 | PreviousCommit = 'PREVIOUS_COMMIT',
10 | LatestCommit = 'LATEST_COMMIT',
11 | }
12 |
13 | export enum CreateCommitRecordAuthType {
14 | ProjectApiKey = 'PROJECT_API_KEY',
15 | GithubActions = 'GITHUB_ACTIONS',
16 | }
17 |
18 | export const MAX_COMMIT_MSG_LENGTH = 72;
19 | export const MAX_QUERY_RECORDS = 100;
20 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../dist/out-tsc",
5 | "types": ["vite/client", "node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"]
6 | },
7 | "include": [
8 | "vite.config.ts",
9 | "src/**/*.test.ts",
10 | "src/**/*.spec.ts",
11 | "src/**/*.test.tsx",
12 | "src/**/*.spec.tsx",
13 | "src/**/*.test.js",
14 | "src/**/*.spec.js",
15 | "src/**/*.test.jsx",
16 | "src/**/*.spec.jsx",
17 | "src/**/*.d.ts"
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/apps/service/src/middlewares/auth.ts:
--------------------------------------------------------------------------------
1 | import type { FastifyReply, FastifyRequest } from 'fastify';
2 | import type { UserSessionData } from '../types/auth';
3 |
4 | export async function authMiddleware(
5 | req: FastifyRequest<{ Body: any; Params: any; Querystring: any; Headers: any }>,
6 | res: FastifyReply
7 | ) {
8 | const userSessionData: UserSessionData | undefined = req.session.get('user');
9 |
10 | if (!userSessionData) {
11 | res.status(401).send({ message: 'unauthorized' });
12 | return res;
13 | }
14 |
15 | req.getUser = () => userSessionData;
16 | return;
17 | }
18 |
--------------------------------------------------------------------------------
/docs/cli-flags.md:
--------------------------------------------------------------------------------
1 | # CLI flags
2 |
3 | | Flag | Description | Type |
4 | | -------------------- | ------------------------------------------ | ------------------------------------------- |
5 | | -c, --config | Config file path | `string` optional |
6 | | --subProject | Override `subProject` config value | `string` optional |
7 | | --defaultCompression | Override `defaultCompression` config value | `"none"` \| `"gzip"` \| `"brotli"` optional |
8 |
--------------------------------------------------------------------------------
/apps/service/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { ProjectProvider } from 'bundlemon-utils';
2 |
3 | export interface ProjectApiKey {
4 | hash: string;
5 | startKey: string;
6 | }
7 |
8 | export interface Project {
9 | id: string;
10 | creationDate: string;
11 | apiKey: ProjectApiKey;
12 | }
13 |
14 | export interface GitDetails {
15 | provider: ProjectProvider;
16 | /**
17 | * @minLength 1
18 | * @maxLength 100
19 | * @pattern ^[a-zA-Z0-9_.-]*$
20 | */
21 | owner: string;
22 | /**
23 | * @minLength 1
24 | * @maxLength 100
25 | * @pattern ^[a-zA-Z0-9_.-]*$
26 | */
27 | repo: string;
28 | }
29 |
--------------------------------------------------------------------------------
/apps/website/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["../../.eslintrc.json", "plugin:@nx/react"],
3 | "ignorePatterns": ["!**/*"],
4 | "env": { "browser": true },
5 | "parserOptions": {
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "rules": {
11 | "@typescript-eslint/no-explicit-any": "off",
12 | "react/prop-types": "off",
13 | "react/react-in-jsx-scope": "off",
14 | "@typescript-eslint/ban-ts-comment": "off",
15 | "@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true, "argsIgnorePattern": "^_" }],
16 | "@typescript-eslint/no-empty-interface": "off"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/apps/website/.bundlemonrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "verbose": true,
3 | "baseDir": "../../dist/apps/website",
4 | "files": [
5 | {
6 | "path": "index.html"
7 | },
8 | {
9 | "path": "assets/Main-*-.js"
10 | },
11 | {
12 | "friendlyName": "JS files",
13 | "path": "assets/**/*-.js"
14 | }
15 | ],
16 | "groups": [
17 | {
18 | "friendlyName": "JS files",
19 | "path": "**/*.js"
20 | }
21 | ],
22 | "defaultCompression": "none",
23 | "reportOutput": ["github"],
24 | "includeCommitMessage": true,
25 | "pathLabels": {
26 | "hash": "[a-zA-Z0-9\\-_]+"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "ES2022",
4 | "jsx": "react-jsx",
5 | "allowJs": false,
6 | "allowSyntheticDefaultImports": true,
7 | "strict": true,
8 | "jsxImportSource": "@emotion/react",
9 | "types": ["vite/client"],
10 | "lib": ["dom"],
11 | "baseUrl": "./src",
12 | "paths": {
13 | "@/*": ["*"]
14 | }
15 | },
16 | "files": [],
17 | "include": [],
18 | "references": [
19 | {
20 | "path": "./tsconfig.app.json"
21 | },
22 | {
23 | "path": "./tsconfig.spec.json"
24 | }
25 | ],
26 | "extends": "../../tsconfig.base.json"
27 | }
28 |
--------------------------------------------------------------------------------
/apps/service/src/consts/__tests__/__snapshots__/commitRecords.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`consts snapshot 1`] = `
4 | {
5 | "BaseRecordCompareTo": {
6 | "LatestCommit": "LATEST_COMMIT",
7 | "PreviousCommit": "PREVIOUS_COMMIT",
8 | },
9 | "CommitRecordsQueryResolution": {
10 | "All": "all",
11 | "Days": "days",
12 | "Months": "months",
13 | "Weeks": "weeks",
14 | },
15 | "CreateCommitRecordAuthType": {
16 | "GithubActions": "GITHUB_ACTIONS",
17 | "ProjectApiKey": "PROJECT_API_KEY",
18 | },
19 | "MAX_COMMIT_MSG_LENGTH": 72,
20 | "MAX_QUERY_RECORDS": 100,
21 | }
22 | `;
23 |
--------------------------------------------------------------------------------
/apps/service/src/utils/website.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import { httpSchema, rootDomain, githubAppClientId } from '@/framework/env';
3 |
4 | const WEBSITE_CONFIG_PATH = '/app/service/public/assets/config.json';
5 |
6 | export function overrideWebsiteConfig() {
7 | const websiteConfig = fs.readFileSync(WEBSITE_CONFIG_PATH);
8 | let websiteConfigJson = JSON.parse(websiteConfig.toString());
9 |
10 | websiteConfigJson = {
11 | ...websiteConfigJson,
12 | bundlemonServiceUrl: `${httpSchema}://${rootDomain}/api`,
13 | githubAppClientId,
14 | };
15 |
16 | fs.writeFileSync(WEBSITE_CONFIG_PATH, JSON.stringify(websiteConfigJson));
17 | }
18 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/validationUtils.ts:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 | import logger from '../../common/logger';
3 |
4 | export function validateYup(
5 | schema: yup.SchemaOf,
6 | value: unknown,
7 | configName: string
8 | ): yup.Asserts | undefined {
9 | try {
10 | return schema.validateSync(value, { abortEarly: false, strict: false });
11 | } catch (err) {
12 | let error = err;
13 |
14 | if (err instanceof yup.ValidationError) {
15 | error = err.errors;
16 | }
17 |
18 | logger.error(`Validation error in ${configName} config`, error);
19 | }
20 |
21 | return undefined;
22 | }
23 |
--------------------------------------------------------------------------------
/apps/service/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import { UserSessionData } from '@/types/auth';
2 | import { randomBytes } from 'crypto';
3 |
4 | export function generateRandomString(length = 10) {
5 | return randomBytes(length / 2).toString('hex');
6 | }
7 |
8 | export function generateRandomInt(min: number, max: number) {
9 | min = Math.ceil(min);
10 | max = Math.floor(max);
11 | return Math.floor(Math.random() * (max - min) + min);
12 | }
13 |
14 | export function generateUserSessionData(): UserSessionData {
15 | return {
16 | provider: 'github',
17 | name: generateRandomString(),
18 | auth: {
19 | token: generateRandomString(),
20 | },
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/apps/website/src/Router.tsx:
--------------------------------------------------------------------------------
1 | import { Routes, Route } from 'react-router-dom';
2 | import { HomePage, CreateProjectPage, ReportPage, ReportsPage, LoginPage } from '@/pages';
3 |
4 | const Router = () => (
5 |
6 | } />
7 |
8 |
9 | } />
10 | } />
11 |
12 |
13 | } />
14 | } />
15 |
16 | );
17 |
18 | export default Router;
19 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/getFileSize.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import { file as calcGzipFileSize } from 'gzip-size';
3 | import { file as calcBrotliFileSize } from 'brotli-size';
4 | import { Compression } from 'bundlemon-utils';
5 |
6 | export async function getFileSize(path: string, compression: Compression): Promise {
7 | switch (compression) {
8 | case Compression.Gzip: {
9 | return await calcGzipFileSize(path);
10 | }
11 | case Compression.Brotli: {
12 | return await calcBrotliFileSize(path);
13 | }
14 | case Compression.None:
15 | default:
16 | return (await fs.promises.readFile(path)).byteLength;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/apps/service/src/routes/index.ts:
--------------------------------------------------------------------------------
1 | import { apiPathPrefix } from '@/framework/env';
2 | import apiRoutes from './api';
3 |
4 | import type { FastifyPluginCallback } from 'fastify';
5 | import { getDB } from '@/framework/mongo/client';
6 |
7 | const routes: FastifyPluginCallback = (app, _opts, done) => {
8 | app.register(apiRoutes, { prefix: apiPathPrefix });
9 |
10 | app.get('/is-alive', (_req, reply) => {
11 | reply.send('OK');
12 | });
13 |
14 | app.get('/health', async (_req, reply) => {
15 | const db = await getDB();
16 | await db.admin().ping({ maxTimeMS: 5000 });
17 |
18 | reply.send('OK');
19 | });
20 |
21 | done();
22 | };
23 |
24 | export default routes;
25 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/lib/consts.ts:
--------------------------------------------------------------------------------
1 | export enum Compression {
2 | None = 'none',
3 | Gzip = 'gzip',
4 | Brotli = 'brotli',
5 | }
6 |
7 | export enum DiffChange {
8 | NoChange = 'No change',
9 | Update = 'Update',
10 | Add = 'Add',
11 | Remove = 'Remove',
12 | }
13 |
14 | export enum Status {
15 | Pass = 'Pass',
16 | Fail = 'Fail',
17 | }
18 |
19 | export enum FailReason {
20 | MaxSize = 'MaxSize',
21 | MaxPercentIncrease = 'MaxPercentIncrease',
22 | }
23 |
24 | export enum ProjectProvider {
25 | GitHub = 'github',
26 | }
27 |
28 | export enum CommitRecordReviewResolution {
29 | Approved = 'approved',
30 | Rejected = 'rejected',
31 | Reset = 'reset',
32 | }
33 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bundlemon-utils",
3 | "version": "2.0.1",
4 | "description": "",
5 | "keywords": [],
6 | "author": "Liron Er",
7 | "license": "MIT",
8 | "homepage": "https://github.com/LironEr/bundlemon.git",
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/LironEr/bundlemon.git",
12 | "directory": "packages/bundlemon-utils"
13 | },
14 | "engines": {
15 | "node": ">=18"
16 | },
17 | "main": "lib/index",
18 | "types": "lib/index.d.ts",
19 | "scripts": {},
20 | "dependencies": {
21 | "bytes": "^3.1.0"
22 | },
23 | "devDependencies": {
24 | "@types/bytes": "^3.1.0"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/common/__tests__/__snapshots__/consts.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`consts default snapshot 1`] = `
4 | {
5 | "CreateCommitRecordAuthType": {
6 | "GithubActions": "GITHUB_ACTIONS",
7 | "ProjectApiKey": "PROJECT_API_KEY",
8 | },
9 | "DEFAULT_PATH_LABELS": {
10 | "hash": "[a-zA-Z0-9]+",
11 | },
12 | "EnvVar": {
13 | "projectApiKey": "BUNDLEMON_PROJECT_APIKEY",
14 | "projectId": "BUNDLEMON_PROJECT_ID",
15 | "remoteFlag": "BUNDLEMON_REMOTE",
16 | "serviceURL": "BUNDLEMON_SERVICE_URL",
17 | "subProject": "BUNDLEMON_SUB_PROJECT",
18 | },
19 | "serviceUrl": "https://api.bundlemon.dev",
20 | }
21 | `;
22 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportHeader/components/ReviewRecord.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react-lite';
2 | import { CommitRecordReview } from 'bundlemon-utils';
3 | import { capitalize } from '@mui/material';
4 |
5 | interface ReviewRecordProps {
6 | review: CommitRecordReview;
7 | }
8 |
9 | const ReviewRecord = observer(
10 | ({
11 | review: {
12 | user: { name },
13 | createdAt,
14 | resolution,
15 | },
16 | }: ReviewRecordProps) => {
17 | return (
18 |
19 | {capitalize(resolution)} by {name} at {new Date(createdAt).toLocaleString()}
20 |
21 | );
22 | }
23 | );
24 |
25 | export default ReviewRecord;
26 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/utils.ts:
--------------------------------------------------------------------------------
1 | export const getEnvVar = (name: string): string | undefined => {
2 | const value = process.env[name];
3 |
4 | // Convert empty string to undefined
5 | return value || undefined;
6 | };
7 |
8 | type ObjectFromList> = {
9 | [K in T extends ReadonlyArray ? U : never]: string | undefined;
10 | };
11 |
12 | export function envVarsListToObject>(envVars: T): ObjectFromList {
13 | return envVars.reduce(
14 | (acc, envVar) => {
15 | acc[envVar] = getEnvVar(envVar);
16 | return acc;
17 | },
18 | {} as Record
19 | ) as ObjectFromList;
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | "strict": true,
5 | "rootDir": ".",
6 | "sourceMap": false,
7 | "declaration": false,
8 | "moduleResolution": "node",
9 | "emitDecoratorMetadata": true,
10 | "experimentalDecorators": true,
11 | "allowSyntheticDefaultImports": true,
12 | "esModuleInterop": true,
13 | "importHelpers": false,
14 | "allowJs": true,
15 | "target": "ES2022",
16 | "module": "CommonJS",
17 | "typeRoots": ["node_modules/@types"],
18 | "lib": ["ES2022"],
19 | "skipLibCheck": true,
20 | "skipDefaultLibCheck": true,
21 | "baseUrl": "."
22 | },
23 | "exclude": ["node_modules", "tmp"]
24 | }
25 |
--------------------------------------------------------------------------------
/apps/service/src/utils/projectUtils.ts:
--------------------------------------------------------------------------------
1 | import { ProjectProvider } from 'bundlemon-utils';
2 | import { Project, GitProject } from '../framework/mongo/projects';
3 | import type { FastifyBaseLogger } from 'fastify';
4 |
5 | export function isGitHubProject(project: Project, log: FastifyBaseLogger): project is GitProject {
6 | if (!('provider' in project && 'owner' in project && 'repo' in project)) {
7 | log.warn({ projectId: project.id }, 'project missing provider details');
8 | return false;
9 | }
10 | const { provider } = project;
11 |
12 | if (provider !== ProjectProvider.GitHub) {
13 | log.warn({ projectId: project.id }, 'project provider is not GitHub');
14 | return false;
15 | }
16 |
17 | return true;
18 | }
19 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: LironEr
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: lironer
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: ['https://www.paypal.com/donate?hosted_button_id=YZL6KM2UGB5ML']
--------------------------------------------------------------------------------
/apps/platform/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine
2 |
3 | ENV NODE_ENV=production
4 |
5 | WORKDIR /app
6 |
7 | RUN addgroup --system service && adduser --system -G service service
8 |
9 | # Needed by @fastify/secure-session & for source maps
10 | RUN set -ex; \
11 | apk add --no-cache --virtual .gyp \
12 | # Gyp build dependencies
13 | python3 make g++; \
14 | npm i sodium-native@4.2.0 source-map-support@0.5.21; \
15 | apk del .gyp
16 |
17 | COPY dist service
18 |
19 | RUN chown -R service:service .
20 |
21 | CMD [ "node", "-r", "source-map-support/register", "service/app.js" ]
22 |
23 | HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
24 | CMD sh -c "curl -f http://localhost:${PORT-8080}/is-alive || exit 1"
25 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bundlemon-markdown-output",
3 | "version": "2.0.1",
4 | "description": "",
5 | "keywords": [],
6 | "author": "Liron Er",
7 | "license": "MIT",
8 | "homepage": "https://github.com/LironEr/bundlemon.git",
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/LironEr/bundlemon.git",
12 | "directory": "packages/bundlemon-markdown-output"
13 | },
14 | "engines": {
15 | "node": ">=18"
16 | },
17 | "main": "lib/index",
18 | "types": "lib/index.d.ts",
19 | "scripts": {},
20 | "dependencies": {
21 | "bundlemon-utils": "^2.0.1",
22 | "bytes": "^3.1.0"
23 | },
24 | "devDependencies": {
25 | "@types/bytes": "^3.1.0"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/ci/providers/codefresh.ts:
--------------------------------------------------------------------------------
1 | import type { Provider } from '../types';
2 | import { getEnvVar } from '../../utils';
3 |
4 | // https://codefresh.io/docs/docs/codefresh-yaml/variables/#system-provided-variables
5 |
6 | const provider: Provider = {
7 | isItMe: !!getEnvVar('CF_BUILD_URL'),
8 | getVars: () => ({
9 | ci: true,
10 | provider: 'codefresh',
11 | owner: getEnvVar('CF_REPO_OWNER'),
12 | repo: getEnvVar('CF_REPO_NAME'),
13 | branch: getEnvVar('CF_BRANCH'),
14 | commitSha: getEnvVar('CF_REVISION'),
15 | targetBranch: getEnvVar('CF_PULL_REQUEST_TARGET'),
16 | prNumber: getEnvVar('CF_PULL_REQUEST_NUMBER'),
17 | commitMsg: getEnvVar('CF_COMMIT_MESSAGE'),
18 | }),
19 | };
20 |
21 | export default provider;
22 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/common/__tests__/consts.spec.ts:
--------------------------------------------------------------------------------
1 | import * as consts from '../consts';
2 |
3 | describe('consts', () => {
4 | const OLD_ENV = process.env;
5 |
6 | beforeEach(() => {
7 | jest.resetModules();
8 | process.env = { ...OLD_ENV };
9 | });
10 |
11 | afterAll(() => {
12 | process.env = OLD_ENV;
13 | });
14 |
15 | test('default snapshot', () => {
16 | const { version, ...rest } = consts;
17 |
18 | expect(rest).toMatchSnapshot();
19 | });
20 |
21 | test('serviceUrl env var exists', () => {
22 | const newUrl = 'https://my-bundlemon-service.com';
23 | process.env[consts.EnvVar.serviceURL] = newUrl;
24 |
25 | const { serviceUrl } = require('../consts');
26 |
27 | expect(serviceUrl).toEqual(newUrl);
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/apps/service/src/utils/hashUtils.ts:
--------------------------------------------------------------------------------
1 | import * as crypto from 'crypto';
2 |
3 | export async function createHash(secret: string): Promise {
4 | return new Promise((resolve, reject) => {
5 | const salt = crypto.randomBytes(8).toString('hex');
6 |
7 | crypto.scrypt(secret, salt, 64, (err, derivedKey) => {
8 | if (err) reject(err);
9 | resolve(salt + ':' + derivedKey.toString('hex'));
10 | });
11 | });
12 | }
13 |
14 | export async function verifyHash(secret: string, hash: string): Promise {
15 | return new Promise((resolve, reject) => {
16 | const [salt, key] = hash.split(':');
17 | crypto.scrypt(secret, salt, 64, (err, derivedKey) => {
18 | if (err) reject(err);
19 | resolve(key == derivedKey.toString('hex'));
20 | });
21 | });
22 | }
23 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/utils.ts:
--------------------------------------------------------------------------------
1 | import bytes from 'bytes';
2 | import { NormalizedConfig } from '../types';
3 |
4 | export function parseOutput(output: NormalizedConfig['reportOutput'][0]): { name: string; options: unknown } {
5 | if (Array.isArray(output)) {
6 | return { name: output[0], options: output[1] };
7 | }
8 |
9 | return { name: output, options: undefined };
10 | }
11 |
12 | export function getSignText(num: number): string {
13 | return num > 0 ? '+' : '';
14 | }
15 |
16 | export function getDiffSizeText(size: number): string {
17 | return `${getSignText(size)}${bytes(size)}`;
18 | }
19 |
20 | export function getDiffPercentText(percent: number): string {
21 | if (percent === Infinity) {
22 | return '';
23 | }
24 |
25 | return `${getSignText(percent)}${percent}%`;
26 | }
27 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: 'CodeQL'
2 |
3 | on:
4 | push:
5 | branches: [main, release/*, next]
6 | pull_request:
7 | branches: [main, release/*, next]
8 | schedule:
9 | - cron: '45 12 * * 4'
10 |
11 | jobs:
12 | analyze:
13 | name: Analyze
14 | runs-on: ubuntu-latest
15 | permissions:
16 | actions: read
17 | contents: read
18 | security-events: write
19 |
20 | steps:
21 | - name: Checkout repository
22 | uses: actions/checkout@v4
23 |
24 | - name: Initialize CodeQL
25 | uses: github/codeql-action/init@v3
26 | with:
27 | config-file: ./.github/codeql/codeql-config.yml
28 | languages: 'javascript-typescript'
29 |
30 | - name: Perform CodeQL Analysis
31 | uses: github/codeql-action/analyze@v3
32 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/lib/__tests__/__snapshots__/consts.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`consts snapshot 1`] = `
4 | {
5 | "CommitRecordReviewResolution": {
6 | "Approved": "approved",
7 | "Rejected": "rejected",
8 | "Reset": "reset",
9 | },
10 | "Compression": {
11 | "Brotli": "brotli",
12 | "Gzip": "gzip",
13 | "None": "none",
14 | },
15 | "DiffChange": {
16 | "Add": "Add",
17 | "NoChange": "No change",
18 | "Remove": "Remove",
19 | "Update": "Update",
20 | },
21 | "FailReason": {
22 | "MaxPercentIncrease": "MaxPercentIncrease",
23 | "MaxSize": "MaxSize",
24 | },
25 | "ProjectProvider": {
26 | "GitHub": "github",
27 | },
28 | "Status": {
29 | "Fail": "Fail",
30 | "Pass": "Pass",
31 | },
32 | }
33 | `;
34 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/common/consts.ts:
--------------------------------------------------------------------------------
1 | import { PathLabels } from '../main/types';
2 |
3 | export enum EnvVar {
4 | remoteFlag = 'BUNDLEMON_REMOTE',
5 | projectId = 'BUNDLEMON_PROJECT_ID',
6 | projectApiKey = 'BUNDLEMON_PROJECT_APIKEY',
7 | serviceURL = 'BUNDLEMON_SERVICE_URL',
8 | subProject = 'BUNDLEMON_SUB_PROJECT',
9 | }
10 |
11 | export const serviceUrl = process.env[EnvVar.serviceURL] || 'https://api.bundlemon.dev';
12 |
13 | // eslint-disable-next-line @typescript-eslint/no-var-requires
14 | const packageJSON = require('../../package.json');
15 |
16 | export const version = packageJSON.version;
17 |
18 | export enum CreateCommitRecordAuthType {
19 | ProjectApiKey = 'PROJECT_API_KEY',
20 | GithubActions = 'GITHUB_ACTIONS',
21 | }
22 |
23 | export const DEFAULT_PATH_LABELS: PathLabels = {
24 | hash: '[a-zA-Z0-9]+',
25 | };
26 |
--------------------------------------------------------------------------------
/apps/service/src/framework/mongo/init.ts:
--------------------------------------------------------------------------------
1 | import { FastifyBaseLogger } from 'fastify';
2 | import { getDB } from './client';
3 | import { getCommitRecordsCollection } from './commitRecords';
4 |
5 | export async function initDb(logger: FastifyBaseLogger) {
6 | logger.info('Initializing DB indexes');
7 | const db = await getDB();
8 |
9 | await db.admin().ping({ maxTimeMS: 5000 });
10 |
11 | const commitRecordCol = await getCommitRecordsCollection();
12 |
13 | commitRecordCol.createIndex({ projectId: 1, subProject: 1, branch: 1, creationDate: -1 });
14 |
15 | // TTL index - remove commit records on PRs after 30 days
16 | commitRecordCol.createIndex(
17 | { creationDate: 1 },
18 | { expireAfterSeconds: 60 * 60 * 24 * 30, partialFilterExpression: { prNumber: { $exists: true } } }
19 | );
20 |
21 | logger.info('DB indexes initialized');
22 | }
23 |
--------------------------------------------------------------------------------
/docs/self-hosted/README.md:
--------------------------------------------------------------------------------
1 | # Self hosted BundleMon platform
2 |
3 | ## Running with Docker
4 |
5 | ```sh
6 | docker run --rm -p 8080:8080 -e MONGO_URL="mongodb://localhost:27017" ghcr.io/lironer/bundlemon-platform:v1
7 | ```
8 |
9 | Full Docker compose example [here](./docker-compose.yaml).
10 |
11 | You can also run with env var `SHOULD_SERVE_WEBSITE=false` to disable serving the website.
12 |
13 | **Full details on all environment variables [here](../../apps/service/README.md).**
14 |
15 | ## MongoDB setup
16 |
17 | Create your MongoDB, you can either create one in docker, your own machine or host on another service like MongoDB Atlas.
18 |
19 | > you can create a free MongoDB [here](https://www.mongodb.com) (500 MB free).
20 |
21 | ## Setup BundleMon CLI
22 |
23 | Set env var `BUNDLEMON_SERVICE_URL=https://your-bundlemon-service.domain/api` in your CI/CD.
24 |
--------------------------------------------------------------------------------
/apps/website/src/components/PathCell.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { textEllipsis } from '@/utils/textUtils';
3 |
4 | import type { FileDetailsDiff } from 'bundlemon-utils';
5 | import type { PathRecord } from '@/pages/ReportsPage/components/types';
6 |
7 | const Container = styled.div`
8 | max-width: 300px;
9 | `;
10 |
11 | interface PathCellProps {
12 | file: FileDetailsDiff | PathRecord;
13 | }
14 |
15 | const PathCell = ({ file }: PathCellProps) => {
16 | const path = {textEllipsis(file.path, 45)} ;
17 |
18 | return (
19 |
20 | {file.friendlyName ? (
21 | <>
22 | {file.friendlyName}
23 |
24 | {path}
25 | >
26 | ) : (
27 | path
28 | )}
29 |
30 | );
31 | };
32 |
33 | export default PathCell;
34 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/TabTitle.tsx:
--------------------------------------------------------------------------------
1 | import { Stack, Tab, Tooltip, TabProps } from '@mui/material';
2 |
3 | import FailIcon from '@mui/icons-material/Close';
4 |
5 | interface TabTitleProps extends TabProps {
6 | value: string;
7 | label: string;
8 | failsCount: number;
9 | }
10 |
11 | const TabTitle = ({ value, label, failsCount, ...tabProps }: TabTitleProps) => {
12 | return (
13 |
17 | {label}
18 | {failsCount > 0 && (
19 |
20 |
21 |
22 | )}
23 |
24 | }
25 | value={value}
26 | />
27 | );
28 | };
29 |
30 | export default TabTitle;
31 |
--------------------------------------------------------------------------------
/docs/types.md:
--------------------------------------------------------------------------------
1 | # Types
2 |
3 | ## File
4 |
5 | #### `path`
6 |
7 | type: `string` **required**
8 |
9 | relative path to the file from `baseDir`
10 |
11 | glob pattern is supported
12 |
13 | ```
14 | "js/*.js"
15 | "**/*.css"
16 | "**/*.{js,css}"
17 | "**/*..js"
18 | ```
19 |
20 | #### `friendlyName`
21 |
22 | type: `string` optional
23 |
24 | friendly name for the path pattern option
25 |
26 | #### `compression`
27 |
28 | value: `"none"` \| `"gzip"` optional
29 |
30 | override default compression
31 |
32 | #### `maxSize`
33 |
34 | type: `string` optional
35 |
36 | max size allowed for match file/files
37 |
38 | ```
39 | "2000b"
40 | "20kb"
41 | "1mb"
42 | ```
43 |
44 | #### `maxPercentIncrease`
45 |
46 | type: `number` optional
47 |
48 | max percent increase allowed for match file/files from base branch
49 |
50 | ```
51 | 0.5 = 0.5%
52 | 4 = 4%
53 | 200 = 200%
54 | ```
55 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/cli/configFile.ts:
--------------------------------------------------------------------------------
1 | import { cosmiconfig } from 'cosmiconfig';
2 | import logger from '../common/logger';
3 | import type { Config } from '../main/types';
4 |
5 | const explorer = cosmiconfig('bundlemon');
6 |
7 | export async function loadConfigFile(configPath?: string): Promise {
8 | if (configPath) {
9 | logger.debug(`Load config file from "${configPath}"`);
10 | }
11 |
12 | try {
13 | const cosmiconfigResult = await (configPath ? explorer.load(configPath) : explorer.search());
14 |
15 | if (!cosmiconfigResult || cosmiconfigResult.isEmpty) {
16 | return undefined;
17 | }
18 |
19 | logger.debug(`Config file loaded from "${cosmiconfigResult.filepath}"`);
20 |
21 | return cosmiconfigResult.config;
22 | } catch (e) {
23 | logger.error(`Error loading config file: ${e}`);
24 | return undefined;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/components/ReportsChart/utils.ts:
--------------------------------------------------------------------------------
1 | import bytes from 'bytes';
2 |
3 | import type { CommitRecord } from 'bundlemon-utils';
4 |
5 | export const stringToColor = function (str: string): string {
6 | let hash = 0;
7 |
8 | for (let i = 0; i < str.length; i++) {
9 | hash = str.charCodeAt(i) + ((hash << 5) - hash);
10 | }
11 |
12 | let color = '#';
13 | for (let i = 0; i < 3; i++) {
14 | const value = (hash >> (i * 8)) & 0xff;
15 | color += ('00' + value.toString(16)).substr(-2);
16 | }
17 | return color;
18 | };
19 |
20 | export const getVal =
21 | (path: string, type: 'files' | 'groups') =>
22 | (value: CommitRecord): number | undefined =>
23 | value[type].find((f) => f.path === path)?.size;
24 |
25 | export const bytesTickFormatter = (value: number) => bytes(value);
26 | export const dateTickFormatter = (value: string) => new Date(value).toLocaleDateString();
27 |
--------------------------------------------------------------------------------
/apps/website/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | BundleMon
6 |
7 |
8 |
9 |
10 |
11 |
12 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/apps/website/src/components/Layout/components/ThemeModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { IconButton, Tooltip } from '@mui/material';
3 | import DarkModeOutlined from '@mui/icons-material/DarkModeOutlined';
4 | import LightModeOutlined from '@mui/icons-material/LightModeOutlined';
5 | import { ThemeContext } from '@/components/ThemeProvider';
6 |
7 | const ThemeModeToggle = () => {
8 | const { isDarkMode, setDarkMode } = useContext(ThemeContext);
9 |
10 | return (
11 |
12 | {
16 | setDarkMode(!isDarkMode);
17 | }}
18 | >
19 | {isDarkMode ? : }
20 |
21 |
22 | );
23 | };
24 |
25 | export default ThemeModeToggle;
26 |
--------------------------------------------------------------------------------
/apps/service/tests/app.ts:
--------------------------------------------------------------------------------
1 | import { UserSessionData } from '@/types/auth';
2 | import { FastifyInstance, InjectOptions } from 'fastify';
3 | import { generateUserSessionData } from './utils';
4 | import initApp from '@/app';
5 |
6 | export async function createTestApp() {
7 | const app = await initApp({ isServerless: false });
8 | await app.ready();
9 |
10 | return app;
11 | }
12 |
13 | export async function injectAuthorizedRequest(
14 | app: FastifyInstance,
15 | injectOptions: InjectOptions,
16 | overrideUserData?: UserSessionData
17 | ) {
18 | const userSessionData = overrideUserData ?? generateUserSessionData();
19 |
20 | const session = app.createSecureSession({ user: userSessionData });
21 | const cookieData = app.encodeSecureSession(session);
22 |
23 | const response = await app.inject({
24 | ...injectOptions,
25 | cookies: {
26 | ...injectOptions.cookies,
27 | session: cookieData,
28 | },
29 | });
30 |
31 | return response;
32 | }
33 |
--------------------------------------------------------------------------------
/docs/customPathLabels.md:
--------------------------------------------------------------------------------
1 | # Custom Path Labels
2 |
3 | By default path labels replace only ``, but you can customize it and add more labels by defining `pathLabels` in your config.
4 |
5 | ### Default labels:
6 |
7 | ```json
8 | {
9 | "pathLabels": {
10 | "hash": "[a-zA-Z0-9]+"
11 | }
12 | }
13 | ```
14 |
15 | ### Adding more labels:
16 |
17 | ```json
18 | {
19 | "baseDir": "./build",
20 | "pathLabels": {
21 | "chunkId": "[\\w-]+"
22 | },
23 | "files": [
24 | {
25 | "path": "*..chunk..js"
26 | },
27 | {
28 | "path": "*..js"
29 | }
30 | ]
31 | }
32 | ```
33 |
34 | ### Customizing and adding more labels:
35 |
36 | ```json
37 | {
38 | "baseDir": "./build",
39 | "pathLabels": {
40 | "hash": "[a-z]+",
41 | "chunkId": "[\\w-]+"
42 | },
43 | "files": [
44 | {
45 | "path": "*..chunk..js"
46 | },
47 | {
48 | "path": "*..js"
49 | }
50 | ]
51 | }
52 | ```
53 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportTable/components/ChangeSizeCell.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { FileDetailsDiff, DiffChange, FailReason } from 'bundlemon-utils';
3 | import { getDiffPercentText, getDiffSizeText } from 'bundlemon-utils';
4 |
5 | const Text = styled.span<{ $bold?: boolean }>`
6 | font-weight: ${({ $bold }) => ($bold ? '700' : '400')};
7 | `;
8 |
9 | interface ChangeSizeCellProps {
10 | file: FileDetailsDiff;
11 | }
12 |
13 | const ChangeSizeCell = ({ file }: ChangeSizeCellProps) => {
14 | if (file.diff.change !== DiffChange.Update) {
15 | return - ;
16 | }
17 |
18 | return (
19 | <>
20 | {getDiffSizeText(file.diff.bytes)} |{' '}
21 |
22 | {getDiffPercentText(file.diff.percent)}
23 |
24 | >
25 | );
26 | };
27 |
28 | export default ChangeSizeCell;
29 |
--------------------------------------------------------------------------------
/apps/service/src/types/schemas/common.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | import type {
4 | RawReplyDefaultExpression,
5 | RawRequestDefaultExpression,
6 | RawServerDefault,
7 | RouteHandlerMethod,
8 | } from 'fastify';
9 |
10 | export interface BaseRequestSchema {
11 | body?: unknown;
12 | query?: unknown;
13 | params?: unknown;
14 | headers?: unknown;
15 | }
16 |
17 | export interface BaseGetRequestSchema {
18 | query?: unknown;
19 | params?: unknown;
20 | headers?: unknown;
21 | }
22 |
23 | export type FastifyValidatedRoute = RouteHandlerMethod<
24 | RawServerDefault,
25 | RawRequestDefaultExpression,
26 | RawReplyDefaultExpression,
27 | {
28 | Body: RouteGeneric['body'];
29 | Querystring: RouteGeneric['query'];
30 | Params: RouteGeneric['params'];
31 | Headers: RouteGeneric['headers'];
32 | }
33 | >;
34 |
35 | export interface ProjectIdParams {
36 | /**
37 | * @pattern ^[0-9a-fA-F]{24}$
38 | */
39 | projectId: string;
40 | }
41 |
--------------------------------------------------------------------------------
/apps/service/src/local-certs/cert.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIICmDCCAYACCQCKcUaw+AIiojANBgkqhkiG9w0BAQUFADANMQswCQYDVQQGEwJV
3 | UzAgFw0yMjA5MTUyMTMxMDNaGA8yMDUwMDEzMDIxMzEwM1owDTELMAkGA1UEBhMC
4 | VVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtukc5UCM/hx5iqpXB
5 | ZrGs0eu6c3RcxbNcaP7MbUhppku8BjG1dtZAm6EFoMrfAQNH2+NlMiRKvcNGQEgA
6 | xuDVerKWD9+IS0FnfoOgETUZ4RN2we0qIxuh7tPG2Ad8OMaVWj0uUrYT2f5zvVPx
7 | zdZZLMPpNFDPC93/pMdsxgs+8JKb9yNVjsWsjnuxdYC+lHcN49dHYSyxT+vCQsNw
8 | Ig9n0PJ/ZUZDroEgIXqpeu6k1q4VtHFbA18HsegAW4V1XkmcgEBijklZfgeH41FC
9 | Q/ITKfAw/bib6YJIsv2BrjTglL1Ox6MqABP4+mBuv9p7M/8L0T9Yt4uE3YKwdLpB
10 | t9xpAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAJj7v+5FyN0nT1eBF6gty9OdeTpE
11 | qWNoloAkzcwDNni679/LLGjDv0alHdjnvw3PpwukGayTrIkZIBTqq3fqWbK7qR+a
12 | 8nWhEsLJ6mGGW3Hq4hBMOTO+n1RHptnLVE/lDPMfnJsQ4Tl+4nLQ94232itUdoU1
13 | nR7Y0UTw7XZvmWpN0Ux9P3pSvP8c4Eq31uJEXScQgCAUh65+472f764Dkb91Many
14 | A/CGtCG7tPZCw9QXirOsWyw6piHyex3mdTMdIw6vhbckwKZX9MF7ANfH2uvAcWCs
15 | NsmrP3v7se9XiaTI/8RnC3RZHYFPYyPo0JLFP2vnQchy0ss6oUJPG/RjI2o=
16 | -----END CERTIFICATE-----
17 |
--------------------------------------------------------------------------------
/apps/service/src/utils/linkUtils.ts:
--------------------------------------------------------------------------------
1 | import { appDomain } from '../framework/env';
2 | import { URLSearchParams } from 'url';
3 | import { CommitRecordsQueryResolution } from '../consts/commitRecords';
4 |
5 | interface GenerateLinkToReport {
6 | projectId: string;
7 | commitRecordId: string;
8 | }
9 |
10 | export function generateLinkToReport({ projectId, commitRecordId }: GenerateLinkToReport) {
11 | return `https://${appDomain}/projects/${projectId}/reports/${commitRecordId}`;
12 | }
13 |
14 | export interface GenerateLinkToReportskParams {
15 | projectId: string;
16 | subProject?: string;
17 | branch: string;
18 | resolution: CommitRecordsQueryResolution;
19 | }
20 |
21 | export function generateLinkToReports({ projectId, subProject, branch, resolution }: GenerateLinkToReportskParams) {
22 | const query = new URLSearchParams({ branch, resolution });
23 |
24 | if (subProject) {
25 | query.append('subProject', subProject);
26 | }
27 |
28 | return `https://${appDomain}/projects/${projectId}/reports?${query.toString()}`;
29 | }
30 |
--------------------------------------------------------------------------------
/apps/website/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { defineConfig } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 | import tsconfigPaths from 'vite-tsconfig-paths';
5 | import basicSsl from '@vitejs/plugin-basic-ssl';
6 | import { analyzer } from 'vite-bundle-analyzer';
7 |
8 | export default defineConfig({
9 | root: __dirname,
10 | cacheDir: '../../node_modules/.vite/apps/website',
11 | publicDir: path.join(__dirname, 'public'),
12 | server: {
13 | port: 4000,
14 | host: 'localhost',
15 | },
16 | preview: {
17 | port: 4000,
18 | host: 'localhost',
19 | },
20 | plugins: [tsconfigPaths(), react(), basicSsl(), analyzer({ analyzerMode: 'static' })],
21 | build: {
22 | outDir: '../../dist/apps/website',
23 | emptyOutDir: true,
24 | reportCompressedSize: true,
25 | commonjsOptions: {
26 | transformMixedEsModules: true,
27 | },
28 | rollupOptions: {
29 | output: {
30 | entryFileNames: `assets/Main-[name]-[hash].js`,
31 | },
32 | },
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/lib/markdownUtils.ts:
--------------------------------------------------------------------------------
1 | export function escapeMarkdown(str: string): string {
2 | return str.replace(/[~|]/g, '\\$&');
3 | }
4 |
5 | interface FormatTextOptions {
6 | bold?: boolean;
7 | }
8 |
9 | export function formatText(text: string, { bold = false }: FormatTextOptions) {
10 | if (bold) {
11 | return `**${text}**`;
12 | }
13 |
14 | return text;
15 | }
16 |
17 | interface Column {
18 | label: string;
19 | center?: boolean;
20 | }
21 |
22 | interface GenerateTableParams {
23 | columns: Column[];
24 | rows: string[][];
25 | }
26 |
27 | export function generateMarkdownTable({ columns, rows }: GenerateTableParams) {
28 | let table = '';
29 |
30 | table += columns
31 | .map((column) => column.label)
32 | .join(' | ')
33 | .concat('\n');
34 | table += columns
35 | .map((column) => (column.center ? ':------------:' : '------------'))
36 | .join(' | ')
37 | .concat('\n');
38 | table += rows
39 | .map((row) => row.join(' | '))
40 | .join('\n')
41 | .concat('\n');
42 |
43 | return table;
44 | }
45 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/analyzeLocalFiles.ts:
--------------------------------------------------------------------------------
1 | import { getAllPaths } from './pathUtils';
2 | import { getFilesDetails, groupFilesByPattern } from './fileDetailsUtils';
3 | import logger from '../../common/logger';
4 |
5 | import type { FileDetails } from 'bundlemon-utils';
6 | import type { NormalizedConfig } from '../types';
7 |
8 | export async function analyzeLocalFiles(
9 | config: NormalizedConfig
10 | ): Promise<{ files: FileDetails[]; groups: FileDetails[] }> {
11 | logger.info(`Start analyzing`);
12 |
13 | const { baseDir, files: filesConfig, groups: groupsConfig, pathLabels } = config;
14 |
15 | const allFiles = await getAllPaths(config.baseDir);
16 |
17 | const [files, groupFiles] = await Promise.all([
18 | getFilesDetails({ baseDir, allFiles, pathLabels, config: filesConfig, stopOnMatch: true }),
19 | getFilesDetails({ baseDir, allFiles, pathLabels, config: groupsConfig, stopOnMatch: false }),
20 | ]);
21 |
22 | const groups = groupFilesByPattern(groupFiles);
23 |
24 | logger.info(`Finished analyzing`);
25 |
26 | return { files, groups };
27 | }
28 |
--------------------------------------------------------------------------------
/apps/platform/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "platform",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "apps/platform",
5 | "projectType": "application",
6 | "tags": [],
7 | "targets": {
8 | "prepare-image": {
9 | "dependsOn": ["service:build", "website:build"],
10 | "command": "rm -rf {projectRoot}/dist && cp -r apps/service/dist {projectRoot}/dist && cp -r dist/apps/website/* {projectRoot}/dist/public"
11 | },
12 | "build-image": {
13 | "dependsOn": ["prepare-image"],
14 | "command": "docker build -f {projectRoot}/Dockerfile {projectRoot} -t bundlemon-platform"
15 | },
16 | "lint": {
17 | "executor": "@nx/eslint:lint",
18 | "outputs": ["{options.outputFile}"],
19 | "options": {
20 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,js,json}"],
21 | "maxWarnings": 0
22 | }
23 | },
24 | "test": {
25 | "executor": "@nx/jest:jest",
26 | "options": {
27 | "jestConfig": "{projectRoot}/jest.config.ts",
28 | "runInBand": true
29 | }
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 LironEr
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 all
13 | 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 THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/components/ReportsChart/components/ColorCell.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import styled from '@emotion/styled';
3 |
4 | const IconWrapper = styled.svg`
5 | display: inline-block;
6 | vertical-align: middle;
7 | margin-right: 4px;
8 | `;
9 |
10 | interface ColorCellProps {
11 | color: string;
12 | }
13 |
14 | const ColorCell = memo(
15 | ({ color }: ColorCellProps) => {
16 | return (
17 |
18 |
28 |
29 | );
30 | },
31 | (prevProps, nextProps) => prevProps.color === nextProps.color
32 | );
33 |
34 | ColorCell.displayName = 'ColorCell';
35 |
36 | export default ColorCell;
37 |
--------------------------------------------------------------------------------
/apps/website/src/components/Table/Table.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react-lite';
2 | import MaterialReactTable, { MaterialReactTableProps } from 'material-react-table';
3 |
4 | interface TableProps = Record> extends MaterialReactTableProps {
5 | maxHeight?: number;
6 | }
7 |
8 | const Table = observer(
9 | = Record>({ maxHeight, initialState, ...rest }: TableProps) => {
10 | return (
11 |
30 | );
31 | }
32 | );
33 |
34 | export default Table;
35 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/index.ts:
--------------------------------------------------------------------------------
1 | import { Report, Status, getReportConclusionText } from 'bundlemon-utils';
2 | import logger from '../common/logger';
3 | import { analyzeLocalFiles } from './analyzer';
4 | import { generateOutputs } from './outputs';
5 | import { generateReport } from './report';
6 | import { initializer } from './initializer';
7 | import type { Config } from './types';
8 |
9 | export default async (config: Config): Promise => {
10 | const normalizedConfig = await initializer(config);
11 |
12 | if (!normalizedConfig) {
13 | throw new Error('Failed to initialize');
14 | }
15 |
16 | const { files, groups } = await analyzeLocalFiles(normalizedConfig);
17 |
18 | if (files.length === 0 && groups.length === 0) {
19 | throw new Error('No files or groups found');
20 | }
21 |
22 | const report = await generateReport(normalizedConfig, { files, groups });
23 |
24 | if (!report) {
25 | throw new Error('Failed to generate report');
26 | }
27 |
28 | await generateOutputs(report);
29 |
30 | logger.info(`Done - ${report.status === Status.Pass ? 'Success' : 'Failure'} - ${getReportConclusionText(report)}`);
31 |
32 | return report;
33 | };
34 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/ci/providers/circleCI.ts:
--------------------------------------------------------------------------------
1 | import type { Provider } from '../types';
2 | import { envVarsListToObject, getEnvVar } from '../../utils';
3 |
4 | // https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables
5 |
6 | const provider: Provider = {
7 | isItMe: getEnvVar('CIRCLECI') === 'true',
8 | getVars: () => {
9 | const raw = envVarsListToObject([
10 | 'CIRCLE_PROJECT_USERNAME',
11 | 'CIRCLE_PROJECT_REPONAME',
12 | 'CIRCLE_BRANCH',
13 | 'CIRCLE_SHA1',
14 | 'CIRCLE_PULL_REQUEST',
15 | ] as const);
16 |
17 | return {
18 | raw,
19 | ci: true,
20 | provider: 'circleci',
21 | owner: raw.CIRCLE_PROJECT_USERNAME,
22 | repo: raw.CIRCLE_PROJECT_REPONAME,
23 | branch: raw.CIRCLE_BRANCH,
24 | commitSha: raw.CIRCLE_SHA1,
25 | // target branch not available in CircleCI
26 | // https://ideas.circleci.com/cloud-feature-requests/p/provide-env-variable-for-branch-name-targeted-by-pull-request
27 | // use CI_TARGET_BRANCH to override
28 | targetBranch: undefined,
29 | prNumber: raw.CIRCLE_PULL_REQUEST?.split('/').pop(),
30 | };
31 | },
32 | };
33 |
34 | export default provider;
35 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/lib/__tests__/markdownUtils.spec.ts:
--------------------------------------------------------------------------------
1 | import { escapeMarkdown, formatText } from '../markdownUtils';
2 |
3 | describe('markdown utils', () => {
4 | describe('escapeMarkdown', () => {
5 | test.each(['text', '**/*..js'])('no escape', (str) => {
6 | const result = escapeMarkdown(str);
7 |
8 | expect(result).toBe(str);
9 | });
10 |
11 | test.each([
12 | { str: 'some~text~2', expected: 'some\\~text\\~2' },
13 | { str: '*.js | *.png', expected: '*.js \\| *.png' },
14 | { str: 'text~text | file', expected: 'text\\~text \\| file' },
15 | ])('escape', ({ str, expected }) => {
16 | const result = escapeMarkdown(str);
17 |
18 | expect(result).toBe(expected);
19 | });
20 | });
21 |
22 | describe('formatText', () => {
23 | test.each([
24 | { str: 'text', options: {}, expected: 'text' },
25 | { str: 'text', options: { bold: false }, expected: 'text' },
26 | { str: 'text', options: { bold: true }, expected: '**text**' },
27 | ])('options: $options', ({ str, options, expected }) => {
28 | const result = formatText(str, options);
29 |
30 | expect(result).toBe(expected);
31 | });
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/apps/service/src/framework/mongo/commitRecords/types.ts:
--------------------------------------------------------------------------------
1 | import type { CommitRecordReview, Compression, CommitRecord } from 'bundlemon-utils';
2 |
3 | export interface CommitRecordReviewDB extends Omit {
4 | createdAt: Date;
5 | }
6 |
7 | export interface AssetLimits {
8 | maxSize?: number;
9 | maxPercentIncrease?: number;
10 | }
11 |
12 | export interface AssetMatchCriteria {
13 | pattern: string;
14 | friendlyName?: string;
15 | compression: Compression;
16 | limits?: AssetLimits;
17 | }
18 |
19 | export interface AssetMatch {
20 | path: string;
21 | size: number;
22 | }
23 |
24 | export interface WatchedFileHits extends AssetMatchCriteria {
25 | matches: AssetMatch[];
26 | }
27 |
28 | export interface WatchedGroupHits extends AssetMatchCriteria, Omit {}
29 |
30 | export interface CommitRecordDB {
31 | projectId: string;
32 | creationDate: Date;
33 | subProject?: string;
34 | branch: string;
35 | commitSha: string;
36 | baseBranch?: string;
37 | prNumber?: string;
38 | commitMsg?: string;
39 | files?: WatchedFileHits[];
40 | groups?: WatchedGroupHits[];
41 | reviews?: CommitRecordReviewDB[];
42 | outputs?: CommitRecord['outputs'];
43 | }
44 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/components/SubprojectsAutocomplete.tsx:
--------------------------------------------------------------------------------
1 | import { Autocomplete, TextField } from '@mui/material';
2 | import { useQuery } from 'react-query';
3 | import { getSubprojects } from '@/services/bundlemonService';
4 | import FetchError from '@/services/FetchError';
5 | import { observer } from 'mobx-react-lite';
6 |
7 | interface SubprojectsAutocompleteProps {
8 | projectId: string;
9 | value?: string;
10 | setValue: (v: string | undefined) => void;
11 | }
12 |
13 | const SubprojectsAutocomplete = observer(({ projectId, value, setValue }: SubprojectsAutocompleteProps) => {
14 | const { isLoading, data: subProjects } = useQuery(['projects', projectId, 'subprojects'], () =>
15 | getSubprojects(projectId)
16 | );
17 |
18 | return (
19 | }
24 | value={value}
25 | onChange={(_e, newValue) => {
26 | setValue(newValue || undefined);
27 | }}
28 | loadingText="Loading..."
29 | />
30 | );
31 | });
32 |
33 | export default SubprojectsAutocomplete;
34 |
--------------------------------------------------------------------------------
/apps/service/src/routes/api/__tests__/usersRoutes.spec.ts:
--------------------------------------------------------------------------------
1 | import { createTestApp, injectAuthorizedRequest } from '@tests/app';
2 | import { generateUserSessionData } from '@tests/utils';
3 | import { FastifyInstance } from 'fastify';
4 |
5 | describe('users routes', () => {
6 | let app: FastifyInstance;
7 |
8 | beforeAll(async () => {
9 | app = await createTestApp();
10 | });
11 |
12 | describe('me', () => {
13 | test('user not logged in', async () => {
14 | const response = await app.inject({
15 | method: 'GET',
16 | url: `/v1/users/me`,
17 | });
18 |
19 | expect(response.statusCode).toEqual(401);
20 | });
21 |
22 | test('success', async () => {
23 | const userSessionData = generateUserSessionData();
24 |
25 | const response = await injectAuthorizedRequest(
26 | app,
27 | {
28 | method: 'GET',
29 | url: `/v1/users/me`,
30 | },
31 | userSessionData
32 | );
33 |
34 | expect(response.statusCode).toEqual(200);
35 |
36 | const responseJson = response.json();
37 |
38 | expect(responseJson).toEqual({
39 | provider: userSessionData.provider,
40 | name: userSessionData.name,
41 | });
42 | });
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/apps/service/scripts/deleteProjectRecords.ts:
--------------------------------------------------------------------------------
1 | // Delete branches that have not been active (pushed new commit records) for x days.
2 |
3 | import { closeMongoClient } from '@/framework/mongo/client';
4 | import { getCommitRecordsCollection } from '@/framework/mongo/commitRecords';
5 |
6 | (async () => {
7 | try {
8 | const projectId = '';
9 | const commitRecordsCollection = await getCommitRecordsCollection();
10 | const recordsCount = await commitRecordsCollection.countDocuments({ projectId });
11 |
12 | console.log(`Total records to delete: ${recordsCount}`);
13 |
14 | console.log('Are you sure you want to delete all these records? (y/n)');
15 | const answer = await new Promise((resolve) => {
16 | process.stdin.on('data', (data) => {
17 | resolve(data.toString().trim());
18 | });
19 | });
20 |
21 | if (answer !== 'y') {
22 | console.log('Abort');
23 | process.exit(0);
24 | }
25 |
26 | console.log('Deleting records...');
27 |
28 | const result = await commitRecordsCollection.deleteMany({
29 | projectId,
30 | });
31 |
32 | console.log(`Deleted records: ${result.deletedCount}`);
33 |
34 | process.exit(0);
35 | } finally {
36 | await closeMongoClient();
37 | }
38 | })();
39 |
--------------------------------------------------------------------------------
/docs/migration-v1-to-v2.md:
--------------------------------------------------------------------------------
1 | # Migration guide from v1 to v2
2 |
3 | - Upgrade node version to at least v14
4 |
5 | - If you are using GitHub actions you don't need to set project ID and API key anymore, make sure [BundleMon GitHub App](https://github.com/apps/bundlemon) is installed.
6 |
7 | - BundleMon will automatically create a new project linked to your repo on GitHub. If you want to keep your current project history you can add your details [here](https://github.com/LironEr/bundlemon/issues/125) or send me an email (lironerm@gmail.com) with the owner and repo name and your current project id.
8 |
9 | - If you are using a different CI provider (Travis, CircleCI, etc) you must provide a project API key. **From now on you will need to provide a GitHub access token** if you want to integrate with GitHub (post commit status / pr comment).
10 |
11 | - [Create GitHub access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) without any scopes, the token owner must have write permission to the repo you would want to post outputs to.
12 |
13 | - Add the token to `BUNDLEMON_GITHUB_TOKEN` environment variable in your CI.
14 |
15 | > The token is not saved in BundleMon service, ONLY used to verify the username that created the token.
16 |
--------------------------------------------------------------------------------
/apps/platform/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bundlemon-platform",
3 | "version": "1.0.1",
4 | "private": true,
5 | "license": "MIT",
6 | "scripts": {
7 | "build:image": "yarn --cwd ../../ nx build-image platform --verbose",
8 | "test": "yarn --cwd ../../ nx test platform --verbose",
9 | "lint": "yarn --cwd ../../ nx lint platform --verbose",
10 | "start:mock-services": "docker compose -f ../service/docker-compose.test.yml up --remove-orphans",
11 | "stop:mock-services": "docker compose -f ../service/docker-compose.test.yml down",
12 | "start:base-platform": "docker run --rm -d --env-file ../service/.development.env -e MONGO_DB_NAME=test -e MONGO_URL=mongodb://host.docker.internal:51651 -e SHOULD_RUN_DB_INIT=false",
13 | "start:platform": "yarn start:base-platform --name bundlemon-platform -e SHOULD_SERVE_WEBSITE=true -e ROOT_DOMAIN=localhost:3333 -p 3333:8080 bundlemon-platform",
14 | "stop:platform": "docker stop bundlemon-platform",
15 | "start:platform-no-website": "yarn start:base-platform --name bundlemon-platform-no-website -e SHOULD_SERVE_WEBSITE=false -e ROOT_DOMAIN=localhost:4444 -p 4444:8080 bundlemon-platform",
16 | "stop:platform-no-website": "docker stop bundlemon-platform-no-website"
17 | },
18 | "dependencies": {},
19 | "devDependencies": {}
20 | }
21 |
--------------------------------------------------------------------------------
/packages/bundlemon/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bundlemon",
3 | "version": "3.1.0",
4 | "description": "Monitor your bundle size",
5 | "keywords": [
6 | "bundle",
7 | "size",
8 | "bundlesize",
9 | "monitor"
10 | ],
11 | "engines": {
12 | "node": ">=18"
13 | },
14 | "author": "Liron Er",
15 | "funding": "https://github.com/sponsors/LironEr",
16 | "license": "MIT",
17 | "homepage": "https://github.com/LironEr/bundlemon.git",
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/LironEr/bundlemon.git"
21 | },
22 | "main": "lib/index",
23 | "types": "lib/index.d.ts",
24 | "bin": {
25 | "bundlemon": "bin/bundlemon.js"
26 | },
27 | "scripts": {
28 | "bundlemon": "node -r @swc-node/register ./bin/bundlemon.ts"
29 | },
30 | "dependencies": {
31 | "axios": "^1.8.2",
32 | "axios-retry": "^4.5.0",
33 | "brotli-size": "^4.0.0",
34 | "bundlemon-utils": "^2.0.1",
35 | "bytes": "^3.1.2",
36 | "chalk": "^4.0.0",
37 | "commander": "^11.1.0",
38 | "cosmiconfig": "^8.3.6",
39 | "gzip-size": "^6.0.0",
40 | "micromatch": "^4.0.8",
41 | "yup": "^0.32.11"
42 | },
43 | "devDependencies": {
44 | "@types/bytes": "^3.1.1",
45 | "@types/micromatch": "^4.0.2",
46 | "@types/node": "^14.0.0"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/apps/website/src/pages/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import Paper from '@mui/material/Paper';
3 | import Typography from '@mui/material/Typography';
4 | import Button from '@mui/material/Button';
5 |
6 | const Container = styled(Paper)`
7 | display: flex;
8 | flex-direction: column;
9 | align-items: center;
10 | align-self: center;
11 | padding: ${({ theme }) => theme.spacing(6)};
12 | max-width: 700px;
13 | `;
14 |
15 | const HomePage = () => {
16 | return (
17 |
18 |
19 | BundleMon helps you to monitor your bundle size.
20 |
21 | Your goal is to keep your bundle size as small as possible to reduce the amount of time it takes for users to
22 | load your website/application. This is particularly important for users on low bandwidth connections.
23 |
24 |
25 | BundleMon helps you achieve that by constantly monitoring your bundle size on every commit and alerts you on
26 | changes.
27 |
28 |
29 |
30 |
31 | Full Documentation
32 |
33 |
34 | );
35 | };
36 |
37 | export default HomePage;
38 |
--------------------------------------------------------------------------------
/apps/service/scripts/replaceProjectBranch.ts:
--------------------------------------------------------------------------------
1 | // Replace branch name for all records for a specific project
2 |
3 | import { closeMongoClient } from '@/framework/mongo/client';
4 | import { getCommitRecordsCollection } from '@/framework/mongo/commitRecords';
5 |
6 | (async () => {
7 | try {
8 | const projectId = '';
9 | const sourceBranch = '';
10 | const targetBranch = '';
11 |
12 | console.log(
13 | `Are you sure you want to replace branch "${sourceBranch}" to "${targetBranch}" for project "${projectId}"? (y/n)`
14 | );
15 | const answer = await new Promise((resolve) => {
16 | process.stdin.on('data', (data) => {
17 | resolve(data.toString().trim());
18 | });
19 | });
20 |
21 | if (answer !== 'y') {
22 | console.log('Abort');
23 | process.exit(0);
24 | }
25 |
26 | console.log('Replacing branch...');
27 |
28 | const commitRecordsCollection = await getCommitRecordsCollection();
29 | const result = await commitRecordsCollection.updateMany(
30 | {
31 | projectId,
32 | branch: sourceBranch,
33 | },
34 | { $set: { branch: targetBranch } }
35 | );
36 |
37 | console.log(`records updated: ${result.modifiedCount}`);
38 |
39 | process.exit(0);
40 | } finally {
41 | await closeMongoClient();
42 | }
43 | })();
44 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportHeader/ReportHeader.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react-lite';
2 | import { getReportConclusionText, Report, Status } from 'bundlemon-utils';
3 | import { Alert, AlertColor } from '@mui/material';
4 | import { ReviewReportModal, ReviewRecord } from './components';
5 |
6 | interface ReportHeaderProps {
7 | report: Report;
8 | setReport: (r: Report) => void;
9 | }
10 |
11 | const ReportHeader = observer(({ report, setReport }: ReportHeaderProps) => {
12 | const conclusionText = getReportConclusionText(report);
13 | let severity: AlertColor = 'info';
14 |
15 | if (report.status === Status.Fail) {
16 | severity = 'error';
17 | } else if (report.status === Status.Pass) {
18 | if (report.metadata.record?.reviews?.length) {
19 | severity = 'warning';
20 | } else {
21 | severity = 'success';
22 | }
23 | }
24 |
25 | return (
26 | }
29 | >
30 | {conclusionText}
31 | {report.metadata.record?.reviews?.length &&
32 | report.metadata.record?.reviews.map((review, index) => )}
33 |
34 | );
35 | });
36 |
37 | export default ReportHeader;
38 |
--------------------------------------------------------------------------------
/migrations.json:
--------------------------------------------------------------------------------
1 | {
2 | "migrations": [
3 | {
4 | "cli": "nx",
5 | "version": "19.2.0-beta.2",
6 | "description": "Updates the default workspace data directory to .nx/workspace-data",
7 | "implementation": "./src/migrations/update-19-2-0/move-workspace-data-directory",
8 | "package": "nx",
9 | "name": "19-2-0-move-graph-cache-directory"
10 | },
11 | {
12 | "cli": "nx",
13 | "version": "19.2.2-beta.0",
14 | "description": "Updates the nx wrapper.",
15 | "implementation": "./src/migrations/update-17-3-0/update-nxw",
16 | "package": "nx",
17 | "name": "19-2-2-update-nx-wrapper"
18 | },
19 | {
20 | "version": "19.2.4-beta.0",
21 | "description": "Set project name in nx.json explicitly",
22 | "implementation": "./src/migrations/update-19-2-4/set-project-name",
23 | "x-repair-skip": true,
24 | "package": "nx",
25 | "name": "19-2-4-set-project-name"
26 | },
27 | {
28 | "cli": "nx",
29 | "version": "19.1.0-beta.6",
30 | "description": "Migrate no-extra-semi rules into user config, out of nx extendable configs",
31 | "implementation": "./src/migrations/update-19-1-0-migrate-no-extra-semi/migrate-no-extra-semi",
32 | "package": "@nx/eslint-plugin",
33 | "name": "update-19-1-0-rename-no-extra-semi"
34 | }
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/.github/workflows/build-web.yml:
--------------------------------------------------------------------------------
1 | name: Build web
2 |
3 | on:
4 | push:
5 | branches: [main, release/*, next]
6 | pull_request:
7 | types: [synchronize, opened, reopened]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-22.04
12 | steps:
13 | - uses: actions/checkout@v4
14 | - name: Setup Node.js
15 | uses: actions/setup-node@v4
16 | with:
17 | node-version: '22'
18 | cache: 'yarn'
19 |
20 | - name: Install dependencies
21 | run: yarn
22 |
23 | - name: Build
24 | working-directory: ./apps/website
25 | run: yarn build
26 |
27 | - name: BundleMon
28 | working-directory: ./apps/website
29 | run: yarn bundlemon
30 | env:
31 | CI_COMMIT_SHA: ${{github.event.pull_request.head.sha || github.sha}} # important!
32 | CI_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
33 |
34 | - name: Deploy
35 | if: ${{ github.ref_name == 'main' }}
36 | working-directory: ./apps/website
37 | env:
38 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
39 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
40 | # https://securitylab.github.com/research/github-actions-untrusted-input/
41 | COMMIT_MSG: ${{ github.event.head_commit.message }}
42 | run: yarn deploy --prod --message "$COMMIT_MSG"
43 |
--------------------------------------------------------------------------------
/apps/service/src/controllers/authController.ts:
--------------------------------------------------------------------------------
1 | import type { RouteHandlerMethod } from 'fastify';
2 | import { RequestError as OctokitRequestError } from '@octokit/request-error';
3 | import { loginWithCode } from '@/framework/github';
4 | import { maxSessionAgeSeconds } from '@/framework/env';
5 | import type { FastifyValidatedRoute, LoginRequestSchema } from '@/types/schemas';
6 |
7 | export const loginController: FastifyValidatedRoute = async (req, res) => {
8 | try {
9 | const { code } = req.body;
10 |
11 | const { sessionData, expiresAt } = await loginWithCode(code);
12 | const expires = expiresAt ?? new Date(new Date().getTime() + 1000 * maxSessionAgeSeconds);
13 |
14 | req.session.options({ expires });
15 | req.session.set('user', sessionData);
16 |
17 | res.setCookie('isSessionExists', 'true', {
18 | httpOnly: false,
19 | expires,
20 | });
21 |
22 | return { status: 'ok' };
23 | } catch (err) {
24 | if (err instanceof OctokitRequestError) {
25 | return res.status(401).send({
26 | message: `GitHub error: ${err.message}`,
27 | });
28 | }
29 |
30 | throw err;
31 | }
32 | };
33 |
34 | export const logoutController: RouteHandlerMethod = async (req, res) => {
35 | req.session.delete();
36 |
37 | res.clearCookie('isSessionExists', { httpOnly: false });
38 |
39 | return { status: 'ok' };
40 | };
41 |
--------------------------------------------------------------------------------
/apps/service/tests/projectUtils.ts:
--------------------------------------------------------------------------------
1 | import { randomBytes } from 'crypto';
2 | import { createHash } from '../src/utils/hashUtils';
3 | import { createProject, getProjectsCollection, GitProject, ProjectDB } from '../src/framework/mongo/projects';
4 | import { ProjectProvider } from 'bundlemon-utils';
5 | import { generateRandomString } from './utils';
6 |
7 | export async function createTestProjectWithApiKey() {
8 | const apiKey = randomBytes(32).toString('hex');
9 | const startKey = apiKey.substring(0, 3);
10 |
11 | const hash = await createHash(apiKey);
12 | const projectId = await createProject({ hash, startKey });
13 |
14 | return { projectId, apiKey };
15 | }
16 |
17 | export async function createTestGithubProject(overrides: Partial = {}): Promise {
18 | const provider = ProjectProvider.GitHub;
19 | const owner = generateRandomString();
20 | const repo = generateRandomString();
21 |
22 | const newProject: ProjectDB = {
23 | provider,
24 | owner,
25 | repo,
26 | creationDate: new Date(),
27 | lastAccessed: new Date(),
28 | ...overrides,
29 | };
30 |
31 | const projectsCollection = await getProjectsCollection();
32 | const id = (await projectsCollection.insertOne(newProject)).insertedId;
33 |
34 | return { id: id.toHexString(), ...newProject };
35 | }
36 |
37 | export function generateProjectId() {
38 | return generateRandomString(24);
39 | }
40 |
--------------------------------------------------------------------------------
/apps/service/scripts/generateSchemas.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 |
3 | const fs = require('node:fs');
4 | const path = require('node:path');
5 | const { execSync } = require('node:child_process');
6 | const { createGenerator } = require('ts-json-schema-generator');
7 |
8 | const startTime = Date.now();
9 |
10 | const TMP_OUTPUT_PATH = '.tmp/schemas.ts';
11 | const OUTPUT_PATH = './src/consts/schemas.ts';
12 |
13 | // Create output directory
14 | fs.mkdirSync(path.dirname(TMP_OUTPUT_PATH), { recursive: true });
15 | fs.rmSync(TMP_OUTPUT_PATH, { force: true });
16 |
17 | const schema = createGenerator({
18 | path: './src/types/schemas/**/*.ts',
19 | tsconfig: './tsconfig.json',
20 | }).createSchema('*');
21 |
22 | let fileString = '';
23 |
24 | for (let name of Object.keys(schema.definitions)) {
25 | console.log(`create ${name} schema`);
26 | fileString += `export const ${name} = ${JSON.stringify(
27 | { $id: `#/definitions/${name}`, ...schema.definitions[name] },
28 | null,
29 | 2
30 | )};\n\n`;
31 | }
32 |
33 | fs.writeFileSync(TMP_OUTPUT_PATH, fileString);
34 |
35 | if (!process.env.CI) {
36 | execSync(`yarn prettier --write "${TMP_OUTPUT_PATH}"`, { cwd: path.join(__dirname, '../'), stdio: 'inherit' });
37 | }
38 |
39 | console.log(`move ${TMP_OUTPUT_PATH} to ${OUTPUT_PATH}`);
40 | fs.renameSync(TMP_OUTPUT_PATH, OUTPUT_PATH);
41 |
42 | console.log(`Done - ${Date.now() - startTime}ms`);
43 |
--------------------------------------------------------------------------------
/apps/website/src/stores/UserStore.ts:
--------------------------------------------------------------------------------
1 | import UserModel from '@/models/UserModel';
2 | import { getMe, logout } from '@/services/bundlemonService';
3 | import { makeAutoObservable, observable, runInAction } from 'mobx';
4 |
5 | export class UserStore {
6 | @observable.ref user: UserModel | undefined;
7 |
8 | constructor() {
9 | makeAutoObservable(this);
10 |
11 | this.init();
12 | }
13 |
14 | init = async () => {
15 | try {
16 | const isSessionExists = getCookie('isSessionExists') === 'true';
17 |
18 | if (!isSessionExists) {
19 | // session not exists, no reason to try to get user details
20 | return;
21 | }
22 |
23 | const user = await getMe();
24 |
25 | if (user) {
26 | runInAction(() => {
27 | this.user = new UserModel(user);
28 | });
29 | }
30 | } catch (e) {
31 | console.error('Failed to load user', e);
32 | }
33 | };
34 |
35 | logout = async () => {
36 | await logout();
37 |
38 | runInAction(() => {
39 | this.user = undefined;
40 | });
41 | };
42 | }
43 |
44 | export const userStore = new UserStore();
45 |
46 | function getCookie(cookieName: string): string | undefined {
47 | const cookies: Record = {};
48 | document.cookie.split(';').forEach(function (el) {
49 | const [key, value] = el.split('=');
50 | cookies[key.trim()] = value;
51 | });
52 | return cookies[cookieName];
53 | }
54 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/ci/providers/travis.ts:
--------------------------------------------------------------------------------
1 | import type { Provider } from '../types';
2 | import { envVarsListToObject, getEnvVar } from '../../utils';
3 |
4 | // https://docs.travis-ci.com/user/environment-variables#default-environment-variables
5 |
6 | const provider: Provider = {
7 | isItMe: getEnvVar('TRAVIS') === 'true',
8 | getVars: () => {
9 | const raw = envVarsListToObject([
10 | 'TRAVIS_REPO_SLUG',
11 | 'TRAVIS_EVENT_TYPE',
12 | 'TRAVIS_PULL_REQUEST',
13 | 'TRAVIS_BRANCH',
14 | 'TRAVIS_PULL_REQUEST_BRANCH',
15 | 'TRAVIS_COMMIT',
16 | 'TRAVIS_COMMIT_MESSAGE',
17 | ] as const);
18 |
19 | const fullRepoName = raw.TRAVIS_REPO_SLUG;
20 | const [owner, repo] = fullRepoName?.split('/') ?? [undefined, undefined];
21 |
22 | const isPushEvent = raw.TRAVIS_EVENT_TYPE === 'push';
23 | const prNumber = raw.TRAVIS_PULL_REQUEST;
24 |
25 | return {
26 | raw,
27 | ci: true,
28 | provider: 'travis',
29 | owner,
30 | repo,
31 | branch: isPushEvent ? raw.TRAVIS_BRANCH : raw.TRAVIS_PULL_REQUEST_BRANCH,
32 | commitSha: raw.TRAVIS_COMMIT,
33 | targetBranch: isPushEvent ? undefined : raw.TRAVIS_BRANCH,
34 | prNumber: prNumber === 'false' ? undefined : prNumber, // "false" if it’s not a pull request, set as undefined
35 | commitMsg: raw.TRAVIS_COMMIT_MESSAGE,
36 | };
37 | },
38 | };
39 |
40 | export default provider;
41 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to BundleMon
2 |
3 | Thanks for taking the time to contribute! ❤️
4 |
5 | ## Local development
6 |
7 | ### Prerequisites
8 |
9 | - [Node.js](https://nodejs.org/) >= v18
10 | - [Yarn](https://yarnpkg.com/en/docs/install)
11 |
12 | #### Install dependencies
13 |
14 | ```bash
15 | yarn install
16 | ```
17 |
18 | #### Build packages
19 |
20 | ```bash
21 | yarn build-packages
22 | ```
23 |
24 | ### BundleMon CLI
25 |
26 | #### Test packages
27 |
28 | ```bash
29 | yarn test-packages
30 | ```
31 |
32 | ### BundleMon Service
33 |
34 | Requires `docker` & `docker compose`
35 |
36 | #### Start service
37 |
38 | When changing code in `apps/service/` directory the service will reload itself
39 |
40 | Run from `apps/service/` directory
41 |
42 | ```
43 | yarn serve
44 | ```
45 |
46 | By default the service will start on port `3333`
47 |
48 | #### Generate local data
49 |
50 | The script will generate 3 projects, run it when the local service is running
51 |
52 | ```
53 | yarn gen-local-data
54 | ```
55 |
56 | #### Run tests
57 |
58 | Run from `apps/service/` directory
59 |
60 | ```bash
61 | yarn start:mock-services
62 | ```
63 |
64 | ```bash
65 | yarn test
66 | ```
67 |
68 | ### BundleMon website
69 |
70 | ```bash
71 | yarn serve
72 | ```
73 |
74 | After running the command the website will be available at https://localhost:4000/
75 |
76 | By default the local website will expect a local BundleMon service on port `3333`.
77 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/ci/providers/github.ts:
--------------------------------------------------------------------------------
1 | import type { Provider } from '../types';
2 | import { getEnvVar, envVarsListToObject } from '../../utils';
3 |
4 | // https://docs.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables
5 |
6 | type GitHubEvent = undefined | '' | 'pull_request' | 'push';
7 |
8 | const provider: Provider = {
9 | isItMe: !!getEnvVar('GITHUB_ACTION'),
10 | getVars: () => {
11 | const raw = envVarsListToObject([
12 | 'GITHUB_REPOSITORY',
13 | 'GITHUB_EVENT_NAME',
14 | 'GITHUB_REF',
15 | 'GITHUB_HEAD_REF',
16 | 'GITHUB_SHA',
17 | 'GITHUB_BASE_REF',
18 | 'GITHUB_RUN_ID',
19 | ] as const);
20 |
21 | const fullRepoName = raw.GITHUB_REPOSITORY;
22 |
23 | const [owner, repo] = fullRepoName?.split('/') ?? [undefined, undefined];
24 |
25 | const event = raw.GITHUB_EVENT_NAME as GitHubEvent;
26 | const isPr = event === 'pull_request';
27 | const ref = raw.GITHUB_REF?.split('/');
28 |
29 | return {
30 | raw,
31 | ci: true,
32 | provider: 'github',
33 | owner,
34 | repo,
35 | branch: isPr ? raw.GITHUB_HEAD_REF : ref?.slice(2).join('/'),
36 | commitSha: raw.GITHUB_SHA,
37 | targetBranch: raw.GITHUB_BASE_REF,
38 | prNumber: isPr ? ref?.[2] : undefined,
39 | buildId: raw.GITHUB_RUN_ID,
40 | };
41 | },
42 | };
43 |
44 | export default provider;
45 |
--------------------------------------------------------------------------------
/packages/bundlemon/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bundlemon",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "{projectRoot}/lib",
5 | "projectType": "library",
6 | "tags": ["type:lib"],
7 | "generators": {},
8 | "targets": {
9 | "build": {
10 | "executor": "@nx/js:tsc",
11 | "options": {
12 | "outputPath": "dist/{projectRoot}",
13 | "tsConfig": "{projectRoot}/tsconfig.lib.json",
14 | "packageJson": "{projectRoot}/package.json",
15 | "main": "{projectRoot}/lib/index.ts",
16 | "updateBuildableProjectDepsInPackageJson": false,
17 | "assets": [
18 | "README.md",
19 | "LICENSE",
20 | {
21 | "input": "{projectRoot}",
22 | "glob": "**/*.d.ts",
23 | "ignore": ["node_modules/**"],
24 | "output": "/"
25 | }
26 | ]
27 | },
28 | "outputs": ["{options.outputPath}"]
29 | },
30 | "lint": {
31 | "executor": "@nx/eslint:lint",
32 | "outputs": ["{options.outputFile}"],
33 | "options": {
34 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,js,json}"],
35 | "maxWarnings": 0
36 | }
37 | },
38 | "test": {
39 | "executor": "@nx/jest:jest",
40 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
41 | "options": {
42 | "jestConfig": "{projectRoot}/jest.config.ts"
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/apps/service/src/framework/mongo/client.ts:
--------------------------------------------------------------------------------
1 | import { MongoClient, ReadPreference, Db, Document } from 'mongodb';
2 | import { mongoUrl, mongoDbName, mongoDbUser, mongoDbPassword } from '../env';
3 |
4 | let client: MongoClient | undefined;
5 | let db: Db | undefined;
6 |
7 | const getClient = async () => {
8 | if (!client) {
9 | try {
10 | client = await MongoClient.connect(mongoUrl, {
11 | auth: { username: mongoDbUser, password: mongoDbPassword },
12 | readPreference: ReadPreference.PRIMARY,
13 | writeConcern: {
14 | w: 'majority',
15 | },
16 | retryWrites: true,
17 | connectTimeoutMS: 5000,
18 | serverSelectionTimeoutMS: 5000,
19 | });
20 | } catch (err) {
21 | throw new Error('Could not connect to mongo\n ' + err);
22 | }
23 | }
24 |
25 | return client;
26 | };
27 |
28 | export async function closeMongoClient() {
29 | if (client) {
30 | await client.close();
31 |
32 | db = undefined;
33 | client = undefined;
34 | }
35 | }
36 |
37 | export const getDB = async () => {
38 | if (!db) {
39 | try {
40 | const client = await getClient();
41 |
42 | db = client.db(mongoDbName);
43 | } catch (err) {
44 | throw new Error('Could not connect to mongo\n ' + err);
45 | }
46 | }
47 |
48 | return db;
49 | };
50 |
51 | export const getCollection = async (collectionName: string) =>
52 | (await getDB()).collection(collectionName);
53 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/components/ReportsChart/components/CustomTooltip.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react-lite';
2 | import styled from '@emotion/styled';
3 | import { TooltipProps } from 'recharts';
4 | import { CommitRecord } from 'bundlemon-utils';
5 | import { Paper, Stack } from '@mui/material';
6 | import bytes from 'bytes';
7 |
8 | const Container = styled(Paper)`
9 | padding: ${({ theme }) => theme.spacing(2)};
10 | min-width: 300px;
11 | `;
12 |
13 | const Title = styled.span`
14 | font-weight: 700;
15 | `;
16 |
17 | const CommitMsgText = styled.span`
18 | font-weight: 500;
19 | overflow: hidden;
20 | white-space: wrap;
21 | max-width: 300px;
22 | `;
23 |
24 | const CustomTooltip = observer(({ active, payload }: TooltipProps) => {
25 | if (!active) {
26 | return null;
27 | }
28 |
29 | const commitRecord: CommitRecord | undefined = payload?.[0]?.payload;
30 |
31 | if (!commitRecord) {
32 | return null;
33 | }
34 |
35 | return (
36 |
37 |
38 | {new Date(commitRecord.creationDate).toLocaleString()}
39 | {commitRecord.commitMsg && {commitRecord.commitMsg} }
40 | {payload?.map((p) => (
41 |
42 | {p.name} : {bytes(p.value as number)}
43 |
44 | ))}
45 |
46 |
47 | );
48 | });
49 |
50 | export default CustomTooltip;
51 |
--------------------------------------------------------------------------------
/apps/website/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode, Suspense } from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import CssBaseline from '@mui/material/CssBaseline';
4 | import { SnackbarProvider } from 'notistack';
5 | import { QueryClient, QueryClientProvider } from 'react-query';
6 | import { BrowserRouter } from 'react-router-dom';
7 | import Layout from './components/Layout';
8 | import Router from './Router';
9 | import FetchError from './services/FetchError';
10 | import ThemeProvider from './components/ThemeProvider';
11 |
12 | const queryClient = new QueryClient({
13 | defaultOptions: {
14 | queries: {
15 | retry: (failureCount, error) => {
16 | if (error instanceof FetchError) {
17 | return error.statusCode >= 500 && failureCount <= 2 ? true : false;
18 | }
19 |
20 | return false;
21 | },
22 | refetchOnWindowFocus: false,
23 | cacheTime: 0,
24 | },
25 | },
26 | });
27 |
28 | createRoot(document.getElementById('root') as HTMLElement).render(
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 |
--------------------------------------------------------------------------------
/apps/service/scripts/generateLocalData.ts:
--------------------------------------------------------------------------------
1 | import { ObjectId } from 'mongodb';
2 | import { getDB } from '../src/framework/mongo/client';
3 | import { getProjectsCollection } from '../src/framework/mongo/projects';
4 | import { createHash } from '../src/utils/hashUtils';
5 | import { mongoUrl, nodeEnv } from '../src/framework/env';
6 |
7 | async function createProject(char: string) {
8 | const projectId = char.repeat(24);
9 | const apiKey = char.repeat(64);
10 | const startKey = apiKey.substring(0, 3);
11 | const hash = await createHash(apiKey);
12 | const projectsCollection = await getProjectsCollection();
13 | const creationDate = new Date();
14 | creationDate.setDate(creationDate.getDate() - 7); // one week ago
15 |
16 | await projectsCollection.insertOne({
17 | _id: new ObjectId(projectId),
18 | apiKey: { hash, startKey },
19 | creationDate,
20 | lastAccessed: new Date(),
21 | });
22 |
23 | console.log(`project ${projectId} created with api key: "${apiKey}"`);
24 | }
25 |
26 | (async () => {
27 | try {
28 | if (!mongoUrl.includes('localhost') || nodeEnv !== 'development') {
29 | throw new Error('This script should only run on local env');
30 | }
31 |
32 | console.log('Clear DB');
33 | await (await getDB()).dropDatabase();
34 |
35 | console.log('Create projects');
36 | await createProject('a');
37 | await createProject('b');
38 | await createProject('c');
39 |
40 | process.exit(0);
41 | } catch (e) {
42 | console.error(e);
43 | process.exit(1);
44 | }
45 | })();
46 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/cli/__tests__/configFile.spec.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { loadConfigFile } from '../configFile';
3 |
4 | const SUCCESS_FILE_CONFIG = {
5 | baseDir: 'build',
6 | verbose: true,
7 | };
8 | describe('load config file', () => {
9 | beforeEach(() => {
10 | jest.resetAllMocks();
11 | });
12 |
13 | test('success', async () => {
14 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'success.json'));
15 |
16 | expect(config).toEqual(SUCCESS_FILE_CONFIG);
17 | });
18 |
19 | describe('failure', () => {
20 | test('empty', async () => {
21 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'empty.json'));
22 |
23 | expect(config).toBeUndefined();
24 | });
25 |
26 | test('bad format JSON', async () => {
27 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'bad-format.json'));
28 |
29 | expect(config).toBeUndefined();
30 | });
31 |
32 | test('bad format YAML', async () => {
33 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'bad-format.yaml'));
34 |
35 | expect(config).toBeUndefined();
36 | });
37 |
38 | test('bad format JS', async () => {
39 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'bad-format.js'));
40 |
41 | expect(config).toBeUndefined();
42 | });
43 |
44 | test('not found', async () => {
45 | const config = await loadConfigFile('not-found');
46 |
47 | expect(config).toBeUndefined();
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/apps/service/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bundlemon-service",
3 | "version": "1.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "scripts": {
7 | "serve": "yarn --cwd ../../ nx serve service --verbose",
8 | "build": "yarn --cwd ../../ nx build service --verbose",
9 | "test": "yarn --cwd ../../ nx test service --verbose",
10 | "lint": "yarn --cwd ../../ nx lint service --verbose",
11 | "generate-schemas": "node ./scripts/generateSchemas.js",
12 | "prevercel-deploy": "yarn --cwd ../../ nx build service -c vercel --verbose",
13 | "vercel-deploy": "vercel deploy",
14 | "start:mock-services": "docker compose -f docker-compose.test.yml up --remove-orphans",
15 | "stop:mock-services": "docker compose -f docker-compose.test.yml down",
16 | "gen-local-data": "node -r @swc-node/register -r dotenv/config ./scripts/generateLocalData.ts dotenv_config_path=.development.env"
17 | },
18 | "dependencies": {
19 | "@fastify/cookie": "^11.0.2",
20 | "@fastify/cors": "^11.1.0",
21 | "@fastify/secure-session": "^8.2.0",
22 | "@fastify/static": "^8.2.0",
23 | "@octokit/auth-app": "^6.0.0",
24 | "@octokit/rest": "^20.0.1",
25 | "bundlemon-markdown-output": "^2.0.1",
26 | "bundlemon-utils": "^2.0.1",
27 | "env-var": "^7.3.1",
28 | "fastify": "^5.5.0",
29 | "mongodb": "^6.18.0"
30 | },
31 | "devDependencies": {
32 | "@types/sodium-native": "^2.3.9",
33 | "dotenv": "^16.3.1",
34 | "ts-json-schema-generator": "^1.3.0",
35 | "typescript": "^5.1.6",
36 | "vercel": "^31.4.0"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bundlemon-utils",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "{projectRoot}/lib",
5 | "projectType": "library",
6 | "tags": ["type:lib"],
7 | "generators": {},
8 | "targets": {
9 | "build": {
10 | "executor": "@nx/js:tsc",
11 | "options": {
12 | "outputPath": "dist/{projectRoot}",
13 | "tsConfig": "{projectRoot}/tsconfig.lib.json",
14 | "packageJson": "{projectRoot}/package.json",
15 | "main": "{projectRoot}/lib/index.ts",
16 | "updateBuildableProjectDepsInPackageJson": false,
17 | "assets": [
18 | {
19 | "input": "{projectRoot}",
20 | "glob": "README.md",
21 | "output": "/"
22 | },
23 | "LICENSE",
24 | {
25 | "input": "{projectRoot}",
26 | "glob": "**/*.d.ts",
27 | "ignore": ["node_modules/**"],
28 | "output": "/"
29 | }
30 | ]
31 | },
32 | "outputs": ["{options.outputPath}"]
33 | },
34 | "lint": {
35 | "executor": "@nx/eslint:lint",
36 | "outputs": ["{options.outputFile}"],
37 | "options": {
38 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,js,json}"],
39 | "maxWarnings": 0
40 | }
41 | },
42 | "test": {
43 | "executor": "@nx/jest:jest",
44 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
45 | "options": {
46 | "jestConfig": "{projectRoot}/jest.config.ts"
47 | }
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/apps/website/src/stores/ConfigStore.ts:
--------------------------------------------------------------------------------
1 | import { computed, makeAutoObservable, observable, runInAction } from 'mobx';
2 |
3 | interface Config {
4 | bundlemonServiceUrl: string;
5 | githubAppClientId?: string;
6 | }
7 |
8 | export class ConfigStore {
9 | @observable.ref _config: Config | undefined = undefined;
10 | @observable.ref error: string | undefined = undefined;
11 |
12 | constructor() {
13 | makeAutoObservable(this);
14 |
15 | this.init();
16 | }
17 |
18 | @computed get isLoaded(): boolean {
19 | return this._config !== undefined;
20 | }
21 |
22 | init = async () => {
23 | try {
24 | const response = await fetch('/assets/config.json');
25 | const config = await response.json();
26 |
27 | runInAction(() => {
28 | this._config = config;
29 | });
30 | } catch (error) {
31 | console.error('Failed to load config', error);
32 |
33 | runInAction(() => {
34 | this.error = `Failed to load config: ${(error as Error).message}`;
35 | });
36 | }
37 | };
38 |
39 | get = (key: T): Config[T] => {
40 | if (this._config === undefined) {
41 | throw new Error('Config is not loaded yet');
42 | }
43 |
44 | return this._config[key];
45 | };
46 |
47 | get bundlemonServiceUrl() {
48 | return import.meta.env.VITE_BUNDLEMON_SERVICE_URL || this.get('bundlemonServiceUrl');
49 | }
50 |
51 | get githubAppClientId() {
52 | return import.meta.env.VITE_GITHUB_APP_ID || this.get('githubAppClientId');
53 | }
54 | }
55 |
56 | export const configStore = new ConfigStore();
57 |
--------------------------------------------------------------------------------
/packages/bundlemon-markdown-output/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bundlemon-markdown-output",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "{projectRoot}/lib",
5 | "projectType": "library",
6 | "tags": ["type:lib"],
7 | "generators": {},
8 | "targets": {
9 | "build": {
10 | "executor": "@nx/js:tsc",
11 | "options": {
12 | "outputPath": "dist/{projectRoot}",
13 | "tsConfig": "{projectRoot}/tsconfig.lib.json",
14 | "packageJson": "{projectRoot}/package.json",
15 | "main": "{projectRoot}/lib/index.ts",
16 | "updateBuildableProjectDepsInPackageJson": false,
17 | "assets": [
18 | {
19 | "input": "{projectRoot}",
20 | "glob": "README.md",
21 | "output": "/"
22 | },
23 | "LICENSE",
24 | {
25 | "input": "{projectRoot}",
26 | "glob": "**/*.d.ts",
27 | "ignore": ["node_modules/**"],
28 | "output": "/"
29 | }
30 | ]
31 | },
32 | "outputs": ["{options.outputPath}"]
33 | },
34 | "lint": {
35 | "executor": "@nx/eslint:lint",
36 | "outputs": ["{options.outputFile}"],
37 | "options": {
38 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,js,json}"],
39 | "maxWarnings": 0
40 | }
41 | },
42 | "test": {
43 | "executor": "@nx/jest:jest",
44 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
45 | "options": {
46 | "jestConfig": "{projectRoot}/jest.config.ts"
47 | }
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/apps/website/src/components/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
2 | import { ThemeProvider as EmotionThemeProvider } from '@emotion/react';
3 | import { createContext, ReactNode, useState } from 'react';
4 | import { lightTheme, darkTheme } from '@/consts/theme';
5 | import { useOnMount } from '@/hooks';
6 |
7 | export const ThemeContext = createContext<{ isDarkMode: boolean; setDarkMode: (isDarkMode: boolean) => void }>({
8 | isDarkMode: false,
9 | // eslint-disable-next-line @typescript-eslint/no-empty-function
10 | setDarkMode: () => {},
11 | });
12 |
13 | interface ThemeProviderProps {
14 | children: ReactNode;
15 | }
16 |
17 | const ThemeProvider = ({ children }: ThemeProviderProps) => {
18 | const [isDarkMode, setDarkModeState] = useState(false);
19 |
20 | useOnMount(() => {
21 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
22 | const storedValue = localStorage.getItem('isDarkMode');
23 |
24 | setDarkModeState(storedValue ? storedValue === 'true' : prefersDark);
25 | });
26 |
27 | const setDarkMode = (isDark: boolean) => {
28 | setDarkModeState(isDark);
29 | localStorage.setItem('isDarkMode', String(isDark));
30 | };
31 |
32 | const theme = isDarkMode ? darkTheme : lightTheme;
33 |
34 | return (
35 |
36 |
37 | {children}
38 |
39 |
40 | );
41 | };
42 |
43 | export default ThemeProvider;
44 |
--------------------------------------------------------------------------------
/apps/service/src/utils/reportUtils.ts:
--------------------------------------------------------------------------------
1 | import { CommitRecordReviewResolution, generateDiffReport, Report, Status } from 'bundlemon-utils';
2 | import { generateLinkToReport } from './linkUtils';
3 |
4 | import type { CommitRecordWithBase } from '../framework/mongo/commitRecords';
5 |
6 | export function generateReport({ record, baseRecord }: CommitRecordWithBase): Report {
7 | const diffReport = generateDiffReport(record, baseRecord);
8 |
9 | const report: Report = {
10 | ...diffReport,
11 | metadata: {
12 | subProject: record.subProject,
13 | linkToReport: generateLinkToReport({ projectId: record.projectId, commitRecordId: record.id }),
14 | record,
15 | baseRecord,
16 | },
17 | };
18 |
19 | // If the record and the base record have the same branch, that probably mean it's a merge commit, so no need to fail the report
20 | if (report.status === Status.Fail && record.branch === baseRecord?.branch) {
21 | report.status = Status.Pass;
22 | }
23 |
24 | // Set report status to the last review
25 | if (record.reviews?.length) {
26 | const lastReviewResolution = record.reviews[record.reviews.length - 1].resolution;
27 |
28 | if (lastReviewResolution === CommitRecordReviewResolution.Approved) {
29 | report.status = Status.Pass;
30 | } else if (lastReviewResolution === CommitRecordReviewResolution.Rejected) {
31 | report.status = Status.Fail;
32 | }
33 | }
34 |
35 | return report;
36 | }
37 |
38 | export function truncateString(input: string, maxLength: number) {
39 | if (input.length > maxLength) {
40 | return input.substring(0, maxLength) + '...';
41 | }
42 |
43 | return input;
44 | }
45 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/ci/index.ts:
--------------------------------------------------------------------------------
1 | import providers from './providers';
2 | import { envVarsListToObject } from '../utils';
3 | import type { CIEnvVars } from './types';
4 |
5 | const rawOverrideVars = envVarsListToObject([
6 | 'CI',
7 | 'CI_REPO_OWNER',
8 | 'CI_REPO_NAME',
9 | 'CI_BRANCH',
10 | 'CI_COMMIT_SHA',
11 | 'CI_TARGET_BRANCH',
12 | 'CI_PR_NUMBER',
13 | 'CI_COMMIT_MESSAGE',
14 | ] as const);
15 |
16 | const overrideVars: CIEnvVars = {
17 | raw: rawOverrideVars,
18 | ci: rawOverrideVars.CI === 'true',
19 | provider: undefined,
20 | owner: rawOverrideVars.CI_REPO_OWNER,
21 | repo: rawOverrideVars.CI_REPO_NAME,
22 | branch: rawOverrideVars.CI_BRANCH,
23 | commitSha: rawOverrideVars.CI_COMMIT_SHA,
24 | targetBranch: rawOverrideVars.CI_TARGET_BRANCH,
25 | prNumber: rawOverrideVars.CI_PR_NUMBER,
26 | commitMsg: rawOverrideVars.CI_COMMIT_MESSAGE,
27 | };
28 |
29 | const providerVars = providers.find((p) => p.isItMe)?.getVars();
30 | const vars = { ...overrideVars };
31 |
32 | if (providerVars) {
33 | // Use provider var if override var is undefined
34 | (Object.keys(providerVars) as (keyof CIEnvVars)[]).forEach((varName) => {
35 | // @ts-expect-error bad types
36 | vars[varName] = vars[varName] ?? providerVars[varName];
37 | });
38 |
39 | vars.raw = {
40 | ...providerVars.raw,
41 | ...overrideVars.raw,
42 | };
43 | }
44 |
45 | export const getCIVars = () => {
46 | return vars;
47 | };
48 |
49 | export default vars;
50 |
51 | const { ci, provider, owner, repo, branch, commitSha, prNumber, targetBranch } = vars;
52 |
53 | export { ci, provider, owner, repo, branch, commitSha, prNumber, targetBranch };
54 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/initializer.ts:
--------------------------------------------------------------------------------
1 | import { existsSync as isDirExists } from 'fs';
2 | import logger, { setVerbose } from '../common/logger';
3 | import { validateConfig, getNormalizedConfig } from './utils/configUtils';
4 | import { Config, NormalizedConfig } from './types';
5 | import { initOutputs } from './outputs';
6 | import ciVars from './utils/ci';
7 | import { version } from '../common/consts';
8 |
9 | export async function initializer(config: Config): Promise {
10 | setVerbose(config.verbose ?? false);
11 |
12 | logger.info(`Start BundleMon v${version}`);
13 |
14 | const validatedConfig = validateConfig(config);
15 |
16 | if (!validatedConfig) {
17 | logger.error('Invalid config');
18 | logger.debug(`Config\n${JSON.stringify(config, null, 2)}`);
19 |
20 | return undefined;
21 | }
22 |
23 | const normalizedConfig = Object.freeze(await getNormalizedConfig(validatedConfig));
24 |
25 | if (!normalizedConfig) {
26 | logger.debug(`Config\n${JSON.stringify(config, null, 2)}`);
27 | return undefined;
28 | }
29 |
30 | logger.debug(`Normalized config:\n${JSON.stringify(normalizedConfig, null, 2)}`);
31 | logger.debug(`CI vars:\n${JSON.stringify(ciVars, null, 2)}`);
32 |
33 | const { baseDir } = normalizedConfig;
34 |
35 | logger.info(`base directory: "${baseDir}"`);
36 |
37 | if (!isDirExists(baseDir)) {
38 | logger.error(`base directory "${baseDir}" not found`);
39 |
40 | return undefined;
41 | }
42 |
43 | try {
44 | await initOutputs(normalizedConfig);
45 | } catch (err) {
46 | logger.error((err as Error).message);
47 | return undefined;
48 | }
49 |
50 | return normalizedConfig;
51 | }
52 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/outputs/json.ts:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 | import fs from 'fs';
3 | import { Report } from 'bundlemon-utils';
4 | import { createLogger } from '../../../common/logger';
5 | import { validateYup } from '../../utils/validationUtils';
6 | import type { Output, OutputInstance } from '../types';
7 |
8 | const NAME = 'json';
9 | const DEFAULT_FILENAME = 'bundlemon-results.json';
10 |
11 | const logger = createLogger(`${NAME} output`);
12 |
13 | interface JsonOutputOptions {
14 | fileName: string;
15 | }
16 |
17 | function validateOptions(options: unknown): JsonOutputOptions | undefined {
18 | const schema: yup.SchemaOf = yup
19 | .object()
20 | .required()
21 | .shape({
22 | fileName: yup.string().optional().default(DEFAULT_FILENAME),
23 | });
24 |
25 | return validateYup(schema, options, `${NAME} output`);
26 | }
27 |
28 | const saveAsJson = (filename: string, payload: Report) => {
29 | try {
30 | fs.writeFileSync(`${filename}`, JSON.stringify(payload, null, 2));
31 | } catch (error) {
32 | logger.error(`Could not save a file ${filename}`);
33 | throw error;
34 | }
35 | };
36 |
37 | const output: Output = {
38 | name: NAME,
39 | create: ({ options }): OutputInstance | Promise | undefined => {
40 | const normalizedOptions = validateOptions(options);
41 |
42 | if (!normalizedOptions) {
43 | throw new Error(`validation error in output "${NAME}" options`);
44 | }
45 | return {
46 | generate: (report: Report): void => {
47 | saveAsJson(normalizedOptions.fileName, report);
48 | },
49 | };
50 | },
51 | };
52 |
53 | export default output;
54 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/cli/index.ts:
--------------------------------------------------------------------------------
1 | import { program, Option } from 'commander';
2 | import { Compression, Status } from 'bundlemon-utils';
3 | import bundlemon from '../main';
4 | import logger from '../common/logger';
5 | import { version } from '../common/consts';
6 | import { loadConfigFile } from './configFile';
7 |
8 | import type { CliOptions } from './types';
9 | import type { Config } from '../main/types';
10 |
11 | program
12 | .version(version)
13 | .addOption(new Option('-c, --config ', 'config file path'))
14 | .addOption(new Option('--subProject ', 'sub project name'))
15 | .addOption(
16 | new Option('--defaultCompression ', 'default compression').choices(Object.values(Compression))
17 | );
18 |
19 | export default async (): Promise => {
20 | try {
21 | program.parse(process.argv);
22 |
23 | const options: CliOptions = program.opts();
24 |
25 | const config = await loadConfigFile(options.config);
26 |
27 | if (!config) {
28 | logger.error('Cant find config or the config file is empty');
29 | process.exit(1);
30 | }
31 |
32 | const report = await bundlemon(mergeCliOptions(config, options));
33 |
34 | process.exit(report.status === Status.Pass ? 0 : 1);
35 | } catch (err) {
36 | logger.error('Unhandled error', err);
37 | process.exit(1);
38 | }
39 | };
40 |
41 | function mergeCliOptions(config: Config, options: CliOptions): Config {
42 | const newConfig = { ...config };
43 |
44 | if (options.subProject) {
45 | newConfig.subProject = options.subProject;
46 | }
47 |
48 | if (options.defaultCompression) {
49 | newConfig.defaultCompression = options.defaultCompression;
50 | }
51 |
52 | return newConfig;
53 | }
54 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/common/logger.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 |
3 | let _verbose = false;
4 |
5 | export const setVerbose = (verbose: boolean): void => {
6 | _verbose = verbose;
7 | };
8 |
9 | class Logger {
10 | prefix: string | undefined;
11 |
12 | constructor(prefix?: string) {
13 | this.prefix = prefix;
14 | }
15 |
16 | log = (message: string): void => {
17 | console.log(this.messageWithPrefix(message));
18 | };
19 |
20 | debug = (message: string): void => {
21 | if (!_verbose) {
22 | return;
23 | }
24 |
25 | console.log(chalk.grey(`[DEBUG] ${this.messageWithPrefix(message)}`));
26 | };
27 |
28 | info = (message: string): void => {
29 | console.log(chalk.cyan(`[INFO] ${this.messageWithPrefix(message)}`));
30 | };
31 |
32 | warn = (message: string): void => {
33 | console.log(chalk.yellow(`[WARN] ${this.messageWithPrefix(message)}`));
34 | };
35 |
36 | error = (message: string, err?: unknown): void => {
37 | console.error(chalk.red(`[ERROR] ${this.messageWithPrefix(message)}`));
38 | if (err) {
39 | if (err instanceof Error) {
40 | console.error(err);
41 | } else {
42 | console.error(chalk.red(err));
43 | }
44 | }
45 | };
46 |
47 | private messageWithPrefix = (message: string) => {
48 | return (this.prefix ? this.prefix + ': ' : '').concat(message);
49 | };
50 |
51 | clone = (prefix: string) => {
52 | const newLogger = new Logger((this.prefix ? `${this.prefix} - ` : '') + prefix);
53 |
54 | return newLogger;
55 | };
56 | }
57 |
58 | const logger = new Logger();
59 |
60 | export default logger;
61 |
62 | export function createLogger(prefix: string): Logger {
63 | return logger.clone(prefix);
64 | }
65 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/utils/__tests__/configUtils.ts:
--------------------------------------------------------------------------------
1 | import { Compression } from 'bundlemon-utils';
2 | import { CreateCommitRecordAuthType, DEFAULT_PATH_LABELS } from '../../../common/consts';
3 | import {
4 | BaseNormalizedConfig,
5 | NormalizedConfigRemoteOn,
6 | NormalizedConfigRemoteOff,
7 | CreateCommitRecordAuthParams,
8 | } from '../../types';
9 |
10 | const baseNormalizedConfig: Omit = {
11 | baseDir: '',
12 | defaultCompression: Compression.Gzip,
13 | files: [],
14 | groups: [],
15 | pathLabels: DEFAULT_PATH_LABELS,
16 | reportOutput: [],
17 | verbose: false,
18 | includeCommitMessage: false,
19 | };
20 |
21 | export function generateNormalizedConfigRemoteOn(
22 | override: Partial = {}
23 | ): NormalizedConfigRemoteOn {
24 | const authHeaders: CreateCommitRecordAuthParams = {
25 | authType: CreateCommitRecordAuthType.ProjectApiKey,
26 | token: generateRandomString(),
27 | };
28 |
29 | return {
30 | ...baseNormalizedConfig,
31 | remote: true,
32 | projectId: generateRandomString(),
33 | gitVars: {
34 | branch: generateRandomString(),
35 | commitSha: generateRandomString(),
36 | },
37 | getCreateCommitRecordAuthParams: () => authHeaders,
38 | ...override,
39 | };
40 | }
41 |
42 | export function generateNormalizedConfigRemoteOff(
43 | override: Partial = {}
44 | ): NormalizedConfigRemoteOff {
45 | return {
46 | ...baseNormalizedConfig,
47 | remote: false,
48 | ...override,
49 | };
50 | }
51 |
52 | export function generateRandomString(length = 10) {
53 | return Math.round(Math.pow(36, length + 1) - Math.random() * Math.pow(36, length))
54 | .toString(36)
55 | .slice(1);
56 | }
57 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/ReportsPage.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { GetCommitRecordsQuery } from '@/services/bundlemonService';
3 | import { useNavigate, useParams } from 'react-router';
4 | import { useQueryParams } from '@/hooks';
5 | import { CommitRecordsQueryResolution } from '@/consts/commitRecords';
6 | import { removeEmptyValuesFromObject } from '@/utils/objectUtils';
7 | import QueryParamsForm from './components/QueryParamsForm';
8 | import ReportsResult from './components/ReportsResult';
9 | import { Stack } from '@mui/material';
10 |
11 | const Container = styled(Stack)`
12 | display: flex;
13 | flex-direction: column;
14 | align-items: center;
15 | align-self: center;
16 | margin: 0 auto;
17 | width: 100%;
18 | `;
19 |
20 | const ReportsPage = () => {
21 | const { projectId } = useParams() as { projectId: string };
22 | const query = useQueryParams();
23 | const navigate = useNavigate();
24 |
25 | const getCommitRecordsQuery: GetCommitRecordsQuery = {
26 | subProject: query.get('subProject') ?? undefined,
27 | branch: query.get('branch') || 'main',
28 | resolution: (query.get('resolution') as CommitRecordsQueryResolution | null) || CommitRecordsQueryResolution.Days,
29 | };
30 |
31 | const setGetCommitRecordsQuery = (params: GetCommitRecordsQuery) => {
32 | navigate({
33 | search: `?${new URLSearchParams(removeEmptyValuesFromObject(params))}`,
34 | });
35 | };
36 |
37 | return (
38 |
39 |
40 |
41 |
42 | );
43 | };
44 |
45 | export default ReportsPage;
46 |
--------------------------------------------------------------------------------
/apps/service/src/local-certs/key.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN RSA PRIVATE KEY-----
2 | MIIEpQIBAAKCAQEA7bpHOVAjP4ceYqqVwWaxrNHrunN0XMWzXGj+zG1IaaZLvAYx
3 | tXbWQJuhBaDK3wEDR9vjZTIkSr3DRkBIAMbg1Xqylg/fiEtBZ36DoBE1GeETdsHt
4 | KiMboe7TxtgHfDjGlVo9LlK2E9n+c71T8c3WWSzD6TRQzwvd/6THbMYLPvCSm/cj
5 | VY7FrI57sXWAvpR3DePXR2EssU/rwkLDcCIPZ9Dyf2VGQ66BICF6qXrupNauFbRx
6 | WwNfB7HoAFuFdV5JnIBAYo5JWX4Hh+NRQkPyEynwMP24m+mCSLL9ga404JS9Tsej
7 | KgAT+Ppgbr/aezP/C9E/WLeLhN2CsHS6QbfcaQIDAQABAoIBAQCiWNhTF5s6wzfJ
8 | Ad4Lmeo0r5dgWYBZ6tm2fi2jxe3x2JNX8JL57hIbRS0N/uUMrlBjPpNohHmsYTN+
9 | Ql/px+e7YnObb3OkTGB6ITgalCXDaqY0L/ObFybDy6ns3ZMfDlbvoBSwEeQuYm0W
10 | 9XDibUO42o1gMU4OV3hgIVPfwM/lRmyi+uL3G237HCf7slA45wh5rm9mdLXnMsru
11 | ZSH8yNL6cty7WUv6aXnz5t18WfDFsc/enbuGdflR1bV28hTm664r56Z5mUt1nCqw
12 | QuLPqgcRhnpd0bXqGp3txtKlHhtgyRYqLhULzzLWfyBJaa5a/kI7CqKn3/we8yTC
13 | w3iZDCpBAoGBAPj69S/HU1jHxDwKAp0kUm3MjIsrqpgwwLzLfYjNi0kPvaYzYUEl
14 | LtJAMLnTooZJqr1IbIJyQiqh+Ttg/ZLj/fDRKkEB53la+kyvyt9MxYs60vYNMcG1
15 | CQ8ZjasXSp96qrrsE8nPYn7aOXuZati3IfkLlaB0WQki/nbQ8V7nusK1AoGBAPRu
16 | GmvkFethbQrTbuK2+eOLqQRgJT8paClaBZZTXRi82Xxmbhf/GYAramVvjcpxcQ0O
17 | RdbFJTxQlg+7t6jMW06df2Vj/kxOLvtobxh2UvXp8r381RJzMcOO2M9iZlOIthn6
18 | h73Xg5N8aVFTS8PN1iP3PHr6xq4iXJamYaLNNL9lAoGBAKXbP0Oxq1Lj2FQKcw1N
19 | Kd/ct+7pir3RFENv5tMf4V4tLy+s4GduJo+GlS7kzUpZfnSS7z3CcVNHDOjCRoj4
20 | eaxXGaeuZg0QTtaQ8DrqQFnsOKYRygh42W0Gn7nOTTaJl3vnUZNJJBrOsiYk3+k3
21 | rVjin60AdGNCvXJW48NN6LpVAoGBAKibNoCL8g7OwqAAHvImk5NBqFILXEYIcwBr
22 | R4Vddc91nXQxV+oXnuiJijf0TlOCEyCVYtl2XmwPjqPFsjeu16EQBWvUIPtTxxbH
23 | ADNYk3tsaHRjbjru2TnzVF0hnEItAKhE59OtUOawoBloItArMXbXuZF/YQOHUmTc
24 | 2maptKP9AoGAFTmY9b6h0pe70tUYnmCyZa2yS6WTTkTSKkG1dp25aTmlnQaZ56zR
25 | YEeNIJB1ZnBf4gV/Wt+LENgdS/Nm0qIPM14BmExxCi8nimPxL9ylQnsQhAhs6oBO
26 | RmGAs/E3Sjc9V/oKDTrLrGGlEVbJ5/gMMAnuulCHtGomRc0EdAey6FI=
27 | -----END RSA PRIVATE KEY-----
28 |
--------------------------------------------------------------------------------
/apps/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bundlemon-website",
3 | "version": "1.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "scripts": {
7 | "build": "yarn --cwd ../../ nx build website --verbose",
8 | "build:analyze": "yarn --cwd ../../ nx build:analyze website --verbose",
9 | "serve": "yarn --cwd ../../ nx serve website --verbose",
10 | "lint": "yarn --cwd ../../ nx lint website --verbose",
11 | "serve:preview": "yarn --cwd ../../ nx preview website --verbose",
12 | "bundlemon": "node -r @swc-node/register ../../packages/bundlemon/bin/bundlemon.ts",
13 | "deploy": "netlify deploy --no-build --dir dist/apps/website"
14 | },
15 | "dependencies": {
16 | "@emotion/react": "^11.11.1",
17 | "@emotion/styled": "^11.11.0",
18 | "@mui/icons-material": "^5.14.13",
19 | "@mui/lab": "^5.0.0-alpha.148",
20 | "@mui/material": "^5.14.13",
21 | "bundlemon-utils": "^2.0.1",
22 | "material-react-table": "^1.15.0",
23 | "mobx": "^6.10.2",
24 | "mobx-react-lite": "^4.0.5",
25 | "notistack": "^3.0.1",
26 | "react": "^18.2.0",
27 | "react-dom": "^18.2.0",
28 | "react-query": "^3.39.3",
29 | "react-router": "^6.16.0",
30 | "react-router-dom": "^6.16.0",
31 | "recharts": "^2.8.0"
32 | },
33 | "devDependencies": {
34 | "@types/react": "^18.2.28",
35 | "@types/react-dom": "^18.2.13",
36 | "@types/react-router-dom": "^5.3.3",
37 | "@types/recharts": "^1.8.25",
38 | "@vitejs/plugin-basic-ssl": "^1.1.0",
39 | "eslint-plugin-jsx-a11y": "^6.8.0",
40 | "eslint-plugin-react": "^7.33.2",
41 | "eslint-plugin-react-hooks": "^4.6.0",
42 | "netlify-cli": "^21.6.0",
43 | "vite": "^5.4.21",
44 | "vite-bundle-analyzer": "^0.17.0",
45 | "vite-tsconfig-paths": "^4.3.2"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/components/ReportsChart/ReportsChart.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react-lite';
2 | import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
3 | import LegendDataTable from './components/LegendDataTable';
4 | import { getVal, bytesTickFormatter, dateTickFormatter } from './utils';
5 | import CustomTooltip from './components/CustomTooltip';
6 |
7 | import type ReportsStore from './ReportsStore';
8 |
9 | interface ReportsChartProps {
10 | store: ReportsStore;
11 | }
12 |
13 | const toolTipStyle = {
14 | zIndex: '3',
15 | };
16 |
17 | const ReportsChart = observer(({ store }: ReportsChartProps) => {
18 | return (
19 | <>
20 |
21 |
30 |
31 |
32 |
33 | } />
34 | {store.pathRecords.map((record) => (
35 |
44 | ))}
45 |
46 |
47 |
48 | >
49 | );
50 | });
51 |
52 | export default ReportsChart;
53 |
--------------------------------------------------------------------------------
/apps/service/src/controllers/utils/markdownReportGenerator.ts:
--------------------------------------------------------------------------------
1 | import { generateReportMarkdown } from 'bundlemon-markdown-output';
2 | import { CommitRecordsQueryResolution } from '../../consts/commitRecords';
3 | import { generateLinkToReports, GenerateLinkToReportskParams } from '../../utils/linkUtils';
4 |
5 | import type { Report, CommitRecord } from 'bundlemon-utils';
6 |
7 | interface GetReportsPageLinkParams extends GenerateLinkToReportskParams {
8 | text: string;
9 | }
10 |
11 | function getReportsPageLink({ text, ...linkParams }: GetReportsPageLinkParams): string {
12 | return `${text} `;
13 | }
14 |
15 | // TODO: max 65535 chars
16 | export function generateReportMarkdownWithLinks(report: Report): string {
17 | const {
18 | metadata: { record, baseRecord },
19 | } = report;
20 |
21 | let body = generateReportMarkdown(report);
22 |
23 | if (record || baseRecord) {
24 | const { projectId, subProject } = (record || baseRecord) as CommitRecord;
25 |
26 | const links: string[] = [];
27 |
28 | if (record) {
29 | links.push(
30 | getReportsPageLink({
31 | projectId,
32 | subProject,
33 | branch: record.branch,
34 | resolution: CommitRecordsQueryResolution.All,
35 | text: 'Current branch size history',
36 | })
37 | );
38 | }
39 |
40 | if (baseRecord) {
41 | links.push(
42 | getReportsPageLink({
43 | projectId,
44 | subProject,
45 | branch: baseRecord.branch,
46 | resolution: CommitRecordsQueryResolution.Days,
47 | text: 'Target branch size history',
48 | })
49 | );
50 | }
51 |
52 | body += `\n\n---\n${links.join(' | ')}
`;
53 | }
54 |
55 | return body;
56 | }
57 |
--------------------------------------------------------------------------------
/.github/workflows/publish-packages.yml:
--------------------------------------------------------------------------------
1 | name: publish-packages
2 |
3 | on:
4 | push:
5 | tags:
6 | - bundlemon@v*.*.*
7 | - bundlemon-markdown-output@v*.*.*
8 | - bundlemon-utils@v*.*.*
9 |
10 | jobs:
11 | test:
12 | name: publish-packages
13 | runs-on: ubuntu-22.04
14 | permissions:
15 | contents: read
16 | id-token: write # needed for provenance data generation
17 | timeout-minutes: 10
18 | steps:
19 | - name: Checkout repository
20 | uses: actions/checkout@v4
21 | with:
22 | fetch-depth: 0
23 |
24 | - name: Extract package name
25 | id: package_name
26 | run: echo "value=$(echo $GITHUB_REF_NAME | cut -d '@' -f 1)" >> $GITHUB_OUTPUT
27 |
28 | # validate the package.json version is the same as the tag
29 | - name: Validate version
30 | run: |
31 | ACTUAL_VERSION=$(jq -r '.version' packages/${{ steps.package_name.outputs.value }}/package.json)
32 | EXPECTED_VERSION=$(echo $GITHUB_REF_NAME | sed 's/.*@v//')
33 | if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then
34 | echo "Version mismatch between package.json ($ACTUAL_VERSION) and tag ($EXPECTED_VERSION)"
35 | exit 1
36 | fi
37 |
38 | - name: Install Node
39 | uses: actions/setup-node@v4
40 | with:
41 | node-version: 22
42 | registry-url: https://registry.npmjs.org/
43 | cache: yarn
44 |
45 | - name: Install dependencies
46 | run: yarn
47 |
48 | - name: Build
49 | run: yarn nx build ${{ steps.package_name.outputs.value }}
50 |
51 | - name: Publish packages
52 | run: npx nx release publish --verbose --projects ${{ steps.package_name.outputs.value }}
53 | env:
54 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
55 | NPM_CONFIG_PROVENANCE: true
56 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/outputs/custom.ts:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 | import path from 'path';
3 | import fs from 'fs';
4 | import { Report } from 'bundlemon-utils';
5 | import { createLogger } from '../../../common/logger';
6 | import { validateYup } from '../../utils/validationUtils';
7 | import type { Output } from '../types';
8 |
9 | const NAME = 'custom';
10 |
11 | const logger = createLogger(`${NAME} output`);
12 |
13 | interface CustomOutputOptions {
14 | path?: string;
15 | }
16 | interface NormalizedCustomOutputOptions {
17 | path: string;
18 | }
19 |
20 | function validateOptions(options: unknown): NormalizedCustomOutputOptions {
21 | const schema: yup.SchemaOf = yup.object().required().shape({
22 | path: yup.string().required(),
23 | });
24 |
25 | const normalizedOptions = validateYup(schema, options, `${NAME} output`);
26 |
27 | if (!normalizedOptions) {
28 | throw new Error(`validation error in output "${NAME}" options`);
29 | }
30 |
31 | return normalizedOptions as NormalizedCustomOutputOptions;
32 | }
33 |
34 | const output: Output = {
35 | name: NAME,
36 | create: async ({ options }) => {
37 | const normalizedOptions = validateOptions(options);
38 |
39 | const resolvedPath = path.resolve(normalizedOptions.path);
40 |
41 | if (!fs.existsSync(resolvedPath)) {
42 | throw new Error(`custom output file not found: ${resolvedPath}`);
43 | }
44 |
45 | logger.debug(`Importing ${resolvedPath}`);
46 | const customOutput = await import(resolvedPath);
47 |
48 | if (typeof customOutput.default !== 'function') {
49 | throw new Error('custom output should export default function');
50 | }
51 |
52 | return {
53 | generate: async (report: Report): Promise => {
54 | await customOutput.default(report);
55 | },
56 | };
57 | },
58 | };
59 |
60 | export default output;
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "license": "MIT",
4 | "scripts": {
5 | "test-packages": "yarn nx run-many --target=test --projects tag:type:lib --verbose",
6 | "build-packages": "nx run-many --target=build --projects tag:type:lib --verbose",
7 | "build-packages:watch": "nx watch --projects tag:type:lib -- nx run \\$NX_PROJECT_NAME:build",
8 | "lint-packages": "yarn nx run-many --target=lint --projects tag:type:lib --verbose",
9 | "prepare": "husky install"
10 | },
11 | "devDependencies": {
12 | "@emotion/babel-plugin": "11.11.0",
13 | "@nx/esbuild": "19.3.0",
14 | "@nx/eslint": "19.3.0",
15 | "@nx/eslint-plugin": "19.3.0",
16 | "@nx/jest": "19.3.0",
17 | "@nx/js": "19.3.0",
18 | "@nx/react": "19.3.0",
19 | "@nx/vite": "19.3.0",
20 | "@nx/web": "19.3.0",
21 | "@swc-node/register": "1.9.2",
22 | "@swc/core": "1.5.7",
23 | "@types/jest": "^29.5.1",
24 | "@types/jest-when": "^3.5.2",
25 | "@types/node": "^18.16.9",
26 | "@typescript-eslint/eslint-plugin": "^6.4.0",
27 | "@typescript-eslint/parser": "^6.4.0",
28 | "@vitejs/plugin-react": "^4.3.4",
29 | "esbuild": "^0.26.0",
30 | "eslint": "^8.47.0",
31 | "eslint-config-prettier": "^9.0.0",
32 | "eslint-plugin-import": "2.27.5",
33 | "eslint-plugin-jest": "^27.2.1",
34 | "eslint-plugin-prettier": "^5.0.0",
35 | "husky": "^8.0.3",
36 | "jest": "^29.7.0",
37 | "jest-when": "^3.5.2",
38 | "lint-staged": "^15.2.2",
39 | "nx": "19.3.0",
40 | "prettier": "^3.0.2",
41 | "ts-jest": "^29.1.0",
42 | "typescript": "^5.0.4"
43 | },
44 | "engines": {
45 | "yarn": "^1.10.0"
46 | },
47 | "lint-staged": {
48 | "*.{js,jsx,ts,tsx}": [
49 | "eslint --fix"
50 | ],
51 | "*.{json,md}": [
52 | "prettier --write"
53 | ]
54 | },
55 | "workspaces": [
56 | "packages/*",
57 | "apps/*",
58 | "examples/*"
59 | ]
60 | }
61 |
--------------------------------------------------------------------------------
/apps/service/src/framework/mongo/commitRecords/__tests__/utils.spec.ts:
--------------------------------------------------------------------------------
1 | import { Compression, FileDetails } from 'bundlemon-utils';
2 | import { filesToWatchedFileHits, watchedFileHitsToFiles } from '../utils';
3 |
4 | describe('DB commit records utils', () => {
5 | test('files transform', () => {
6 | const files: FileDetails[] = [
7 | { path: 'file.js', pattern: '*.js', size: 100, compression: Compression.None },
8 | { path: 'file2.js', pattern: '*.js', size: 150, compression: Compression.None },
9 | { path: 'file.css', pattern: '*.css', size: 150, compression: Compression.Gzip, maxPercentIncrease: 5 },
10 | { path: 'index.html', pattern: 'index.html', size: 100, compression: Compression.Brotli, maxSize: 500 },
11 | {
12 | path: 'file.png',
13 | pattern: '*.png',
14 | size: 150,
15 | compression: Compression.Gzip,
16 | maxSize: 500,
17 | maxPercentIncrease: 5,
18 | },
19 | ];
20 |
21 | const hits = filesToWatchedFileHits(files);
22 | const filesAfterTransform = watchedFileHitsToFiles(hits);
23 |
24 | expect(filesAfterTransform).toEqual(files);
25 | });
26 |
27 | test('groups transform', () => {
28 | const groups: FileDetails[] = [
29 | { path: '*.js', pattern: '*.js', size: 100, compression: Compression.None },
30 | { path: '*.css', pattern: '*.css', size: 150, compression: Compression.Gzip, maxPercentIncrease: 5 },
31 | { path: 'index.html', pattern: 'index.html', size: 100, compression: Compression.Brotli, maxSize: 500 },
32 | {
33 | path: '*.png',
34 | pattern: '*.png',
35 | size: 150,
36 | compression: Compression.Gzip,
37 | maxSize: 500,
38 | maxPercentIncrease: 5,
39 | },
40 | ];
41 |
42 | const hits = filesToWatchedFileHits(groups);
43 | const groupsAfterTransform = watchedFileHitsToFiles(hits);
44 |
45 | expect(groupsAfterTransform).toEqual(groups);
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/fileDetailsUtils.ts:
--------------------------------------------------------------------------------
1 | import { getFileSize } from './getFileSize';
2 | import { getMatchFiles } from './pathUtils';
3 |
4 | import type { FileDetails } from 'bundlemon-utils';
5 | import type { NormalizedFileConfig, PathLabels } from '../types';
6 |
7 | interface GetFilesDetailsParams {
8 | baseDir: string;
9 | pathLabels: PathLabels;
10 | config: NormalizedFileConfig[];
11 | allFiles: string[];
12 | stopOnMatch: boolean;
13 | }
14 |
15 | export async function getFilesDetails({
16 | baseDir,
17 | pathLabels,
18 | config,
19 | allFiles,
20 | stopOnMatch,
21 | }: GetFilesDetailsParams): Promise {
22 | const filesConfigMap: Record = config.reduce((acc, curr) => {
23 | return { ...acc, [curr.path]: curr };
24 | }, {});
25 |
26 | const matchFiles = await getMatchFiles(baseDir, allFiles, pathLabels, Object.keys(filesConfigMap), stopOnMatch);
27 |
28 | const files: FileDetails[] = [];
29 |
30 | await Promise.all(
31 | Object.entries(matchFiles).map(async ([pattern, matchFiles]) => {
32 | const { path, ...restFileConfig } = filesConfigMap[pattern];
33 |
34 | for (const { fullPath, prettyPath } of matchFiles) {
35 | const size = await getFileSize(fullPath, restFileConfig.compression);
36 |
37 | files.push({
38 | ...restFileConfig,
39 | pattern,
40 | path: prettyPath,
41 | size,
42 | });
43 | }
44 | })
45 | );
46 |
47 | return files;
48 | }
49 |
50 | export function groupFilesByPattern(files: FileDetails[]): FileDetails[] {
51 | const groupsMap: Record = {};
52 |
53 | for (const file of files) {
54 | const { pattern, size } = file;
55 |
56 | if (!groupsMap[pattern]) {
57 | groupsMap[pattern] = {
58 | ...file,
59 | path: pattern,
60 | pattern,
61 | size: 0,
62 | };
63 | }
64 |
65 | groupsMap[pattern].size += size;
66 | }
67 |
68 | return Object.values(groupsMap);
69 | }
70 |
--------------------------------------------------------------------------------
/apps/service/src/types/schemas/githubOutput.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | import type { GithubOutputTypes, Report } from 'bundlemon-utils';
4 | import type { GetCommitRecordRequestParams } from './commitRecords';
5 | import type { BaseRequestSchema } from './common';
6 |
7 | interface ProjectIdParams {
8 | /**
9 | * @pattern ^[0-9a-fA-F]{24}$
10 | */
11 | projectId: string;
12 | }
13 |
14 | interface ProjectApiKeyHeaders {
15 | /**
16 | * @minLength 1
17 | */
18 | 'x-api-key': string;
19 | }
20 |
21 | interface CreateGithubCheckBody {
22 | report: Report;
23 | git: {
24 | owner: string;
25 | repo: string;
26 | commitSha: string;
27 | };
28 | }
29 |
30 | export interface CreateGithubCheckRequestSchema extends BaseRequestSchema {
31 | body: CreateGithubCheckBody;
32 | params: ProjectIdParams;
33 | headers: ProjectApiKeyHeaders;
34 | }
35 |
36 | interface CreateGithubCommitStatusBody {
37 | report: Report;
38 | git: {
39 | owner: string;
40 | repo: string;
41 | commitSha: string;
42 | };
43 | }
44 |
45 | export interface CreateGithubCommitStatusRequestSchema extends BaseRequestSchema {
46 | body: CreateGithubCommitStatusBody;
47 | params: ProjectIdParams;
48 | headers: ProjectApiKeyHeaders;
49 | }
50 |
51 | interface CreateGithubPrCommentBody {
52 | report: Report;
53 | git: {
54 | owner: string;
55 | repo: string;
56 | prNumber: string;
57 | };
58 | }
59 |
60 | export interface PostGithubPRCommentRequestSchema extends BaseRequestSchema {
61 | body: CreateGithubPrCommentBody;
62 | params: ProjectIdParams;
63 | headers: ProjectApiKeyHeaders;
64 | }
65 |
66 | interface GithubOutputBody {
67 | git: {
68 | owner: string;
69 | repo: string;
70 | commitSha: string;
71 | prNumber?: string;
72 | };
73 | output: Partial>;
74 | auth: { token: string } | { runId: string };
75 | }
76 |
77 | export interface GithubOutputRequestSchema extends BaseRequestSchema {
78 | body: GithubOutputBody;
79 | params: GetCommitRecordRequestParams;
80 | }
81 |
--------------------------------------------------------------------------------
/packages/bundlemon-utils/lib/diffReport/utils.ts:
--------------------------------------------------------------------------------
1 | import { DiffChange, Status, FailReason } from '../consts';
2 | import type { FileDetails, FileStatusObject } from '../types';
3 |
4 | function roundDecimals(num: number, decimals: number) {
5 | return Number(Math.round(Number(num + 'e' + decimals)) + 'e-' + decimals);
6 | }
7 |
8 | export function getPercentageDiff(a: number, b: number): number {
9 | const percent = ((a - b) / b) * 100;
10 |
11 | const diff = Number.isFinite(percent) ? roundDecimals(percent, 2) : percent;
12 |
13 | return Number.isNaN(diff) ? 0 : diff;
14 | }
15 |
16 | interface CalcChangeParams {
17 | isExistsInCurrBranch: boolean;
18 | isExistsInBaseBranch: boolean;
19 | diffBytes: number;
20 | }
21 |
22 | export function calcChange({ isExistsInCurrBranch, isExistsInBaseBranch, diffBytes }: CalcChangeParams): DiffChange {
23 | if (isExistsInCurrBranch && isExistsInBaseBranch) {
24 | // return update only if the change is greater than 10 bytes
25 | if (Math.abs(diffBytes) > 10) {
26 | return DiffChange.Update;
27 | } else {
28 | return DiffChange.NoChange;
29 | }
30 | }
31 |
32 | if (isExistsInCurrBranch) {
33 | return DiffChange.Add;
34 | }
35 |
36 | return DiffChange.Remove;
37 | }
38 |
39 | interface GetStatusParams {
40 | currBranchFile?: FileDetails;
41 | change: DiffChange;
42 | diffPercent: number;
43 | }
44 |
45 | export function getStatusObject({ currBranchFile, change, diffPercent }: GetStatusParams): FileStatusObject {
46 | const failReasons: FailReason[] = [];
47 |
48 | if (currBranchFile?.maxSize && currBranchFile.size > currBranchFile.maxSize) {
49 | failReasons.push(FailReason.MaxSize);
50 | }
51 |
52 | if (
53 | change === DiffChange.Update &&
54 | currBranchFile?.maxPercentIncrease &&
55 | diffPercent > currBranchFile.maxPercentIncrease
56 | ) {
57 | failReasons.push(FailReason.MaxPercentIncrease);
58 | }
59 |
60 | if (failReasons.length === 0) {
61 | return { status: Status.Pass };
62 | }
63 |
64 | return { status: Status.Fail, failReasons };
65 | }
66 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": ["plugin:prettier/recommended"],
4 | "ignorePatterns": ["**/*"],
5 | "plugins": ["@nx"],
6 | "overrides": [
7 | {
8 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
9 | "rules": {
10 | "@nx/enforce-module-boundaries": [
11 | "error",
12 | {
13 | "enforceBuildableLibDependency": true,
14 | "allow": [],
15 | "depConstraints": [
16 | {
17 | "sourceTag": "*",
18 | "onlyDependOnLibsWithTags": ["*"]
19 | }
20 | ]
21 | }
22 | ]
23 | }
24 | },
25 | {
26 | "files": ["*.ts", "*.tsx"],
27 | "extends": ["plugin:@nx/typescript", "prettier"],
28 | "parserOptions": {
29 | "project": "./tsconfig.*?.json"
30 | },
31 | "rules": {
32 | "@typescript-eslint/no-extra-semi": "error",
33 | "no-extra-semi": "off"
34 | }
35 | },
36 | {
37 | "files": ["*.js", "*.jsx"],
38 | "extends": ["plugin:@nx/javascript"],
39 | "rules": {
40 | "@typescript-eslint/no-extra-semi": "error",
41 | "no-extra-semi": "off"
42 | }
43 | },
44 | {
45 | "files": ["*.json"],
46 | "parser": "jsonc-eslint-parser",
47 | "rules": {}
48 | },
49 | {
50 | "files": ["**/__tests__/**/*.spec.ts"],
51 | "extends": ["plugin:jest/recommended"],
52 | "rules": {
53 | "@typescript-eslint/no-explicit-any": "off",
54 | "@typescript-eslint/no-empty-function": "off",
55 | "@typescript-eslint/no-var-requires": "off",
56 | "jest/no-disabled-tests": "off"
57 | }
58 | }
59 | ],
60 | "rules": {
61 | "@typescript-eslint/ban-ts-comment": "off",
62 | "@typescript-eslint/no-unused-vars": [
63 | "error",
64 | {
65 | "ignoreRestSiblings": true,
66 | "argsIgnorePattern": "^_"
67 | }
68 | ],
69 | "@typescript-eslint/explicit-module-boundary-types": "off",
70 | "@typescript-eslint/no-explicit-any": "off"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/analyzer/__tests__/getFileSize.spec.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import { file as calcGzipFileSize } from 'gzip-size';
3 | import { file as calcBrotliFileSize } from 'brotli-size';
4 | import { getFileSize } from '../getFileSize';
5 | import { Compression } from 'bundlemon-utils';
6 |
7 | jest.mock('fs', () => ({ readFile: jest.fn(), readFileSync: jest.fn(), promises: { readFile: jest.fn() } }));
8 | jest.mock('gzip-size');
9 | jest.mock('brotli-size');
10 |
11 | function randomInt(min: number, max: number) {
12 | return Math.floor(Math.random() * (max - min + 1) + min);
13 | }
14 |
15 | let expectedSize = 0;
16 |
17 | describe('getFileSize', () => {
18 | beforeEach(() => {
19 | jest.resetAllMocks();
20 | expectedSize = randomInt(1000, 5000);
21 | });
22 |
23 | test('comperssion: none', async () => {
24 | // @ts-expect-error mock only the needed properties that we need for the test
25 | jest.mocked(fs.promises.readFile).mockResolvedValue({ byteLength: expectedSize });
26 |
27 | const size = await getFileSize('path', Compression.None);
28 |
29 | expect(size).toEqual(expectedSize);
30 | });
31 |
32 | test('comperssion: gzip', async () => {
33 | jest.mocked(calcGzipFileSize).mockResolvedValue(expectedSize);
34 |
35 | const size = await getFileSize('path', Compression.Gzip);
36 |
37 | expect(size).toEqual(expectedSize);
38 | });
39 |
40 | test('comperssion: brotli', async () => {
41 | jest.mocked(calcBrotliFileSize).mockResolvedValue(expectedSize);
42 |
43 | const size = await getFileSize('path', Compression.Brotli);
44 |
45 | expect(size).toEqual(expectedSize);
46 | });
47 |
48 | test('comperssion: unknown', async () => {
49 | // @ts-expect-error mock only the needed properties that we need for the test
50 | jest.mocked(fs.promises.readFile).mockResolvedValue({ byteLength: expectedSize });
51 |
52 | // @ts-expect-error mock only the needed properties that we need for the test
53 | const size = await getFileSize('path', 'kjasdkjaskd');
54 |
55 | expect(size).toEqual(expectedSize);
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/outputs/console.ts:
--------------------------------------------------------------------------------
1 | import bytes from 'bytes';
2 | import chalk from 'chalk';
3 | import { Status, DiffChange, FileDetailsDiff, getReportConclusionText } from 'bundlemon-utils';
4 | import logger from '../../../common/logger';
5 | import { getDiffSizeText, getDiffPercentText } from '../utils';
6 | import type { Output } from '../types';
7 |
8 | function print(status: Status, changeText: string, message: string) {
9 | const color = status === Status.Pass ? 'green' : 'red';
10 |
11 | logger.log(` ${chalk[color](`[${status.toUpperCase()}]`)} ${changeText}${message}`);
12 | }
13 |
14 | function printDiffSection(files: FileDetailsDiff[], haveBaseRecord: boolean) {
15 | files.forEach((f) => {
16 | const changeText = haveBaseRecord ? `(${f.diff.change}) ` : '';
17 | const diffPercentText = f.diff.change === DiffChange.Update ? ' ' + getDiffPercentText(f.diff.percent) : '';
18 | const diffText = haveBaseRecord ? ` (${getDiffSizeText(f.diff.bytes)}${diffPercentText})` : '';
19 | const maxSizeText = f.maxSize ? ` ${f.size <= f.maxSize ? '<' : '>'} ${bytes(f.maxSize)}` : '';
20 |
21 | print(f.status, changeText, `${f.path}: ${bytes(f.size)}${diffText}${maxSizeText}`);
22 | });
23 | }
24 |
25 | const output: Output = {
26 | name: 'console',
27 | create: () => {
28 | return {
29 | generate: (report) => {
30 | const {
31 | files,
32 | groups,
33 | metadata: { linkToReport, baseRecord },
34 | } = report;
35 |
36 | logger.log('\n');
37 |
38 | logger.log('Files:');
39 | printDiffSection(files, !!baseRecord);
40 | logger.log('\n');
41 |
42 | if (groups.length > 0) {
43 | logger.log('Groups:');
44 | printDiffSection(groups, !!baseRecord);
45 | logger.log('\n');
46 | }
47 |
48 | logger.log(getReportConclusionText(report));
49 |
50 | if (linkToReport) {
51 | logger.log(`\nView report: ${linkToReport}`);
52 | }
53 |
54 | logger.log('\n');
55 | },
56 | };
57 | },
58 | };
59 |
60 | export default output;
61 |
--------------------------------------------------------------------------------
/apps/website/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "website",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "apps/website/src",
5 | "projectType": "application",
6 | "tags": [],
7 | "targets": {
8 | "build": {
9 | "dependsOn": ["^build"],
10 | "executor": "@nx/vite:build",
11 | "outputs": ["{options.outputPath}"],
12 | "defaultConfiguration": "production",
13 | "options": {
14 | "outputPath": "dist/{projectRoot}",
15 | "generatePackageJson": false
16 | },
17 | "configurations": {
18 | "development": {
19 | "mode": "development"
20 | },
21 | "production": {
22 | "mode": "production"
23 | }
24 | }
25 | },
26 | "build:analyze": {
27 | "dependsOn": ["^build"],
28 | "executor": "nx:run-commands",
29 | "options": {
30 | "cwd": "{projectRoot}",
31 | "commands": ["analyze"]
32 | }
33 | },
34 | "serve": {
35 | "executor": "@nx/vite:dev-server",
36 | "defaultConfiguration": "development",
37 | "options": {
38 | "buildTarget": "website:build"
39 | },
40 | "configurations": {
41 | "development": {
42 | "buildTarget": "website:build:development",
43 | "hmr": true
44 | },
45 | "production": {
46 | "buildTarget": "website:build:production",
47 | "hmr": false
48 | }
49 | }
50 | },
51 | "preview": {
52 | "executor": "@nx/vite:preview-server",
53 | "defaultConfiguration": "development",
54 | "options": {
55 | "buildTarget": "website:build"
56 | },
57 | "configurations": {
58 | "development": {
59 | "buildTarget": "website:build:development"
60 | },
61 | "production": {
62 | "buildTarget": "website:build:production"
63 | }
64 | }
65 | },
66 | "lint": {
67 | "executor": "@nx/eslint:lint",
68 | "outputs": ["{options.outputFile}"],
69 | "options": {
70 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,tsx,js,json}"],
71 | "maxWarnings": 0
72 | }
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/apps/service/src/framework/env.ts:
--------------------------------------------------------------------------------
1 | import * as env from 'env-var';
2 | import { Buffer } from 'buffer';
3 | import * as sodium from 'sodium-native';
4 |
5 | const getRequiredString = (key: string) => env.get(key).required().asString();
6 | const getOptionalString = (key: string) => env.get(key).asString();
7 | const getOptionalIntPositive = (key: string) => env.get(key).asIntPositive();
8 | const getOptionalBoolean = (key: string) => env.get(key).asBool();
9 |
10 | function generateSecretKey() {
11 | const buf = Buffer.allocUnsafe(sodium.crypto_secretbox_KEYBYTES);
12 | sodium.randombytes_buf(buf);
13 | return buf.toString('hex');
14 | }
15 |
16 | export const nodeEnv = getRequiredString('NODE_ENV');
17 | export const mongoUrl = getRequiredString('MONGO_URL');
18 |
19 | export const mongoDbName = getOptionalString('MONGO_DB_NAME') || 'bundlemon';
20 | export const mongoDbUser = getOptionalString('MONGO_DB_USER');
21 | export const mongoDbPassword = getOptionalString('MONGO_DB_PASSWORD');
22 | export const httpSchema = getOptionalString('HTTP_SCHEMA') || 'https';
23 | export const host = getOptionalString('HOST') || '0.0.0.0';
24 | export const port = getOptionalIntPositive('PORT') || 8080;
25 | export const rootDomain = getOptionalString('ROOT_DOMAIN') || 'bundlemon.dev';
26 | export const appDomain = getOptionalString('APP_DOMAIN') || rootDomain;
27 | export const apiPathPrefix = getOptionalString('API_PATH_PREFIX') || '/api';
28 | export const secretSessionKey = getOptionalString('SECRET_SESSION_KEY') || generateSecretKey();
29 | export const isTestEnv = getOptionalBoolean('IS_TEST_ENV') ?? false;
30 | export const shouldServeWebsite = getOptionalBoolean('SHOULD_SERVE_WEBSITE') ?? true;
31 | export const maxSessionAgeSeconds = getOptionalIntPositive('MAX_SESSION_AGE_SECONDS') || 60 * 60 * 6; // 6 hours
32 | export const maxBodySizeBytes = getOptionalIntPositive('MAX_BODY_SIZE_BYTES') || 1024 * 1024; // 1MB
33 | export const shouldRunDbInit = getOptionalBoolean('SHOULD_RUN_DB_INIT') ?? true;
34 |
35 | export const githubAppId = getOptionalString('GITHUB_APP_ID');
36 | export const githubAppPrivateKey = getOptionalString('GITHUB_APP_PRIVATE_KEY');
37 | export const githubAppClientId = getOptionalString('GITHUB_APP_CLIENT_ID');
38 | export const githubAppClientSecret = getOptionalString('GITHUB_APP_CLIENT_SECRET');
39 |
--------------------------------------------------------------------------------
/apps/service/src/types/schemas/commitRecords.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | import type { CommitRecordPayload, CommitRecordReviewResolution } from 'bundlemon-utils';
4 | import type {
5 | CommitRecordsQueryResolution,
6 | BaseRecordCompareTo,
7 | CreateCommitRecordAuthType,
8 | } from '../../consts/commitRecords';
9 | import type { BaseRequestSchema, BaseGetRequestSchema, ProjectIdParams } from './common';
10 |
11 | export type CreateCommitRecordProjectApiKeyAuthQuery = {
12 | authType: CreateCommitRecordAuthType.ProjectApiKey;
13 | token: string;
14 | };
15 |
16 | export type CreateCommitRecordGithubActionsAuthQuery = {
17 | authType: CreateCommitRecordAuthType.GithubActions;
18 | runId: string;
19 | };
20 |
21 | export type CreateCommitRecordRequestQuery =
22 | | CreateCommitRecordProjectApiKeyAuthQuery
23 | | CreateCommitRecordGithubActionsAuthQuery
24 | | Record;
25 |
26 | export interface CreateCommitRecordRequestSchema extends BaseRequestSchema {
27 | body: CommitRecordPayload;
28 | params: ProjectIdParams;
29 | query: CreateCommitRecordRequestQuery;
30 | }
31 |
32 | export interface GetCommitRecordRequestParams extends ProjectIdParams {
33 | /**
34 | * @pattern ^[0-9a-fA-F]{24}$
35 | */
36 | commitRecordId: string;
37 | }
38 |
39 | interface GetCommitRecordRequestQuery {
40 | /**
41 | * @default "PREVIOUS_COMMIT"
42 | */
43 | compareTo?: BaseRecordCompareTo;
44 | }
45 |
46 | export interface GetCommitRecordRequestSchema extends BaseGetRequestSchema {
47 | params: GetCommitRecordRequestParams;
48 | query: GetCommitRecordRequestQuery;
49 | }
50 |
51 | export interface GetCommitRecordsQuery {
52 | branch: string;
53 | latest?: boolean;
54 | resolution?: CommitRecordsQueryResolution;
55 | subProject?: string;
56 | olderThan?: Date;
57 | }
58 |
59 | export interface GetCommitRecordsRequestSchema extends BaseGetRequestSchema {
60 | params: ProjectIdParams;
61 | query: GetCommitRecordsQuery;
62 | }
63 |
64 | interface ReviewCommitRecordBody {
65 | resolution: CommitRecordReviewResolution;
66 | /**
67 | * @minLength 1
68 | * @maxLength 100
69 | */
70 | reason?: string;
71 | }
72 |
73 | export interface ReviewCommitRecordRequestSchema extends BaseRequestSchema {
74 | params: GetCommitRecordRequestParams;
75 | body: ReviewCommitRecordBody;
76 | }
77 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript cache
45 | *.tsbuildinfo
46 |
47 | # Optional npm cache directory
48 | .npm
49 |
50 | # Optional eslint cache
51 | .eslintcache
52 |
53 | # Microbundle cache
54 | .rpt2_cache/
55 | .rts2_cache_cjs/
56 | .rts2_cache_es/
57 | .rts2_cache_umd/
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 / generate output
79 | .nuxt
80 | dist
81 |
82 | # Gatsby files
83 | .cache/
84 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
85 | # https://nextjs.org/blog/next-9-1#public-directory-support
86 | # public
87 |
88 | # vuepress build output
89 | .vuepress/dist
90 |
91 | # Serverless directories
92 | .serverless/
93 |
94 | # FuseBox cache
95 | .fusebox/
96 |
97 | # DynamoDB Local files
98 | .dynamodb/
99 |
100 | # TernJS port file
101 | .tern-port
102 |
103 | .npmrc
104 |
105 | .vscode
106 | examples
107 | .DS_STORE
108 | build
109 | .vercel
110 | deploy.env
111 |
112 | # Local Netlify folder
113 | .netlify
114 |
115 |
116 | .nx/cache
117 | .nx/workspace-data
118 | .tmp
119 | tmp
120 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/report/generateReport.ts:
--------------------------------------------------------------------------------
1 | import { generateDiffReport, Report, CommitRecord, DiffReportInput, Status } from 'bundlemon-utils';
2 | import logger from '../../common/logger';
3 | import { createCommitRecord } from '../../common/service';
4 |
5 | import type { NormalizedConfig } from '../types';
6 |
7 | export async function generateReport(config: NormalizedConfig, input: DiffReportInput): Promise {
8 | logger.info('Start generating report');
9 |
10 | const subProject = config.subProject;
11 | let record: CommitRecord | undefined;
12 | let baseRecord: CommitRecord | undefined;
13 | let linkToReport: string | undefined;
14 |
15 | if (!config.remote) {
16 | logger.warn('remote flag is OFF, showing only local results');
17 | } else {
18 | const { gitVars } = config;
19 |
20 | logger.info(`Create commit record for branch "${gitVars.branch}"`);
21 |
22 | const result = await createCommitRecord(
23 | config.projectId,
24 | {
25 | subProject,
26 | ...gitVars,
27 | ...input,
28 | },
29 | config.getCreateCommitRecordAuthParams()
30 | );
31 |
32 | if (!result) {
33 | logger.error('Failed to create commit record');
34 | return undefined;
35 | }
36 |
37 | try {
38 | ({ record, baseRecord, linkToReport } = result);
39 |
40 | logger.info(`Commit record "${result.record.id}" has been successfully created`);
41 | } catch (e) {
42 | logger.error(`Failed to create commit record. result: ${JSON.stringify(result)}`, e);
43 | return undefined;
44 | }
45 | }
46 |
47 | const diffReport = generateDiffReport(
48 | input,
49 | baseRecord ? { files: baseRecord.files, groups: baseRecord.groups } : undefined
50 | );
51 |
52 | const report = {
53 | ...diffReport,
54 | metadata: { subProject, linkToReport, record, baseRecord },
55 | };
56 |
57 | logger.info('Finished generating report');
58 |
59 | // If the record and the base record have the same branch, that probably mean it's a merge commit, so no need to fail the report
60 | if (report.status === Status.Fail && record?.branch && record?.branch === baseRecord?.branch) {
61 | report.status = Status.Pass;
62 | logger.info('Merge commit detected, change report status to "Pass"');
63 | }
64 |
65 | return report;
66 | }
67 |
--------------------------------------------------------------------------------
/apps/website/src/components/Layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { observer } from 'mobx-react-lite';
3 | import { AppBar, Box, IconButton, Stack, Tooltip } from '@mui/material';
4 | import GitHubIcon from '@mui/icons-material/GitHub';
5 | import LinkNoStyles from '@/components/LinkNoStyles';
6 | import Logo from './components/Logo';
7 | import ThemeModeToggle from './components/ThemeModeToggle';
8 | import UserSection from './components/UserSection';
9 | import { configStore } from '@/stores/ConfigStore';
10 |
11 | const StyledAppBar = styled(AppBar)`
12 | display: flex;
13 | flex-direction: row;
14 | align-items: center;
15 | height: 64px;
16 | padding: ${({ theme }) => theme.spacing(1, 3)};
17 |
18 | color: ${({ theme }) => theme.palette.text.primary};
19 | background-color: ${({ theme }) => theme.palette.background.default};
20 |
21 | svg: {
22 | margin-right: ${({ theme }) => theme.spacing(2)};
23 | }
24 | `;
25 |
26 | const LogoText = styled(LinkNoStyles)`
27 | font-weight: 500;
28 | font-size: 1.25rem;
29 | `;
30 |
31 | const MainContainer = styled.main`
32 | display: flex;
33 | flex-direction: column;
34 | min-height: 100%;
35 | width: 100%;
36 | padding: ${({ theme }) => theme.spacing(11, 6, 2, 6)};
37 | background-color: ${({ theme }) => theme.palette.background.default};
38 | `;
39 |
40 | const Layout = observer(({ children }: React.PropsWithChildren) => {
41 | if (configStore.error) {
42 | return {configStore.error} ;
43 | }
44 |
45 | if (!configStore.isLoaded) {
46 | return null;
47 | }
48 |
49 | return (
50 | <>
51 |
52 |
53 | BundleMon
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | {children ||
}
66 | >
67 | );
68 | });
69 |
70 | export default Layout;
71 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportPage/components/ReportTable/ReportTable.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { observer } from 'mobx-react-lite';
3 | import { MRT_ColumnDef } from 'material-react-table';
4 | import { DiffChange, FileDetailsDiff, getLimitsCellText, Status } from 'bundlemon-utils';
5 | import { StatusCell, ChangeSizeCell } from './components';
6 | import PathCell from '@/components/PathCell';
7 | import bytes from 'bytes';
8 | import Table from '@/components/Table';
9 |
10 | interface ReportTableProps {
11 | data: FileDetailsDiff[];
12 | }
13 |
14 | const ReportTable = observer(({ data }: ReportTableProps) => {
15 | const columns = useMemo[]>(
16 | () => [
17 | {
18 | header: 'Status',
19 | accessorKey: 'status',
20 | Cell: ({ cell }) => ()} />,
21 | filterVariant: 'multi-select',
22 | filterSelectOptions: Object.values(Status),
23 | },
24 | {
25 | id: 'state',
26 | header: 'State',
27 | accessorKey: 'diff.change',
28 | filterVariant: 'multi-select',
29 | filterSelectOptions: Object.values(DiffChange),
30 | },
31 | {
32 | id: 'path',
33 | header: 'Path',
34 | accessorKey: 'path',
35 | Cell: ({ row }) => ,
36 | },
37 | {
38 | id: 'size',
39 | header: 'Size',
40 | accessorKey: 'size',
41 | Cell: ({ cell }) => sizeToText(cell.getValue()),
42 | enableColumnActions: false,
43 | enableColumnFilter: false,
44 | },
45 | {
46 | id: 'changeSize',
47 | header: 'Change size',
48 | accessorKey: 'diff.bytes',
49 | Cell: ({ row }) => ,
50 | enableColumnActions: false,
51 | enableColumnFilter: false,
52 | enableSorting: false,
53 | },
54 | {
55 | header: 'Limits',
56 | Cell: ({ row }) => getLimitsCellText(row.original),
57 | enableColumnActions: false,
58 | enableColumnFilter: false,
59 | enableSorting: false,
60 | },
61 | ],
62 | []
63 | );
64 |
65 | return ;
66 | });
67 |
68 | export default ReportTable;
69 |
70 | function sizeToText(size?: number) {
71 | return size ? bytes(size) : '-';
72 | }
73 |
--------------------------------------------------------------------------------
/docs/privacy.md:
--------------------------------------------------------------------------------
1 | # Privacy
2 |
3 | ## Data stored in the free hosted service
4 |
5 | When using the free hosted service, the following information is stored for each record (commit) ([exact type definition](../apps/service/src/framework/mongo/commitRecords/types.ts#L30)):
6 |
7 | - Project ID - stored in a separate collection
8 | - For GitHub projects, the collection contains owner and repository names
9 | - Sub project name (if specified)
10 | - Branch name
11 | - Commit SHA
12 | - Base branch name (for pull requests)
13 | - Pull request number
14 | - Commit message - Only when `includeCommitMessage` is enabled
15 | - Files / Groups
16 | - Pattern
17 | - Friendly name (if specified)
18 | - File path
19 | - Size
20 | - Compression type
21 | - Size limits
22 |
23 | Example record:
24 |
25 | ```json
26 | {
27 | "_id": "6897284dd96dcac9c6449c84",
28 | "branch": "dependabot/npm_and_yarn/npm_and_yarn-45ede89f0a",
29 | "commitSha": "472e405f5261fc02f3af8ba491b5fc121dbff98d",
30 | "baseBranch": "main",
31 | "prNumber": "245",
32 | "projectId": "60a928cfc1ab380009f5cc0b",
33 | "creationDate": "2025-08-09T10:51:57.069Z",
34 | "files": [
35 | {
36 | "compression": "none",
37 | "pattern": "index.html",
38 | "matches": [
39 | {
40 | "path": "index.html",
41 | "size": 869
42 | }
43 | ]
44 | },
45 | {
46 | "compression": "none",
47 | "friendlyName": "JS files",
48 | "pattern": "assets/**/*-.js",
49 | "matches": [
50 | {
51 | "path": "assets/Alert-(hash).js",
52 | "size": 5913
53 | },
54 | {
55 | "path": "assets/AlertTitle-(hash).js",
56 | "size": 659
57 | }
58 | ]
59 | },
60 | {
61 | "compression": "none",
62 | "pattern": "assets/Main-*-.js",
63 | "matches": [
64 | {
65 | "path": "assets/Main-index-(hash).js",
66 | "size": 473258
67 | }
68 | ]
69 | }
70 | ],
71 | "groups": [
72 | {
73 | "compression": "none",
74 | "friendlyName": "JS files",
75 | "pattern": "**/*.js",
76 | "size": 1252544
77 | }
78 | ],
79 | "outputs": {
80 | "github": {
81 | "owner": "LironEr",
82 | "repo": "bundlemon",
83 | "outputs": {
84 | "commitStatus": 38346868576,
85 | "prComment": 3170605595
86 | }
87 | }
88 | }
89 | }
90 | ```
91 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/outputs/__tests__/utils.spec.ts:
--------------------------------------------------------------------------------
1 | import { parseOutput, getSignText, getDiffSizeText, getDiffPercentText } from '../utils';
2 |
3 | describe('output utils', () => {
4 | beforeEach(() => {
5 | jest.resetAllMocks();
6 | });
7 |
8 | describe('parseOutput', () => {
9 | test('only name', () => {
10 | const expectedName = 'test';
11 | const { name, options } = parseOutput(expectedName);
12 |
13 | expect(name).toEqual(expectedName);
14 | expect(options).toBeUndefined();
15 | });
16 |
17 | test('array, no options', () => {
18 | const expectedName = 'test';
19 | const { name, options } = parseOutput([expectedName, undefined]);
20 |
21 | expect(name).toEqual(expectedName);
22 | expect(options).toBeUndefined();
23 | });
24 |
25 | test('array, with options', () => {
26 | const expectedName = 'test';
27 | const expectedOptions = {
28 | op: {
29 | t: 'aaa',
30 | },
31 | check: true,
32 | n: 100,
33 | };
34 | const { name, options } = parseOutput([expectedName, expectedOptions]);
35 |
36 | expect(name).toEqual(expectedName);
37 | expect(options).toEqual(expectedOptions);
38 | });
39 | });
40 |
41 | describe('getSignText', () => {
42 | test('positive number', () => {
43 | const sign = getSignText(100);
44 |
45 | expect(sign).toEqual('+');
46 | });
47 |
48 | test('negative number', () => {
49 | const sign = getSignText(-8);
50 |
51 | expect(sign).toEqual('');
52 | });
53 | });
54 |
55 | describe('getDiffSizeText', () => {
56 | test('positive number', () => {
57 | const text = getDiffSizeText(100);
58 |
59 | expect(text).toEqual('+100B');
60 | });
61 |
62 | test('negative number', () => {
63 | const text = getDiffSizeText(-7823);
64 |
65 | expect(text).toEqual('-7.64KB');
66 | });
67 | });
68 |
69 | describe('getDiffPercentText', () => {
70 | test('positive number', () => {
71 | const text = getDiffPercentText(100);
72 |
73 | expect(text).toEqual('+100%');
74 | });
75 |
76 | test('negative number', () => {
77 | const text = getDiffPercentText(-42);
78 |
79 | expect(text).toEqual('-42%');
80 | });
81 |
82 | test('infinity number', () => {
83 | const text = getDiffPercentText(Infinity);
84 |
85 | expect(text).toEqual('');
86 | });
87 | });
88 | });
89 |
--------------------------------------------------------------------------------
/apps/website/src/pages/ReportsPage/components/ReportsResult.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, Suspense } from 'react';
2 | import styled from '@emotion/styled';
3 | import { Alert, CircularProgress, Paper, Tabs, Tab } from '@mui/material';
4 | import { useQuery } from 'react-query';
5 | import { getCommitRecords, GetCommitRecordsQuery } from '@/services/bundlemonService';
6 | import FetchError from '@/services/FetchError';
7 | import type { CommitRecord } from 'bundlemon-utils';
8 | import ReportsChart from './ReportsChart';
9 | import { observer } from 'mobx-react-lite';
10 | import ReportsStore from './ReportsChart/ReportsStore';
11 |
12 | const Container = styled(Paper)`
13 | width: 100%;
14 | padding: ${({ theme }) => theme.spacing(2)};
15 | `;
16 |
17 | interface ReportsResultProps {
18 | projectId: string;
19 | query: GetCommitRecordsQuery;
20 | }
21 |
22 | const ReportsResult = observer(({ projectId, query }: ReportsResultProps) => {
23 | const [store] = useState(() => new ReportsStore());
24 | const {
25 | isLoading,
26 | data: commitRecords,
27 | error,
28 | } = useQuery(['projects', projectId, 'reports', query], () =>
29 | getCommitRecords(projectId, query)
30 | );
31 |
32 | useEffect(() => {
33 | store.setCommitRecords(commitRecords ?? []);
34 | }, [store, commitRecords]);
35 |
36 | const handleTabChange = (_event: React.SyntheticEvent, newTag: 'files' | 'groups') => {
37 | store.setType(newTag);
38 | };
39 |
40 | if (isLoading) {
41 | return ;
42 | }
43 |
44 | if (error) {
45 | return {error.message} ;
46 | }
47 |
48 | if (!commitRecords) {
49 | return Missing results ;
50 | }
51 |
52 | if (commitRecords.length === 0) {
53 | return (
54 |
55 | No records found for project "{projectId}", branch: "{query.branch}"
56 | {query.subProject ? `, sub project: "${query.subProject}"` : ''}
57 |
58 | );
59 | }
60 |
61 | return (
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | );
72 | });
73 |
74 | export default ReportsResult;
75 |
--------------------------------------------------------------------------------
/apps/service/src/controllers/projectsController.ts:
--------------------------------------------------------------------------------
1 | import { randomBytes } from 'crypto';
2 | import { ProjectProvider } from 'bundlemon-utils';
3 | import { createProject, getOrCreateProjectId } from '../framework/mongo/projects';
4 | import { createHash } from '../utils/hashUtils';
5 | import { createOctokitClientByAction } from '../framework/github';
6 |
7 | import type { CreateProjectResponse } from 'bundlemon-utils';
8 | import type { FastifyBaseLogger, RouteHandlerMethod } from 'fastify';
9 | import type { FastifyValidatedRoute } from '../types/schemas';
10 | import type { GetOrCreateProjectIdRequestSchema } from '../types/schemas/projects';
11 |
12 | export const createProjectController: RouteHandlerMethod = async (_req, res) => {
13 | const apiKey = randomBytes(32).toString('hex');
14 | const startKey = apiKey.substring(0, 3);
15 |
16 | const hash = await createHash(apiKey);
17 | const projectId = await createProject({ hash, startKey });
18 |
19 | const response: CreateProjectResponse = { projectId, apiKey };
20 |
21 | res.send(response);
22 | };
23 |
24 | export const getOrCreateProjectIdController: FastifyValidatedRoute = async (
25 | req,
26 | res
27 | ) => {
28 | const { body, query } = req;
29 |
30 | const authResult = await checkGetOrCreateProjectIdAuth({ body, query }, req.log);
31 |
32 | if (!authResult.authenticated) {
33 | res.status(403).send({ message: authResult.error, extraData: authResult.extraData });
34 | return;
35 | }
36 |
37 | const { provider, owner, repo } = body;
38 | const id = await getOrCreateProjectId({ provider, owner: owner.toLowerCase(), repo: repo.toLowerCase() });
39 |
40 | res.send({ id });
41 | };
42 |
43 | type CheckAuthResponse =
44 | | {
45 | authenticated: false;
46 | error: string;
47 | extraData?: Record;
48 | }
49 | | { authenticated: true };
50 |
51 | async function checkGetOrCreateProjectIdAuth(
52 | { body, query }: GetOrCreateProjectIdRequestSchema,
53 | log: FastifyBaseLogger
54 | ): Promise {
55 | const { provider, owner, repo } = body;
56 |
57 | switch (provider) {
58 | case ProjectProvider.GitHub: {
59 | return createOctokitClientByAction({ owner, repo, runId: query.runId, commitSha: query.commitSha }, log);
60 | }
61 | default: {
62 | log.warn({ provider }, 'unknown provider');
63 |
64 | return { authenticated: false, error: 'forbidden' };
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/packages/bundlemon/lib/main/types.ts:
--------------------------------------------------------------------------------
1 | import type { Compression, ProjectProvider } from 'bundlemon-utils';
2 | import type { CreateCommitRecordAuthType } from '../common/consts';
3 |
4 | export type PathLabels = Record;
5 |
6 | export interface FileConfig {
7 | friendlyName?: string;
8 | path: string;
9 | compression?: Compression;
10 | maxSize?: string;
11 | maxPercentIncrease?: number;
12 | }
13 |
14 | export interface NormalizedFileConfig extends Omit {
15 | compression: Compression;
16 | maxSize?: number;
17 | }
18 |
19 | export interface Config {
20 | subProject?: string;
21 | baseDir?: string;
22 | files?: FileConfig[];
23 | groups?: FileConfig[];
24 | pathLabels?: PathLabels;
25 | verbose?: boolean;
26 | defaultCompression?: Compression;
27 | reportOutput?: (string | [string, unknown])[];
28 | includeCommitMessage?: boolean;
29 | }
30 |
31 | export interface BaseNormalizedConfig extends Omit, 'files' | 'groups' | 'subProject' | 'pathLabels'> {
32 | subProject?: string;
33 | files: NormalizedFileConfig[];
34 | groups: NormalizedFileConfig[];
35 | pathLabels: PathLabels;
36 | remote: boolean;
37 | }
38 |
39 | export interface NormalizedConfigRemoteOn extends BaseNormalizedConfig {
40 | remote: true;
41 | projectId: string;
42 | gitVars: GitVars;
43 | getCreateCommitRecordAuthParams: () => CreateCommitRecordAuthParams;
44 | }
45 |
46 | export interface NormalizedConfigRemoteOff extends BaseNormalizedConfig {
47 | remote: false;
48 | }
49 |
50 | export type NormalizedConfig = NormalizedConfigRemoteOn | NormalizedConfigRemoteOff;
51 |
52 | export interface MatchFile {
53 | fullPath: string;
54 | prettyPath: string;
55 | }
56 |
57 | export interface GitVars {
58 | branch: string;
59 | commitSha: string;
60 | baseBranch?: string;
61 | prNumber?: string;
62 | commitMsg?: string;
63 | }
64 |
65 | export type CreateCommitRecordProjectApiKeyAuthQuery = {
66 | authType: CreateCommitRecordAuthType.ProjectApiKey;
67 | token: string;
68 | };
69 |
70 | export type CreateCommitRecordGithubActionsAuthQuery = {
71 | authType: CreateCommitRecordAuthType.GithubActions;
72 | runId: string;
73 | };
74 |
75 | export type CreateCommitRecordAuthParams =
76 | | CreateCommitRecordProjectApiKeyAuthQuery
77 | | CreateCommitRecordGithubActionsAuthQuery;
78 |
79 | export interface GitDetails {
80 | provider: ProjectProvider;
81 | owner: string;
82 | repo: string;
83 | }
84 |
--------------------------------------------------------------------------------
/apps/website/src/components/Layout/components/UserSection.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { observer } from 'mobx-react-lite';
3 | import { Link } from 'react-router-dom';
4 | import { Button, Divider, IconButton, List, ListItem, ListItemIcon, ListItemText, Popover } from '@mui/material';
5 | import { userStore } from '@/stores/UserStore';
6 | import AccountIcon from '@mui/icons-material/AccountCircle';
7 | import LogoutIcon from '@mui/icons-material/Close';
8 | import { useSnackbar } from 'notistack';
9 | import { configStore } from '@/stores/ConfigStore';
10 |
11 | const UserSection = observer(() => {
12 | const { user } = userStore;
13 | const isLoggedIn = !!user;
14 | const [anchorEl, setAnchorEl] = useState(null);
15 | const { enqueueSnackbar } = useSnackbar();
16 |
17 | const handleMenu = (event: React.MouseEvent) => {
18 | setAnchorEl(event.currentTarget);
19 | };
20 |
21 | const closeMenu = () => {
22 | setAnchorEl(null);
23 | };
24 |
25 | const handleLogout = async () => {
26 | closeMenu();
27 |
28 | await userStore.logout();
29 |
30 | enqueueSnackbar('Successfully logged out', { variant: 'success' });
31 | };
32 |
33 | if (!configStore.githubAppClientId) {
34 | return null;
35 | }
36 |
37 | if (isLoggedIn) {
38 | return (
39 | <>
40 |
41 |
42 |
43 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | >
68 | );
69 | }
70 |
71 | return (
72 |
73 | Login
74 |
75 | );
76 | });
77 |
78 | export default UserSection;
79 |
--------------------------------------------------------------------------------