├── .dockerignore ├── .flake8 ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── config.yml │ └── feature.md ├── pull_request_template.md └── workflows │ ├── black.yml │ ├── build-base.yml │ ├── build.yml │ ├── pre-commit.yml │ └── pytest.yml ├── .gitignore ├── .gitpod.yml ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.dev ├── Dockerfile.prebuilt ├── LICENSE ├── README.md ├── SECURITY.md ├── backend ├── __init__.py ├── analytics_server │ ├── app.py │ ├── env.py │ ├── mhq │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── ai │ │ │ │ └── dora_ai.py │ │ │ ├── bookmark.py │ │ │ ├── deployment_analytics.py │ │ │ ├── hello.py │ │ │ ├── incidents.py │ │ │ ├── integrations.py │ │ │ ├── pull_requests.py │ │ │ ├── request_utils.py │ │ │ ├── resources │ │ │ │ ├── __init__.py │ │ │ │ ├── code_resouces.py │ │ │ │ ├── core_resources.py │ │ │ │ ├── deployment_resources.py │ │ │ │ ├── incident_resources.py │ │ │ │ └── settings_resource.py │ │ │ ├── settings.py │ │ │ ├── sync.py │ │ │ └── teams.py │ │ ├── config │ │ │ └── config.ini │ │ ├── exapi │ │ │ ├── __init__.py │ │ │ ├── git_incidents.py │ │ │ ├── github.py │ │ │ ├── gitlab.py │ │ │ └── models │ │ │ │ ├── __init__.py │ │ │ │ ├── git_incidents.py │ │ │ │ ├── github.py │ │ │ │ └── gitlab.py │ │ ├── service │ │ │ ├── __init__.py │ │ │ ├── ai │ │ │ │ └── ai_analytics_service.py │ │ │ ├── bookmark │ │ │ │ ├── __init__.py │ │ │ │ ├── bookmark.py │ │ │ │ └── bookmark_types.py │ │ │ ├── code │ │ │ │ ├── __init__.py │ │ │ │ ├── integration.py │ │ │ │ ├── lead_time.py │ │ │ │ ├── models │ │ │ │ │ ├── lead_time.py │ │ │ │ │ └── org_repo.py │ │ │ │ ├── pr_analytics.py │ │ │ │ ├── pr_filter.py │ │ │ │ ├── repository_service.py │ │ │ │ └── sync │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── etl_code_analytics.py │ │ │ │ │ ├── etl_code_factory.py │ │ │ │ │ ├── etl_github_handler.py │ │ │ │ │ ├── etl_gitlab_handler.py │ │ │ │ │ ├── etl_handler.py │ │ │ │ │ ├── etl_provider_handler.py │ │ │ │ │ ├── models.py │ │ │ │ │ ├── revert_pr_gitlab_sync.py │ │ │ │ │ └── revert_prs_github_sync.py │ │ │ ├── core │ │ │ │ └── teams.py │ │ │ ├── deployments │ │ │ │ ├── __init__.py │ │ │ │ ├── analytics.py │ │ │ │ ├── deployment_pr_mapper.py │ │ │ │ ├── deployment_service.py │ │ │ │ ├── deployments_factory_service.py │ │ │ │ ├── factory.py │ │ │ │ ├── models │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── adapter.py │ │ │ │ │ └── models.py │ │ │ │ ├── pr_deployments_service.py │ │ │ │ └── workflow_deployments_service.py │ │ │ ├── external_integrations_service.py │ │ │ ├── incidents │ │ │ │ ├── __init__.py │ │ │ │ ├── incident_filter.py │ │ │ │ ├── incidents.py │ │ │ │ ├── integration.py │ │ │ │ ├── models │ │ │ │ │ ├── adapter.py │ │ │ │ │ └── mean_time_to_recovery.py │ │ │ │ └── sync │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── etl_git_incidents_handler.py │ │ │ │ │ ├── etl_handler.py │ │ │ │ │ ├── etl_incidents_factory.py │ │ │ │ │ └── etl_provider_handler.py │ │ │ ├── merge_to_deploy_broker │ │ │ │ ├── __init__.py │ │ │ │ ├── mtd_handler.py │ │ │ │ └── utils.py │ │ │ ├── query_validator.py │ │ │ ├── settings │ │ │ │ ├── __init__.py │ │ │ │ ├── configuration_settings.py │ │ │ │ ├── default_settings_data.py │ │ │ │ ├── models.py │ │ │ │ └── setting_type_validator.py │ │ │ ├── sync_data.py │ │ │ └── workflows │ │ │ │ ├── __init__.py │ │ │ │ ├── integration.py │ │ │ │ ├── sync │ │ │ │ ├── __init__.py │ │ │ │ ├── etl_github_actions_handler.py │ │ │ │ ├── etl_handler.py │ │ │ │ ├── etl_provider_handler.py │ │ │ │ └── etl_workflows_factory.py │ │ │ │ └── workflow_filter.py │ │ ├── store │ │ │ ├── __init__.py │ │ │ ├── initialise_db.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── code │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── enums.py │ │ │ │ │ ├── filter.py │ │ │ │ │ ├── pull_requests.py │ │ │ │ │ ├── repository.py │ │ │ │ │ └── workflows │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── enums.py │ │ │ │ │ │ ├── filter.py │ │ │ │ │ │ └── workflows.py │ │ │ │ ├── core │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── organization.py │ │ │ │ │ ├── teams.py │ │ │ │ │ └── users.py │ │ │ │ ├── incidents │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── enums.py │ │ │ │ │ ├── filter.py │ │ │ │ │ ├── incidents.py │ │ │ │ │ └── services.py │ │ │ │ ├── integrations │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── enums.py │ │ │ │ │ └── integrations.py │ │ │ │ └── settings │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── configuration_settings.py │ │ │ │ │ └── enums.py │ │ │ └── repos │ │ │ │ ├── __init__.py │ │ │ │ ├── code.py │ │ │ │ ├── core.py │ │ │ │ ├── incidents.py │ │ │ │ ├── integrations.py │ │ │ │ ├── settings.py │ │ │ │ └── workflows.py │ │ └── utils │ │ │ ├── __init__.py │ │ │ ├── cryptography.py │ │ │ ├── dict.py │ │ │ ├── diffparser.py │ │ │ ├── github.py │ │ │ ├── lock.py │ │ │ ├── log.py │ │ │ ├── regex.py │ │ │ ├── string.py │ │ │ └── time.py │ ├── sync_app.py │ └── tests │ │ ├── __init__.py │ │ ├── exapi │ │ ├── __init__.py │ │ └── test_github.py │ │ ├── factories │ │ ├── __init__.py │ │ └── models │ │ │ ├── __init__.py │ │ │ ├── code.py │ │ │ ├── exapi │ │ │ ├── __init__.py │ │ │ ├── github.py │ │ │ └── gitlab.py │ │ │ └── incidents.py │ │ ├── service │ │ ├── Incidents │ │ │ ├── sync │ │ │ │ ├── __init__.py │ │ │ │ └── test_etl_git_incidents_handler.py │ │ │ ├── test_change_failure_rate.py │ │ │ ├── test_deployment_incident_mapper.py │ │ │ ├── test_incident_types_setting.py │ │ │ ├── test_mean_time_to_recovery.py │ │ │ └── test_team_pr_incidents.py │ │ ├── __init__.py │ │ ├── code │ │ │ ├── __init__.py │ │ │ ├── sync │ │ │ │ ├── __init__.py │ │ │ │ ├── test_etl_code_analytics.py │ │ │ │ ├── test_etl_github_handler.py │ │ │ │ ├── test_etl_gitlab_handler.py │ │ │ │ └── test_revert_pr_gitlab_sync.py │ │ │ └── test_lead_time_service.py │ │ ├── deployments │ │ │ ├── __init__.py │ │ │ ├── test_deployment_frequency.py │ │ │ └── test_deployment_pr_mapper.py │ │ └── workflows │ │ │ ├── __init__.py │ │ │ └── sync │ │ │ ├── __init__.py │ │ │ └── test_etl_github_actions_handler.py │ │ ├── utilities.py │ │ └── utils │ │ ├── dict │ │ ├── test_get_average_of_dict_values.py │ │ └── test_get_key_to_count_map.py │ │ ├── string │ │ └── test_is_bot_name.py │ │ └── time │ │ ├── test_fill_missing_week_buckets.py │ │ └── test_generate_expanded_buckets.py ├── dev-requirements.txt ├── dev_scripts │ └── make_new_setting.py ├── env.example └── requirements.txt ├── cli ├── .eslintignore ├── .eslintrc.cjs ├── .gitattributes ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── libdefs │ └── ink.ts ├── package.json ├── readme.md ├── source │ ├── __tests__ │ │ └── test.tsx │ ├── app.tsx │ ├── cli.tsx │ ├── constants.ts │ ├── hooks │ │ ├── useLogs.tsx │ │ ├── useLogsFromAllSources.tsx │ │ ├── usePreCheck.ts │ │ └── usePrevious.ts │ ├── precheck.ts │ ├── slices │ │ └── app.ts │ ├── store │ │ ├── index.ts │ │ └── rootReducer.ts │ └── utils │ │ ├── circularBuffer.ts │ │ ├── line-limit.ts │ │ ├── project-root.ts │ │ ├── run-command.ts │ │ └── update-checker.ts ├── tsconfig.dev.json ├── tsconfig.json └── yarn.lock ├── database-docker ├── Dockerfile ├── README.md ├── db │ ├── migrations │ │ ├── 20240404142732_init.sql │ │ ├── 20240430142502_delete_id_team_incident_service_and_team_repos_prod_branch.sql │ │ ├── 20240503060203_delete_lead_time_column.sql │ │ └── 20240503073715_add-url=in-incidents.sql │ └── schema.sql ├── dbwait.sh └── docker-compose.yml ├── dev.sh ├── docker-compose.yml ├── env.example ├── local-setup.sh ├── media_files ├── banner.gif ├── banner.png ├── logo.png └── product_demo_1.gif ├── setup_utils ├── cronjob.txt ├── generate_config_ini.sh ├── init_db.sh ├── start.sh ├── start_api_server.sh ├── start_frontend.sh ├── start_sync_server.sh └── supervisord.conf ├── version.txt └── web-server ├── .eslintignore ├── .eslintrc.js ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── app └── api │ └── stream │ └── route.ts ├── http-server.js ├── jest.config.js ├── jest.setup.js ├── libdefs ├── ambient.d.ts ├── chartjs-plugin-trendline.d.ts ├── font.d.ts ├── img.d.ts ├── intl.d.ts └── next-auth.d.ts ├── middleware.ts ├── next-env.d.ts ├── next.config.js ├── next.d.ts ├── package.json ├── pages ├── 404.tsx ├── _app.tsx ├── _document.tsx ├── _error.js ├── api │ ├── auth │ │ └── session.ts │ ├── db_status.ts │ ├── hello.ts │ ├── integrations │ │ ├── github │ │ │ ├── orgs.ts │ │ │ └── selected.ts │ │ ├── index.ts │ │ └── integrations-map.ts │ ├── internal │ │ ├── [org_id] │ │ │ ├── __tests__ │ │ │ │ └── github.test.ts │ │ │ ├── gh_org.ts │ │ │ ├── git_provider_org.ts │ │ │ ├── incident_services.ts │ │ │ ├── integrations │ │ │ │ ├── incident_providers.ts │ │ │ │ └── workflows.ts │ │ │ ├── settings.ts │ │ │ ├── sync_repos.ts │ │ │ └── utils.ts │ │ ├── ai │ │ │ ├── dora_metrics.ts │ │ │ └── models.ts │ │ ├── deployments │ │ │ └── prs.ts │ │ ├── hello.ts │ │ ├── team │ │ │ └── [team_id] │ │ │ │ ├── deployment_analytics.ts │ │ │ │ ├── deployment_freq.ts │ │ │ │ ├── deployment_prs.ts │ │ │ │ ├── dora_metrics.ts │ │ │ │ ├── excluded_prs.ts │ │ │ │ ├── get_incidents.ts │ │ │ │ ├── incident_prs_filter.ts │ │ │ │ ├── incident_services.ts │ │ │ │ ├── incidents_filter.ts │ │ │ │ ├── insights.ts │ │ │ │ ├── repo_branches.ts │ │ │ │ ├── resolved_incidents.ts │ │ │ │ ├── revert_prs.ts │ │ │ │ └── settings.ts │ │ ├── track.ts │ │ └── version.ts │ ├── internal_status.ts │ ├── resources │ │ ├── deployment_source.ts │ │ ├── orgs │ │ │ └── [org_id] │ │ │ │ ├── filter_users.ts │ │ │ │ ├── integration.ts │ │ │ │ ├── onboarding.ts │ │ │ │ ├── repos.ts │ │ │ │ └── teams │ │ │ │ ├── index.ts │ │ │ │ ├── team_branch_map.ts │ │ │ │ └── v2.ts │ │ ├── search │ │ │ ├── teams.ts │ │ │ └── user.ts │ │ ├── share.ts │ │ ├── team_repos.ts │ │ └── teams │ │ │ └── [team_id] │ │ │ └── unsynced_repos.ts │ └── status.ts ├── dora-metrics │ └── index.tsx ├── index.tsx ├── integrations.tsx ├── server-admin.tsx ├── settings.tsx ├── system-logs.tsx ├── teams │ └── index.tsx └── welcome.tsx ├── playwright.config.ts ├── public ├── _redirects ├── assets │ ├── PAT_permissions.png │ └── gitlabPAT.png ├── favicon.ico ├── icon-192x192.png ├── icon-256x256.png ├── icon-384x384.png ├── icon-512x512.png ├── icon-96x96.png ├── imageStatusApiWorker.js ├── manifest.json ├── robots.txt └── static │ └── images │ ├── placeholders │ ├── illustrations │ │ ├── coming-soon.svg │ │ └── no-data.svg │ └── logo │ │ ├── google-icon.svg │ │ └── google.svg │ └── status │ ├── 404.svg │ ├── 500.svg │ ├── coming-soon.svg │ └── maintenance.svg ├── scripts ├── build.sh ├── server-init.sh ├── utils.sh └── zip.sh ├── src ├── api-helpers │ ├── axios-api-instance.ts │ ├── axios.ts │ ├── features.ts │ ├── global.ts │ ├── internal.ts │ ├── pr.ts │ ├── team.ts │ └── transformers-and-parsers.ts ├── assets │ ├── background.png │ ├── dora-icon.svg │ ├── empty-state.svg │ ├── fonts │ │ └── Inter.ttf │ ├── git-merge-line.svg │ ├── login-dashboard.svg │ ├── login-eng-team.svg │ ├── login-key-points.svg │ ├── login-personal-goals.svg │ ├── login-predictive-analytics.svg │ ├── login-presentation.svg │ ├── no-team-members.svg │ └── no-team-selected.svg ├── components │ ├── AccessDenied │ │ └── error.svg │ ├── AiButton.tsx │ ├── AnimatedInputWrapper │ │ ├── AnimatedInputWrapper.tsx │ │ └── slider.module.css │ ├── AppErrors │ │ ├── AppErrors.tsx │ │ └── disconnected.svg │ ├── AppHead.tsx │ ├── AsyncSelect.tsx │ ├── Authenticated │ │ └── index.tsx │ ├── AvatarPageTitle.tsx │ ├── BranchSelector.tsx │ ├── Chart2 │ │ ├── InternalChart2.tsx │ │ └── index.tsx │ ├── ChipInput.tsx │ ├── DateRangePicker │ │ ├── index.tsx │ │ ├── useDateRangeUpdateHandler.tsx │ │ └── utils.ts │ ├── DoraMetricsConfigurationSettings.tsx │ ├── DoraScore.tsx │ ├── DoraScoreV2.tsx │ ├── EmptyState.tsx │ ├── ErrorBoundaryFallback │ │ ├── err-pattern.png │ │ └── index.tsx │ ├── FeatureFlagOverrides.tsx │ ├── FixedContentRefreshLoader │ │ └── FixedContentRefreshLoader.tsx │ ├── FlexBox.tsx │ ├── GithubButton.tsx │ ├── HeaderBtn.tsx │ ├── Hotkey.tsx │ ├── ImageUpdateBanner.tsx │ ├── InsightChip.tsx │ ├── LegendItem.tsx │ ├── LegendsMenu.tsx │ ├── Loader │ │ └── index.tsx │ ├── Logo │ │ ├── Logo.tsx │ │ ├── logo-long.svg │ │ └── logo.svg │ ├── MaintenanceModeDisplay │ │ └── index.tsx │ ├── MiniButton.tsx │ ├── MiniLoader.tsx │ ├── MotionComponents.tsx │ ├── NoTeamSelected.tsx │ ├── OverlayComponents │ │ ├── ChangeFailureRate.tsx │ │ ├── Dummy.tsx │ │ └── TeamEdit.tsx │ ├── OverlayPage.tsx │ ├── OverlayPageContext.tsx │ ├── PRTable │ │ ├── PrTableWithPrExclusionMenu.tsx │ │ ├── PullRequestTableColumnSelector.tsx │ │ ├── PullRequestsTable.tsx │ │ └── PullRequestsTableHead.tsx │ ├── PRTableMini │ │ ├── PullRequestsTableHeadMini.tsx │ │ └── PullRequestsTableMini.tsx │ ├── PageContentWrapper.tsx │ ├── PageHeader.tsx │ ├── PageTitleWrapper │ │ └── index.tsx │ ├── ProgressBar.tsx │ ├── RepoCard.tsx │ ├── Scrollbar │ │ └── index.tsx │ ├── Service │ │ ├── SystemLog │ │ │ ├── FormattedLog.tsx │ │ │ ├── PlainLog.tsx │ │ │ ├── SystemLogErrorMessage.tsx │ │ │ └── SystemLogsErrorFllback.tsx │ │ └── SystemStatus.tsx │ ├── Settings │ │ └── SyncDaysSetting.tsx │ ├── Shared.tsx │ ├── SimpleAvatar.tsx │ ├── SomethingWentWrong │ │ ├── SomethingWentWrong.tsx │ │ └── error.svg │ ├── Tabs.tsx │ ├── TeamIncidentPRsFilter.tsx │ ├── TeamProductionBranchSelector.tsx │ ├── TeamSelector │ │ ├── DatePopover.tsx │ │ ├── TeamPopover.tsx │ │ ├── TeamSelector.tsx │ │ ├── defaultPopoverProps.tsx │ │ ├── integrations-data.svg │ │ ├── team-data.svg │ │ └── useTeamSelectorSetup.tsx │ ├── Teams │ │ ├── CreateTeams.tsx │ │ └── useTeamsConfig.tsx │ ├── TeamsList.tsx │ ├── Text │ │ └── index.tsx │ ├── TicketsTableAddons │ │ └── SearchInput.tsx │ ├── TopLevelLogicComponent.tsx │ ├── TrendsLineChart.tsx │ ├── WorkflowSelector.tsx │ └── WrapperComponents.tsx ├── constants │ ├── api.ts │ ├── db.ts │ ├── error.ts │ ├── events.ts │ ├── feature.ts │ ├── generic.ts │ ├── integrations.ts │ ├── lang-colors.ts │ ├── log-formatter.ts │ ├── notification.ts │ ├── overlays.ts │ ├── relations.ts │ ├── routes.ts │ ├── service.ts │ ├── stream.ts │ ├── ui-states.ts │ ├── urls.ts │ └── useRoute.ts ├── content │ ├── Cockpit │ │ └── codeMetrics │ │ │ └── shared.tsx │ ├── Dashboards │ │ ├── ConfigureGithubModalBody.tsx │ │ ├── ConfigureGitlabModalBody.tsx │ │ ├── GithubIntegrationCard.tsx │ │ ├── GitlabIntegrationCard.tsx │ │ ├── githubIntegration.tsx │ │ └── useIntegrationHandlers.tsx │ ├── DoraMetrics │ │ ├── AIAnalysis │ │ │ ├── AIAnalysis.tsx │ │ │ └── rocket-graphic.svg │ │ ├── ClassificationPills.tsx │ │ ├── CorelationInsightCardFooter.tsx │ │ ├── DeploymentWithIncidentsMenuItem.tsx │ │ ├── DoraCards │ │ │ ├── ChangeFailureRateCard.tsx │ │ │ ├── ChangeTimeCard.tsx │ │ │ ├── MeanTimeToRestoreCard.tsx │ │ │ ├── NoIncidentsLabel.tsx │ │ │ ├── SkeletalCard.tsx │ │ │ ├── WeeklyDeliveryVolumeCard.tsx │ │ │ ├── sharedComponents.tsx │ │ │ └── sharedHooks.tsx │ │ ├── DoraMetricsBody.tsx │ │ ├── DoraMetricsComparisonPill.tsx │ │ ├── Incidents.tsx │ │ ├── IncidentsMenuItem.tsx │ │ ├── MetricExternalRead.tsx │ │ ├── MetricsClassificationsThreshold.ts │ │ ├── MetricsCommonProps.tsx │ │ ├── MissingDORAProviderLink.tsx │ │ ├── ResolvedIncidents.tsx │ │ └── getDoraLink.tsx │ ├── PullRequests │ │ ├── DeploymentFrequencyGraph.tsx │ │ ├── DeploymentInsightsOverlay.tsx │ │ ├── DeploymentItem.tsx │ │ ├── LeadTimeStatsCore.tsx │ │ ├── LegendAndStats.tsx │ │ ├── PageWrapper.tsx │ │ ├── PrsReverted.tsx │ │ ├── TeamInsightsBody.tsx │ │ └── useChangeTimePipeline.ts │ └── Service │ │ └── SystemLogs.tsx ├── contexts │ ├── .babelrc │ ├── ModalContext.tsx │ ├── SidebarContext.tsx │ └── ThirdPartyAuthContext.tsx ├── createEmotionCache.ts ├── hooks │ ├── useActiveRouteEvent.ts │ ├── useAuth.ts │ ├── useAxios.ts │ ├── useCountUp.ts │ ├── useDoraMetricsGraph │ │ ├── index.tsx │ │ ├── useDoraMetricsGraph.test.ts │ │ └── utils.ts │ ├── useEasyState.ts │ ├── useFeature.ts │ ├── useFrequentUpdateProtection.ts │ ├── useImageUpdateStatusWorker.ts │ ├── usePageRefreshCallback.ts │ ├── usePrevious.ts │ ├── useRefMounted.ts │ ├── useResizeEventTracking.tsx │ ├── useScrollTop.ts │ ├── useStateTeamConfig.tsx │ ├── useSystemLogs.tsx │ └── useTableSort.ts ├── layouts │ └── ExtendedSidebarLayout │ │ ├── Sidebar │ │ ├── SidebarMenu │ │ │ ├── MenuWrapper.tsx │ │ │ ├── SubMenuWrapper.tsx │ │ │ ├── index.tsx │ │ │ ├── item.tsx │ │ │ ├── items.tsx │ │ │ └── useFilteredSidebarItems.tsx │ │ ├── SidebarTopSection │ │ │ └── index.tsx │ │ └── index.tsx │ │ └── index.tsx ├── mocks │ ├── cockpit.ts │ ├── deployment-freq.ts │ ├── dora_metrics.ts │ ├── github.ts │ ├── icons │ │ ├── bitbucket.svg │ │ ├── circleci-icon.svg │ │ ├── gcal.svg │ │ ├── gitlab.svg │ │ ├── jira-icon.svg │ │ ├── opsgenie.svg │ │ ├── slack-icon.svg │ │ ├── zenduty.png │ │ └── zenduty.svg │ ├── incidents.ts │ ├── pull-requests.ts │ ├── repos.ts │ ├── resolved-incidents.ts │ ├── teamExcludedPrs.ts │ └── teams.ts ├── slices │ ├── actions.ts │ ├── app.ts │ ├── auth.ts │ ├── dora_metrics.ts │ ├── loadLink.ts │ ├── org.ts │ ├── service.ts │ └── team.ts ├── store │ ├── index.ts │ └── rootReducer.ts ├── theme │ ├── ThemeProvider.tsx │ ├── base.ts │ └── schemes │ │ └── theme.ts ├── types │ ├── api │ │ └── teams.ts │ ├── github.ts │ ├── octokit.ts │ ├── redux.ts │ ├── request.ts │ └── resources.ts └── utils │ ├── __tests__ │ ├── adapt_deployment_frequency.test.ts │ ├── array.test.ts │ ├── datatype.test.ts │ ├── domainCheck.test.ts │ ├── filterUtils.test.ts │ └── logFormatter.test.ts │ ├── adapt_deployment_frequency.ts │ ├── adapt_deployments.ts │ ├── array.ts │ ├── auth-supplementary.ts │ ├── auth.ts │ ├── cockpitMetricUtils.ts │ ├── code.ts │ ├── datatype.ts │ ├── date.ts │ ├── db.ts │ ├── debounce.ts │ ├── domainCheck.ts │ ├── dora.ts │ ├── enum.ts │ ├── filterUtils.ts │ ├── fn.ts │ ├── loading-messages.ts │ ├── logFormatter.ts │ ├── mock.ts │ ├── objectArray.ts │ ├── randomId.ts │ ├── redux.ts │ ├── storage.ts │ ├── stringAvatar.ts │ ├── stringFormatting.ts │ ├── trend.ts │ ├── unistring.ts │ ├── url.ts │ ├── user.ts │ └── wait.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | ./*/__pycache__ 2 | ./*/venv 3 | .git 4 | .github 5 | ./*/env.example 6 | ./*/.vscode 7 | ./*/node_modules 8 | .gitignore 9 | ./CONTRIBUTING.md 10 | ./docker-compose.yml 11 | ./*/docker-compose.yml 12 | ./Dockerfile 13 | ./Dockerfile.dev 14 | ./Dockerfile.prebuilt 15 | ./LICENSE 16 | ./README.md 17 | ./*/README.* 18 | ./dev.sh 19 | ./cli 20 | ./backend/analytics_server/mhq/config/config.ini 21 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 110 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Treat all text files without a CR (as we have Docker) 2 | * text eol=lf 3 | 4 | # Denote all files that are truly binary and should not be modified. 5 | *.png binary 6 | *.jpg binary 7 | *.jpeg binary 8 | *.gif binary 9 | *.ico binary 10 | *.gz binary 11 | *.zip binary 12 | *.ttf binary 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Create a report to help us improve 4 | labels: bug 5 | --- 6 | 7 | ### Description: 8 | 9 | 10 | 11 | ### Steps to reproduce: 12 | 13 | 1. 14 | 2. 15 | 3. 16 | 17 | ### Expected behavior: 18 | 19 | 20 | 21 | ### Actual behavior: 22 | 23 | 24 | 25 | ### Server Setup Information: 26 | 27 | 28 | 29 | 30 | - Operating System: 31 | - Deployment Method: 32 | 33 | 34 | ### Additional context 35 | 36 | 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Slack for Support & Discussions 4 | url: https://join.slack.com/t/middle-out-group/shared_invite/zt-2hg1b7i04-GdJuchJJ8Wdm8IKX6d2rxA 5 | about: Hang out with the community of Engineering Managers, Tech Leads, and CTOs. 6 | - name: Start a productivity conversation 7 | url: mailto:productivity@middlewarehq.com 8 | about: Get a tailored demo for your use cases 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: Create a feature request 4 | labels: enhancement 5 | --- 6 | 7 | 8 | ## Why do we need this ? 9 | 10 | 11 | 12 | ## Acceptance Criteria 13 | 14 | 15 | 16 | - [ ] TODO 1 17 | - [ ] TODO 2 18 | - [ ] TODO 3 19 | 20 | ## Further Comments / References 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Linked Issue(s) 2 | 3 | 4 | 5 | ## Acceptance Criteria fulfillment 6 | 7 | 8 | 9 | - [ ] Task 1 10 | - [ ] Task 2 11 | - [ ] Task 3 12 | 13 | ## Proposed changes (including videos or screenshots) 14 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | ## Further comments 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Black Integration 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | paths: 7 | - 'backend/**' 8 | - '**/*.py' 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | black: 15 | name: Black Check 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | with: 22 | path: 'apiserver' # Only checkout the api folder 23 | 24 | - name: Set up Python 3.11.6 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: "3.11.6" 28 | 29 | - name: Install Black 30 | run: python -m pip install black==24.3.0 31 | 32 | - name: Run Black Check 33 | run: black . --check 34 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main" ] 6 | paths: 7 | - 'backend/**' 8 | - '**/*.py' 9 | push: 10 | branches: [ "main" ] 11 | paths: 12 | - 'backend/**' 13 | - '**/*.py' 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: '3.11.6' 28 | 29 | - name: Install dependencies 30 | run: | 31 | cd ./backend 32 | python -m pip install --upgrade pip 33 | pip install -r requirements.txt 34 | 35 | - name: Test with pytest 36 | env: 37 | DB_HOST: "localhost" 38 | DB_NAME: "mhq-oss" 39 | DB_PASS: "postgres" 40 | DB_PORT: 5432 41 | DB_USER: "postgres" 42 | 43 | run: | 44 | pip install pytest pytest-cov 45 | pytest ./backend/analytics_server/tests/ --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html 46 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # Ports to expose 2 | tasks: 3 | - init: | 4 | command: ./dev.sh 5 | openMode: split-right 6 | 7 | ports: 8 | - port: 3333 9 | visibility: public 10 | onOpen: open-preview 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: (\.git/|\.tox/|\.venv/|build/|static/|dist/|node_modules/|__init__.py|app.py|sync_app.py) 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.0.1 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - repo: https://github.com/PyCQA/flake8 10 | rev: 7.1.1 11 | hooks: 12 | - id: flake8 13 | args: 14 | - --ignore=E501,W503,W605 15 | 16 | # I dont find any dedicated hook for this, so using a custom one 17 | - repo: local 18 | hooks: 19 | - id: linting webserver 20 | name: Linting-webserver 21 | entry: bash -c 'cd web-server && yarn lint-fix' 22 | language: system 23 | - repo: local 24 | hooks: 25 | - id: linting cli 26 | name: Linting-cli 27 | entry: bash -c 'cd cli && yarn lint-fix' 28 | language: system 29 | - repo: https://github.com/PyCQA/autoflake 30 | rev: v2.3.1 31 | hooks: 32 | - id: autoflake 33 | args: [--remove-all-unused-imports, --in-place] 34 | # black fixes code, so let it be at the last 35 | - repo: https://github.com/psf/black 36 | rev: 24.8.0 37 | hooks: 38 | - id: black 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { "directory": "./cli", "changeProcessCWD": true }, 4 | { "directory": "./web-server", "changeProcessCWD": true } 5 | ], 6 | "eslint.run": "onSave", 7 | "eslint.lintTask.enable": true, 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": "always" 10 | }, 11 | "editor.formatOnSave": false 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile.prebuilt: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim-bookworm as oss-base 2 | 3 | # Install necessary packages for building the backend 4 | RUN apt-get update && apt-get install -y --no-install-recommends \ 5 | gcc \ 6 | build-essential \ 7 | libpq-dev \ 8 | cron \ 9 | postgresql \ 10 | postgresql-contrib \ 11 | redis-server \ 12 | supervisor \ 13 | curl \ 14 | && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ 15 | && apt-get install -y nodejs \ 16 | && curl -fsSL -o /usr/local/bin/dbmate https://github.com/amacneil/dbmate/releases/download/v1.16.0/dbmate-linux-amd64 \ 17 | && chmod +x /usr/local/bin/dbmate \ 18 | && apt-get clean \ 19 | && rm -rf /var/lib/apt/lists/* 20 | -------------------------------------------------------------------------------- /backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/app.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | 3 | from flask import Flask 4 | 5 | from env import load_app_env 6 | 7 | load_app_env() 8 | 9 | from mhq.store import configure_db_with_app 10 | from mhq.api.hello import app as core_api 11 | from mhq.api.settings import app as settings_api 12 | from mhq.api.pull_requests import app as pull_requests_api 13 | from mhq.api.incidents import app as incidents_api 14 | from mhq.api.integrations import app as integrations_api 15 | from mhq.api.deployment_analytics import app as deployment_analytics_api 16 | from mhq.api.teams import app as teams_api 17 | from mhq.api.bookmark import app as bookmark_api 18 | from mhq.api.ai.dora_ai import app as ai_api 19 | 20 | from mhq.store.initialise_db import initialize_database 21 | 22 | ANALYTICS_SERVER_PORT = getenv("ANALYTICS_SERVER_PORT") 23 | 24 | app = Flask(__name__) 25 | 26 | app.register_blueprint(core_api) 27 | app.register_blueprint(settings_api) 28 | app.register_blueprint(pull_requests_api) 29 | app.register_blueprint(incidents_api) 30 | app.register_blueprint(deployment_analytics_api) 31 | app.register_blueprint(integrations_api) 32 | app.register_blueprint(teams_api) 33 | app.register_blueprint(bookmark_api) 34 | app.register_blueprint(ai_api) 35 | 36 | configure_db_with_app(app) 37 | initialize_database(app) 38 | 39 | if __name__ == "__main__": 40 | app.run(port=ANALYTICS_SERVER_PORT) 41 | -------------------------------------------------------------------------------- /backend/analytics_server/env.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | 3 | from dotenv import load_dotenv 4 | 5 | 6 | def load_app_env(): 7 | if getenv("FLASK_ENV") == "production": 8 | load_dotenv("../.env.prod") 9 | else: 10 | load_dotenv("../../.env") 11 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/mhq/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/mhq/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/mhq/api/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/mhq/api/hello.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | app = Blueprint("hello", __name__) 4 | 5 | 6 | @app.route("/", methods=["GET"]) 7 | def hello_world(): 8 | 9 | return {"message": "hello world"} 10 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/api/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/mhq/api/resources/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/mhq/api/resources/core_resources.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from mhq.store.models.core.teams import Team 3 | 4 | from mhq.store.models import Users 5 | 6 | 7 | def adapt_user_info( 8 | author: str, 9 | username_user_map: Dict[str, Users] = None, 10 | ): 11 | if not username_user_map or author not in username_user_map: 12 | return {"username": author, "linked_user": None} 13 | 14 | return { 15 | "username": author, 16 | "linked_user": { 17 | "id": str(username_user_map[author].id), 18 | "name": username_user_map[author].name, 19 | "email": username_user_map[author].primary_email, 20 | "avatar_url": username_user_map[author].avatar_url, 21 | }, 22 | } 23 | 24 | 25 | def adapt_team(team: Team): 26 | return { 27 | "id": str(team.id), 28 | "org_id": str(team.org_id), 29 | "name": team.name, 30 | "member_ids": [str(member_id) for member_id in team.member_ids], 31 | "created_at": team.created_at.isoformat(), 32 | "updated_at": team.updated_at.isoformat(), 33 | } 34 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/api/sync.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | 3 | from mhq.service.query_validator import get_query_validator 4 | from mhq.service.sync_data import trigger_data_sync 5 | from mhq.utils.lock import get_redis_lock_service 6 | from mhq.utils.log import LOG 7 | from mhq.utils.time import time_now 8 | 9 | app = Blueprint("sync", __name__) 10 | 11 | 12 | @app.route("/sync", methods=["POST"]) 13 | def sync(): 14 | default_org = get_query_validator().get_default_org() 15 | if not default_org: 16 | return jsonify({"message": "Default org not found"}), 404 17 | org_id = str(default_org.id) 18 | with get_redis_lock_service().acquire_lock("{org}:" + f"{str(org_id)}:data_sync"): 19 | try: 20 | trigger_data_sync(org_id) 21 | except Exception as e: 22 | LOG.error(f"Error syncing data for org {org_id}: {str(e)}") 23 | return {"message": "sync failed", "time": time_now().isoformat()}, 500 24 | return {"message": "sync started", "time": time_now().isoformat()} 25 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/config/config.ini: -------------------------------------------------------------------------------- 1 | [KEYS] 2 | SECRET_PRIVATE_KEY = SECRET_PRIVATE_KEY 3 | SECRET_PUBLIC_KEY = SECRET_PUBLIC_KEY 4 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/exapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/mhq/exapi/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/mhq/exapi/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/mhq/exapi/models/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/mhq/exapi/models/git_incidents.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from mhq.store.models.code import PullRequest 5 | 6 | 7 | @dataclass 8 | class RevertPRMap: 9 | revert_pr: PullRequest 10 | original_pr: PullRequest 11 | created_at: datetime 12 | updated_at: datetime 13 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/exapi/models/github.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class GitHubBaseUser: 6 | login: str = "" 7 | id: int = 0 8 | node_id: str = "" 9 | avatar_url: str = "" 10 | gravatar_id: str = "" 11 | url: str = "" 12 | html_url: str = "" 13 | followers_url: str = "" 14 | following_url: str = "" 15 | gists_url: str = "" 16 | starred_url: str = "" 17 | subscriptions_url: str = "" 18 | organizations_url: str = "" 19 | repos_url: str = "" 20 | events_url: str = "" 21 | received_events_url: str = "" 22 | type: str = "User" 23 | site_admin: bool = False 24 | contributions: int = 0 25 | 26 | def __hash__(self): 27 | return hash(self.id) 28 | 29 | def __eq__(self, other): 30 | if isinstance(other, GitHubBaseUser): 31 | return self.id == other.id 32 | return False 33 | 34 | 35 | @dataclass 36 | class GitHubContributor(GitHubBaseUser): 37 | contributions: int = 0 38 | 39 | def __hash__(self): 40 | return hash(self.id) 41 | 42 | def __eq__(self, other): 43 | if isinstance(other, GitHubContributor): 44 | return self.id == other.id 45 | return False 46 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/mhq/service/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/bookmark/__init__.py: -------------------------------------------------------------------------------- 1 | from .bookmark import BookmarkService, get_bookmark_service 2 | from .bookmark_types import BookmarkType 3 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/bookmark/bookmark_types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class BookmarkType(Enum): 5 | 6 | ORG_REPO_BOOKMARK = "ORG_REPO_BOOKMARK" 7 | INCIDENT_SERVICE_BOOKMARK = "INCIDENT_SERVICE_BOOKMARK" 8 | REPO_WORKFLOW_BOOKMARK = "REPO_WORKFLOW_BOOKMARK" 9 | MERGE_TO_DEPLOY_BOOKMARK = "MERGE_TO_DEPLOY_BOOKMARK" 10 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/code/__init__.py: -------------------------------------------------------------------------------- 1 | from .sync import sync_code_repos 2 | from .integration import get_code_integration_service 3 | from .pr_filter import apply_pr_filter 4 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/code/integration.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from mhq.store.models import UserIdentityProvider, Integration 4 | from mhq.store.repos.core import CoreRepoService 5 | 6 | CODE_INTEGRATION_BUCKET = [ 7 | UserIdentityProvider.GITHUB.value, 8 | UserIdentityProvider.GITLAB.value, 9 | ] 10 | 11 | 12 | class CodeIntegrationService: 13 | def __init__(self, core_repo_service: CoreRepoService): 14 | self.core_repo_service = core_repo_service 15 | 16 | def get_org_providers(self, org_id: str) -> List[str]: 17 | integrations: List[Integration] = ( 18 | self.core_repo_service.get_org_integrations_for_names( 19 | org_id, CODE_INTEGRATION_BUCKET 20 | ) 21 | ) 22 | if not integrations: 23 | return [] 24 | return [integration.name for integration in integrations] 25 | 26 | 27 | def get_code_integration_service(): 28 | return CodeIntegrationService(core_repo_service=CoreRepoService()) 29 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/code/models/org_repo.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from mhq.store.models.code.enums import CodeProvider, TeamReposDeploymentType 4 | 5 | 6 | @dataclass 7 | class RawTeamOrgRepo: 8 | team_id: str 9 | provider: CodeProvider 10 | name: str 11 | org_name: str 12 | slug: str 13 | idempotency_key: str 14 | default_branch: str 15 | deployment_type: TeamReposDeploymentType 16 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/code/pr_analytics.py: -------------------------------------------------------------------------------- 1 | from mhq.store.models.code import OrgRepo, PullRequest 2 | from mhq.store.repos.code import CodeRepoService 3 | 4 | from typing import List, Optional 5 | 6 | 7 | class PullRequestAnalyticsService: 8 | def __init__(self, code_repo_service: CodeRepoService): 9 | self.code_repo_service: CodeRepoService = code_repo_service 10 | 11 | def get_prs_by_ids(self, pr_ids: List[str]) -> List[PullRequest]: 12 | return self.code_repo_service.get_prs_by_ids(pr_ids) 13 | 14 | def get_team_repos(self, team_id: str) -> List[OrgRepo]: 15 | return self.code_repo_service.get_team_repos(team_id) 16 | 17 | def get_repo_by_id(self, repo_id: str) -> Optional[OrgRepo]: 18 | return self.code_repo_service.get_repo_by_id(repo_id) 19 | 20 | 21 | def get_pr_analytics_service(): 22 | return PullRequestAnalyticsService(CodeRepoService()) 23 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/code/sync/__init__.py: -------------------------------------------------------------------------------- 1 | from .etl_handler import sync_code_repos 2 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/code/sync/etl_code_factory.py: -------------------------------------------------------------------------------- 1 | from mhq.service.code.sync.etl_gitlab_handler import get_gitlab_etl_handler 2 | from mhq.service.code.sync.etl_github_handler import get_github_etl_handler 3 | from mhq.service.code.sync.etl_provider_handler import CodeProviderETLHandler 4 | from mhq.store.models.code import CodeProvider 5 | 6 | 7 | class CodeETLFactory: 8 | def __init__(self, org_id: str): 9 | self.org_id = org_id 10 | 11 | def __call__(self, provider: str) -> CodeProviderETLHandler: 12 | if provider == CodeProvider.GITHUB.value: 13 | return get_github_etl_handler(self.org_id) 14 | 15 | if provider == CodeProvider.GITLAB.value: 16 | return get_gitlab_etl_handler(self.org_id) 17 | 18 | raise NotImplementedError(f"Unknown provider - {provider}") 19 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/code/sync/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class PRPerformance: 6 | first_commit_to_open: int = -1 7 | first_review_time: int = -1 8 | rework_time: int = -1 9 | merge_time: int = -1 10 | merge_to_deploy: int = -1 11 | cycle_time: int = -1 12 | blocking_reviews: int = -1 13 | approving_reviews: int = -1 14 | requested_reviews: int = -1 15 | prs_authored_count: int = -1 16 | additions: int = -1 17 | deletions: int = -1 18 | rework_cycles: int = -1 19 | lead_time: int = -1 20 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/core/teams.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from mhq.store.models.core.teams import Team 3 | from mhq.store.repos.core import CoreRepoService 4 | 5 | 6 | class TeamService: 7 | def __init__(self, core_repo_service: CoreRepoService): 8 | self._core_repo_service = core_repo_service 9 | 10 | def get_team(self, team_id: str) -> Optional[Team]: 11 | return self._core_repo_service.get_team(team_id) 12 | 13 | def delete_team(self, team_id: str) -> Optional[Team]: 14 | return self._core_repo_service.delete_team(team_id) 15 | 16 | def create_team(self, org_id: str, name: str, member_ids: List[str] = None) -> Team: 17 | return self._core_repo_service.create_team(org_id, name, member_ids or []) 18 | 19 | def update_team( 20 | self, team_id: str, name: str = None, member_ids: List[str] = None 21 | ) -> Team: 22 | 23 | team = self._core_repo_service.get_team(team_id) 24 | 25 | if name is not None: 26 | team.name = name 27 | 28 | if member_ids is not None: 29 | team.member_ids = member_ids 30 | 31 | return self._core_repo_service.update_team(team) 32 | 33 | 34 | def get_team_service(): 35 | return TeamService(CoreRepoService()) 36 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/deployments/__init__.py: -------------------------------------------------------------------------------- 1 | from .deployment_pr_mapper import DeploymentPRMapperService 2 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/deployments/factory.py: -------------------------------------------------------------------------------- 1 | from .models.adapter import DeploymentsAdaptorFactory 2 | from mhq.service.deployments.models.models import DeploymentType 3 | from mhq.store.repos.code import CodeRepoService 4 | from mhq.store.repos.workflows import WorkflowRepoService 5 | from .deployment_pr_mapper import DeploymentPRMapperService 6 | from .deployments_factory_service import DeploymentsFactoryService 7 | from .pr_deployments_service import PRDeploymentsService 8 | from .workflow_deployments_service import WorkflowDeploymentsService 9 | 10 | 11 | def get_deployments_factory( 12 | deployment_type: DeploymentType, 13 | ) -> DeploymentsFactoryService: 14 | if deployment_type == DeploymentType.PR_MERGE: 15 | return PRDeploymentsService( 16 | CodeRepoService(), 17 | DeploymentsAdaptorFactory(DeploymentType.PR_MERGE).get_adaptor(), 18 | ) 19 | elif deployment_type == DeploymentType.WORKFLOW: 20 | return WorkflowDeploymentsService( 21 | WorkflowRepoService(), 22 | CodeRepoService(), 23 | DeploymentsAdaptorFactory(DeploymentType.WORKFLOW).get_adaptor(), 24 | DeploymentPRMapperService(), 25 | ) 26 | else: 27 | raise ValueError(f"Unknown deployment type: {deployment_type}") 28 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/deployments/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/mhq/service/deployments/models/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/deployments/models/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | from enum import Enum 4 | from voluptuous import default_factory 5 | 6 | 7 | class DeploymentType(Enum): 8 | WORKFLOW = "WORKFLOW" 9 | PR_MERGE = "PR_MERGE" 10 | 11 | 12 | class DeploymentStatus(Enum): 13 | SUCCESS = "SUCCESS" 14 | FAILURE = "FAILURE" 15 | PENDING = "PENDING" 16 | CANCELLED = "CANCELLED" 17 | 18 | 19 | @dataclass 20 | class Deployment: 21 | deployment_type: DeploymentType 22 | repo_id: str 23 | entity_id: str 24 | provider: str 25 | actor: str 26 | head_branch: str 27 | conducted_at: datetime 28 | duration: int 29 | status: DeploymentStatus 30 | html_url: str 31 | meta: dict = default_factory(dict) 32 | 33 | def __hash__(self): 34 | return hash(self.deployment_type.value + "|" + str(self.entity_id)) 35 | 36 | @property 37 | def id(self): 38 | return self.deployment_type.value + "|" + str(self.entity_id) 39 | 40 | 41 | @dataclass 42 | class DeploymentFrequencyMetrics: 43 | total_deployments: int 44 | daily_deployment_frequency: int 45 | avg_weekly_deployment_frequency: int 46 | avg_monthly_deployment_frequency: int 47 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/incidents/__init__.py: -------------------------------------------------------------------------------- 1 | from .sync import sync_org_incidents 2 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/incidents/models/adapter.py: -------------------------------------------------------------------------------- 1 | from mhq.store.models.incidents import Incident 2 | from mhq.store.models.code.pull_requests import PullRequest 3 | from mhq.store.models.incidents.enums import IncidentStatus, IncidentType 4 | 5 | 6 | def adaptIncidentPR(incident_pr: PullRequest, resolution_pr: PullRequest) -> Incident: 7 | return Incident( 8 | id=incident_pr.id, 9 | provider=incident_pr.provider, 10 | key=str(incident_pr.id), 11 | title=incident_pr.title, 12 | incident_number=int(incident_pr.number), 13 | status=IncidentStatus.RESOLVED.value, 14 | creation_date=incident_pr.state_changed_at, 15 | acknowledged_date=resolution_pr.created_at, 16 | resolved_date=resolution_pr.state_changed_at, 17 | assigned_to=resolution_pr.author, 18 | assignees=[resolution_pr.author], 19 | url=incident_pr.url, 20 | meta={}, 21 | incident_type=IncidentType.REVERT_PR, 22 | ) 23 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/incidents/models/mean_time_to_recovery.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional, Set 3 | 4 | from mhq.service.deployments.models.models import Deployment 5 | 6 | 7 | @dataclass 8 | class MeanTimeToRecoveryMetrics: 9 | mean_time_to_recovery: Optional[float] = None 10 | incident_count: int = 0 11 | 12 | 13 | @dataclass 14 | class ChangeFailureRateMetrics: 15 | failed_deployments: Set[Deployment] = None 16 | total_deployments: Set[Deployment] = None 17 | 18 | def __post_init__(self): 19 | self.failed_deployments = self.failed_deployments or set() 20 | self.total_deployments = self.total_deployments or set() 21 | 22 | @property 23 | def change_failure_rate(self): 24 | if not self.total_deployments: 25 | return 0 26 | return len(self.failed_deployments) / len(self.total_deployments) * 100 27 | 28 | @property 29 | def failed_deployments_count(self): 30 | return len(self.failed_deployments) 31 | 32 | @property 33 | def total_deployments_count(self): 34 | return len(self.total_deployments) 35 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/incidents/sync/__init__.py: -------------------------------------------------------------------------------- 1 | from .etl_handler import sync_org_incidents 2 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/incidents/sync/etl_incidents_factory.py: -------------------------------------------------------------------------------- 1 | from mhq.service.incidents.sync.etl_git_incidents_handler import ( 2 | get_incidents_sync_etl_handler, 3 | ) 4 | from mhq.service.incidents.sync.etl_provider_handler import IncidentsProviderETLHandler 5 | from mhq.store.models.incidents import IncidentProvider 6 | 7 | 8 | class IncidentsETLFactory: 9 | def __init__(self, org_id: str): 10 | self.org_id = org_id 11 | 12 | def __call__(self, provider: str) -> IncidentsProviderETLHandler: 13 | if provider == IncidentProvider.GITHUB.value: 14 | return get_incidents_sync_etl_handler(self.org_id) 15 | 16 | if provider == IncidentProvider.GITLAB.value: 17 | return get_incidents_sync_etl_handler(self.org_id) 18 | 19 | raise NotImplementedError(f"Unknown provider - {provider}") 20 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/merge_to_deploy_broker/__init__.py: -------------------------------------------------------------------------------- 1 | from .mtd_handler import process_merge_to_deploy_cache 2 | from .utils import get_merge_to_deploy_broker_utils_service, MergeToDeployBrokerUtils 3 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .configuration_settings import SettingsService, get_settings_service 2 | from .setting_type_validator import settings_type_validator 3 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/settings/default_settings_data.py: -------------------------------------------------------------------------------- 1 | from mhq.store.models.incidents import IncidentSource, IncidentType 2 | from mhq.store.models.settings import SettingType 3 | 4 | 5 | MIN_CYCLE_TIME_THRESHOLD = 3600 6 | 7 | 8 | def get_default_setting_data(setting_type: SettingType): 9 | if setting_type == SettingType.INCIDENT_SETTING: 10 | return {"title_filters": []} 11 | 12 | if setting_type == SettingType.EXCLUDED_PRS_SETTING: 13 | return {"excluded_pr_ids": []} 14 | 15 | if setting_type == SettingType.INCIDENT_SOURCES_SETTING: 16 | incident_sources = list(IncidentSource) 17 | return { 18 | "incident_sources": [ 19 | incident_source.value for incident_source in incident_sources 20 | ] 21 | } 22 | 23 | if setting_type == SettingType.INCIDENT_TYPES_SETTING: 24 | incident_types = list(IncidentType) 25 | return { 26 | "incident_types": [incident_type.value for incident_type in incident_types] 27 | } 28 | 29 | if setting_type == SettingType.DEFAULT_SYNC_DAYS_SETTING: 30 | return {"default_sync_days": 31} 31 | 32 | if setting_type == SettingType.INCIDENT_PRS_SETTING: 33 | return { 34 | "include_revert_prs": True, 35 | "filters": [], 36 | } 37 | 38 | # ADD NEW DEFAULT SETTING HERE 39 | 40 | raise Exception(f"Invalid Setting Type: {setting_type}") 41 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/settings/setting_type_validator.py: -------------------------------------------------------------------------------- 1 | from werkzeug.exceptions import BadRequest 2 | 3 | from mhq.store.models.settings import SettingType 4 | 5 | 6 | def settings_type_validator(setting_type: str): 7 | if setting_type == SettingType.INCIDENT_SETTING.value: 8 | return SettingType.INCIDENT_SETTING 9 | 10 | if setting_type == SettingType.EXCLUDED_PRS_SETTING.value: 11 | return SettingType.EXCLUDED_PRS_SETTING 12 | 13 | if setting_type == SettingType.INCIDENT_TYPES_SETTING.value: 14 | return SettingType.INCIDENT_TYPES_SETTING 15 | 16 | if setting_type == SettingType.INCIDENT_SOURCES_SETTING.value: 17 | return SettingType.INCIDENT_SOURCES_SETTING 18 | 19 | if setting_type == SettingType.DEFAULT_SYNC_DAYS_SETTING.value: 20 | return SettingType.DEFAULT_SYNC_DAYS_SETTING 21 | 22 | if setting_type == SettingType.INCIDENT_PRS_SETTING.value: 23 | return SettingType.INCIDENT_PRS_SETTING 24 | 25 | # ADD NEW VALIDATOR HERE 26 | 27 | raise BadRequest(f"Invalid Setting Type: {setting_type}") 28 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/sync_data.py: -------------------------------------------------------------------------------- 1 | from mhq.service.code import sync_code_repos 2 | from mhq.service.incidents import sync_org_incidents 3 | from mhq.service.merge_to_deploy_broker import process_merge_to_deploy_cache 4 | from mhq.service.workflows import sync_org_workflows 5 | from mhq.utils.log import LOG 6 | 7 | sync_sequence = [ 8 | sync_code_repos, 9 | sync_org_workflows, 10 | process_merge_to_deploy_cache, 11 | sync_org_incidents, 12 | ] 13 | 14 | 15 | def trigger_data_sync(org_id: str): 16 | LOG.info(f"Starting data sync for org {org_id}") 17 | for sync_func in sync_sequence: 18 | try: 19 | sync_func(org_id) 20 | LOG.info(f"Data sync for {sync_func.__name__} completed successfully") 21 | except Exception as e: 22 | LOG.error( 23 | f"Error syncing {sync_func.__name__} data for org {org_id}: {str(e)}" 24 | ) 25 | continue 26 | LOG.info(f"Data sync for org {org_id} completed successfully") 27 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/workflows/__init__.py: -------------------------------------------------------------------------------- 1 | from .sync import sync_org_workflows 2 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/workflows/integration.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from mhq.store.models import Integration 4 | from mhq.store.models.code import RepoWorkflowProviders 5 | from mhq.store.repos.core import CoreRepoService 6 | 7 | WORKFLOW_INTEGRATION_BUCKET = [ 8 | RepoWorkflowProviders.GITHUB_ACTIONS.value, 9 | ] 10 | 11 | 12 | class WorkflowsIntegrationsService: 13 | def __init__(self, core_repo_service: CoreRepoService): 14 | self.core_repo_service = core_repo_service 15 | 16 | def get_org_providers(self, org_id: str) -> List[str]: 17 | integrations: List[Integration] = ( 18 | self.core_repo_service.get_org_integrations_for_names( 19 | org_id, WORKFLOW_INTEGRATION_BUCKET 20 | ) 21 | ) 22 | if not integrations: 23 | return [] 24 | return [integration.name for integration in integrations] 25 | 26 | 27 | def get_workflows_integrations_service() -> WorkflowsIntegrationsService: 28 | return WorkflowsIntegrationsService(CoreRepoService()) 29 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/workflows/sync/__init__.py: -------------------------------------------------------------------------------- 1 | from .etl_handler import sync_org_workflows 2 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/workflows/sync/etl_provider_handler.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from datetime import datetime 3 | from typing import List, Tuple 4 | 5 | from mhq.store.models.code import ( 6 | OrgRepo, 7 | RepoWorkflow, 8 | RepoWorkflowRuns, 9 | ) 10 | 11 | 12 | class WorkflowProviderETLHandler(ABC): 13 | @abstractmethod 14 | def check_pat_validity(self) -> bool: 15 | """ 16 | This method checks if the PAT is valid. 17 | :return: PAT details 18 | :raises: Exception if PAT is invalid 19 | """ 20 | 21 | @abstractmethod 22 | def get_workflow_runs( 23 | self, 24 | org_repo: OrgRepo, 25 | repo_workflow: RepoWorkflow, 26 | bookmark: datetime, 27 | ) -> Tuple[List[RepoWorkflowRuns], datetime]: 28 | """ 29 | This method returns all workflow runs of a repo's workflow. After the bookmark date. 30 | :param org_repo: OrgRepo object to get workflow runs for 31 | :param repo_workflow: RepoWorkflow object to get workflow runs for 32 | :param bookmark: datetime object to get all workflow runs after this date 33 | :return: List of RepoWorkflowRuns objects, datetime object 34 | """ 35 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/service/workflows/sync/etl_workflows_factory.py: -------------------------------------------------------------------------------- 1 | from mhq.service.workflows.sync.etl_github_actions_handler import ( 2 | get_github_actions_etl_handler, 3 | ) 4 | from mhq.service.workflows.sync.etl_provider_handler import WorkflowProviderETLHandler 5 | from mhq.store.models.code import RepoWorkflowProviders 6 | 7 | 8 | class WorkflowETLFactory: 9 | def __init__(self, org_id: str): 10 | self.org_id = org_id 11 | 12 | def __call__(self, provider: str) -> WorkflowProviderETLHandler: 13 | if provider == RepoWorkflowProviders.GITHUB_ACTIONS.name: 14 | return get_github_actions_etl_handler(self.org_id) 15 | raise NotImplementedError(f"Unknown provider - {provider}") 16 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/__init__.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | 3 | from flask_sqlalchemy import SQLAlchemy 4 | 5 | from mhq.utils.log import LOG 6 | 7 | db = SQLAlchemy() 8 | 9 | 10 | def configure_db_with_app(app): 11 | 12 | DB_HOST = getenv("DB_HOST") 13 | DB_PORT = getenv("DB_PORT") 14 | DB_USER = getenv("DB_USER") 15 | DB_PASS = getenv("DB_PASS") 16 | DB_NAME = getenv("DB_NAME") 17 | ENVIRONMENT = getenv("ENVIRONMENT", "local") 18 | 19 | connection_uri = f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}?application_name=mhq--{ENVIRONMENT}" 20 | 21 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 22 | app.config["SQLALCHEMY_DATABASE_URI"] = connection_uri 23 | app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_size": 10, "max_overflow": 5} 24 | db.init_app(app) 25 | 26 | 27 | def rollback_on_exc(func): 28 | def wrapper(self, *args, **kwargs): 29 | try: 30 | return func(self, *args, **kwargs) 31 | except Exception as e: 32 | self._db.session.rollback() 33 | LOG.error(f"Error in {func.__name__} - {str(e)}") 34 | raise 35 | 36 | return wrapper 37 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/initialise_db.py: -------------------------------------------------------------------------------- 1 | from mhq.store import db 2 | from mhq.store.models import Organization 3 | from mhq.utils.lock import get_redis_lock_service 4 | from mhq.utils.string import uuid4_str 5 | from mhq.utils.time import time_now 6 | 7 | 8 | def initialize_database(app): 9 | with app.app_context(): 10 | with get_redis_lock_service().acquire_lock("initialize_database"): 11 | default_org = ( 12 | db.session.query(Organization) 13 | .filter(Organization.name == "default") 14 | .one_or_none() 15 | ) 16 | if default_org: 17 | return 18 | default_org = Organization( 19 | id=uuid4_str(), 20 | name="default", 21 | domain="default", 22 | created_at=time_now(), 23 | ) 24 | db.session.add(default_org) 25 | db.session.commit() 26 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Organization, Team, Users 2 | from .integrations import Integration, UserIdentity, UserIdentityProvider 3 | from .settings import ( 4 | EntityType, 5 | Settings, 6 | SettingType, 7 | ) 8 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/code/__init__.py: -------------------------------------------------------------------------------- 1 | from .enums import ( 2 | CodeProvider, 3 | CodeBookmarkType, 4 | PullRequestState, 5 | PullRequestEventState, 6 | PullRequestEventType, 7 | PullRequestRevertPRMappingActorType, 8 | ) 9 | from .filter import PRFilter 10 | from .pull_requests import ( 11 | PullRequest, 12 | PullRequestEvent, 13 | PullRequestCommit, 14 | PullRequestRevertPRMapping, 15 | ) 16 | from .repository import ( 17 | OrgRepo, 18 | TeamRepos, 19 | RepoSyncLogs, 20 | Bookmark, 21 | BookmarkMergeToDeployBroker, 22 | ) 23 | from .workflows import ( 24 | RepoWorkflow, 25 | RepoWorkflowRuns, 26 | RepoWorkflowRunsBookmark, 27 | RepoWorkflowType, 28 | RepoWorkflowProviders, 29 | RepoWorkflowRunsStatus, 30 | WorkflowFilter, 31 | ) 32 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/code/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CodeProvider(Enum): 5 | GITHUB = "github" 6 | GITLAB = "gitlab" 7 | 8 | 9 | class CodeBookmarkType(Enum): 10 | PR = "PR" 11 | 12 | 13 | class TeamReposDeploymentType(Enum): 14 | WORKFLOW = "WORKFLOW" 15 | PR_MERGE = "PR_MERGE" 16 | 17 | 18 | class PullRequestState(Enum): 19 | OPEN = "OPEN" 20 | CLOSED = "CLOSED" 21 | MERGED = "MERGED" 22 | 23 | 24 | class PullRequestEventState(Enum): 25 | CHANGES_REQUESTED = "CHANGES_REQUESTED" 26 | APPROVED = "APPROVED" 27 | COMMENTED = "COMMENTED" 28 | 29 | 30 | class PullRequestEventType(Enum): 31 | REVIEW = "REVIEW" 32 | 33 | 34 | class PullRequestRevertPRMappingActorType(Enum): 35 | SYSTEM = "SYSTEM" 36 | USER = "USER" 37 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/code/workflows/__init__.py: -------------------------------------------------------------------------------- 1 | from .enums import RepoWorkflowType, RepoWorkflowProviders, RepoWorkflowRunsStatus 2 | from .filter import WorkflowFilter 3 | from .workflows import RepoWorkflow, RepoWorkflowRuns, RepoWorkflowRunsBookmark 4 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/code/workflows/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class RepoWorkflowProviders(Enum): 5 | GITHUB_ACTIONS = "github" 6 | CIRCLE_CI = "circle_ci" 7 | 8 | @classmethod 9 | def get_workflow_providers(cls): 10 | return [v for v in cls.__members__.values()] 11 | 12 | @classmethod 13 | def get_workflow_providers_values(cls): 14 | return [v.value for v in cls.__members__.values()] 15 | 16 | @classmethod 17 | def get_enum(cls, provider: str): 18 | for v in cls.__members__.values(): 19 | if provider == v.value: 20 | return v 21 | return None 22 | 23 | 24 | class RepoWorkflowType(Enum): 25 | DEPLOYMENT = "DEPLOYMENT" 26 | 27 | 28 | class RepoWorkflowRunsStatus(Enum): 29 | SUCCESS = "SUCCESS" 30 | FAILURE = "FAILURE" 31 | PENDING = "PENDING" 32 | CANCELLED = "CANCELLED" 33 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .organization import Organization 2 | from .teams import Team 3 | from .users import Users 4 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/core/organization.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.dialects.postgresql import UUID, ARRAY 2 | 3 | from mhq.store import db 4 | 5 | 6 | class Organization(db.Model): 7 | __tablename__ = "Organization" 8 | 9 | id = db.Column(UUID(as_uuid=True), primary_key=True) 10 | name = db.Column(db.String) 11 | created_at = db.Column(db.DateTime(timezone=True)) 12 | domain = db.Column(db.String) 13 | other_domains = db.Column(ARRAY(db.String)) 14 | 15 | def __eq__(self, other): 16 | 17 | if isinstance(other, Organization): 18 | return self.id == other.id 19 | 20 | return False 21 | 22 | def __hash__(self): 23 | return hash(self.id) 24 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/core/teams.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from sqlalchemy import ( 4 | func, 5 | ) 6 | from sqlalchemy.dialects.postgresql import UUID, ARRAY 7 | 8 | from mhq.store import db 9 | 10 | 11 | class Team(db.Model): 12 | __tablename__ = "Team" 13 | 14 | id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 15 | org_id = db.Column(UUID(as_uuid=True), db.ForeignKey("Organization.id")) 16 | name = db.Column(db.String) 17 | member_ids = db.Column(ARRAY(UUID(as_uuid=True)), nullable=False) 18 | manager_id = db.Column(UUID(as_uuid=True), db.ForeignKey("Users.id")) 19 | created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) 20 | updated_at = db.Column( 21 | db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now() 22 | ) 23 | is_deleted = db.Column(db.Boolean, default=False) 24 | 25 | def __hash__(self): 26 | return hash(self.id) 27 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/core/users.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import ( 2 | func, 3 | ) 4 | from sqlalchemy.dialects.postgresql import UUID 5 | 6 | from mhq.store import db 7 | 8 | 9 | class Users(db.Model): 10 | __tablename__ = "Users" 11 | 12 | id = db.Column(UUID(as_uuid=True), primary_key=True) 13 | org_id = db.Column(UUID(as_uuid=True), db.ForeignKey("Organization.id")) 14 | name = db.Column(db.String) 15 | created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) 16 | updated_at = db.Column( 17 | db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now() 18 | ) 19 | primary_email = db.Column(db.String) 20 | is_deleted = db.Column(db.Boolean, default=False) 21 | avatar_url = db.Column(db.String) 22 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/incidents/__init__.py: -------------------------------------------------------------------------------- 1 | from .enums import ( 2 | IncidentType, 3 | IncidentBookmarkType, 4 | IncidentProvider, 5 | ServiceStatus, 6 | IncidentStatus, 7 | IncidentSource, 8 | ) 9 | from .filter import IncidentFilter 10 | from .incidents import ( 11 | Incident, 12 | IncidentOrgIncidentServiceMap, 13 | IncidentsBookmark, 14 | ) 15 | from .services import OrgIncidentService, TeamIncidentService 16 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/incidents/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class IncidentProvider(Enum): 5 | GITHUB = "github" 6 | GITLAB = "gitlab" 7 | 8 | 9 | class IncidentSource(Enum): 10 | INCIDENT_SERVICE = "INCIDENT_SERVICE" 11 | INCIDENT_TEAM = "INCIDENT_TEAM" 12 | GIT_REPO = "GIT_REPO" 13 | 14 | 15 | class ServiceStatus(Enum): 16 | DISABLED = "disabled" 17 | ACTIVE = "active" 18 | WARNING = "warning" 19 | CRITICAL = "critical" 20 | MAINTENANCE = "maintenance" 21 | 22 | 23 | class IncidentStatus(Enum): 24 | TRIGGERED = "triggered" 25 | ACKNOWLEDGED = "acknowledged" 26 | RESOLVED = "resolved" 27 | 28 | 29 | class IncidentType(Enum): 30 | INCIDENT = "INCIDENT" 31 | REVERT_PR = "REVERT_PR" 32 | ALERT = "ALERT" 33 | 34 | 35 | class IncidentBookmarkType(Enum): 36 | SERVICE = "SERVICE" 37 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/incidents/filter.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from sqlalchemy import or_ 5 | 6 | from mhq.store.models.incidents.incidents import Incident 7 | 8 | 9 | @dataclass 10 | class IncidentFilter: 11 | """Dataclass for filtering incidents.""" 12 | 13 | title_filter_substrings: List[str] = None 14 | incident_types: List[str] = None 15 | 16 | @property 17 | def filter_query(self) -> List: 18 | def _title_filter_substrings_query(): 19 | if not self.title_filter_substrings: 20 | return None 21 | 22 | return or_( 23 | Incident.title.contains(substring, autoescape=True) 24 | for substring in self.title_filter_substrings 25 | ) 26 | 27 | def _incident_type_query(): 28 | if not self.incident_types: 29 | return None 30 | 31 | return or_( 32 | Incident.incident_type == incident_type 33 | for incident_type in self.incident_types 34 | ) 35 | 36 | conditions = { 37 | "title_filter_substrings": _title_filter_substrings_query(), 38 | "incident_types": _incident_type_query(), 39 | } 40 | 41 | return [ 42 | conditions[x] 43 | for x in self.__dict__.keys() 44 | if getattr(self, x) is not None and conditions[x] is not None 45 | ] 46 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | from .enums import UserIdentityProvider 2 | from .integrations import Integration, UserIdentity 3 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/integrations/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class UserIdentityProvider(Enum): 5 | GITHUB = "github" 6 | GITLAB = "gitlab" 7 | 8 | @classmethod 9 | def get_enum(self, provider: str): 10 | for v in self.__members__.values(): 11 | if provider == v.value: 12 | return v 13 | return None 14 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .configuration_settings import SettingType, Settings 2 | from .enums import EntityType 3 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/models/settings/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class EntityType(Enum): 5 | USER = "USER" 6 | TEAM = "TEAM" 7 | ORG = "ORG" 8 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/repos/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/mhq/store/repos/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/mhq/store/repos/integrations.py: -------------------------------------------------------------------------------- 1 | class IntegrationsRepoService: 2 | pass 3 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/mhq/utils/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/mhq/utils/dict.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List 2 | 3 | 4 | def get_average_of_dict_values(key_to_int_map: Dict[any, int]) -> int: 5 | """ 6 | This method accepts a dictionary with any key type mapped to integer values and returns the average of those keys. Nulls are considered as zero. 7 | """ 8 | 9 | if not key_to_int_map: 10 | return 0 11 | 12 | values = list(key_to_int_map.values()) 13 | sum_of_value = 0 14 | for value in values: 15 | 16 | if value is None: 17 | continue 18 | 19 | sum_of_value += value 20 | 21 | return sum_of_value // len(values) 22 | 23 | 24 | def get_key_to_count_map_from_key_to_list_map( 25 | week_to_list_map: Dict[Any, List[Any]] 26 | ) -> Dict[Any, int]: 27 | """ 28 | This method takes a dict of keys to list and returns a dict of keys mapped to the length of lists from the input dict. 29 | """ 30 | list_len_or_zero = lambda x: len(x) if type(x) in [list, set] else 0 # noqa E731 31 | 32 | return {key: list_len_or_zero(lst) for key, lst in week_to_list_map.items()} 33 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/utils/diffparser.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def _parse_gitlab_diff(text): 5 | line_range_match = re.search( 6 | r"^@@ -(\d+),(\d+) \+(\d+),(\d+) @@$", text, flags=re.MULTILINE 7 | ) 8 | if line_range_match: 9 | deletion_lines = int(line_range_match.group(2)) 10 | addition_lines = int(line_range_match.group(4)) 11 | else: 12 | deletion_lines = 0 13 | addition_lines = 0 14 | return addition_lines, deletion_lines 15 | 16 | 17 | def parse_gitlab_diffs(texts): 18 | additions = 0 19 | deletions = 0 20 | for text in texts: 21 | diff_additions, diff_deletions = _parse_gitlab_diff(text[:50]) 22 | additions += diff_additions 23 | deletions += diff_deletions 24 | return additions, deletions, len(texts) 25 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/utils/lock.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | 3 | from redis import Redis 4 | from redis_lock import Lock 5 | 6 | REDIS_HOST = getenv("REDIS_HOST", "localhost") 7 | REDIS_PORT = getenv("REDIS_PORT", 6379) 8 | REDIS_DB = 0 9 | REDIS_PASSWORD = "" 10 | SSL_STATUS = True if REDIS_HOST != "localhost" else False 11 | 12 | service = None 13 | 14 | 15 | class RedisLockService: 16 | def __init__(self, host, port, db, password, ssl): 17 | self.host = host 18 | self.port = port 19 | self.db = db 20 | self.password = password 21 | self.ssl = ssl 22 | self.redis = Redis( 23 | host=self.host, 24 | port=self.port, 25 | db=self.db, 26 | password=self.password, 27 | ssl=self.ssl, 28 | socket_connect_timeout=5, 29 | ) 30 | 31 | def acquire_lock(self, key: str): 32 | return Lock(self.redis, name=key, expire=1.5, auto_renewal=True) 33 | 34 | 35 | def get_redis_lock_service(): 36 | global service 37 | if not service: 38 | service = RedisLockService( 39 | REDIS_HOST, REDIS_PORT, REDIS_DB, REDIS_PASSWORD, SSL_STATUS 40 | ) 41 | return service 42 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/utils/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | LOG = logging.getLogger() 4 | 5 | 6 | def custom_logging(func): 7 | def wrapper(*args, **kwargs): 8 | print( 9 | f"[{func.__name__.upper()}]", args[0] 10 | ) # Assuming the first argument is the log message 11 | return func(*args, **kwargs) 12 | 13 | return wrapper 14 | 15 | 16 | LOG.error = custom_logging(LOG.error) 17 | LOG.info = custom_logging(LOG.info) 18 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/utils/regex.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List 3 | from werkzeug.exceptions import BadRequest 4 | 5 | 6 | def check_regex(pattern: str): 7 | # pattern is a string containing the regex pattern 8 | try: 9 | re.compile(pattern) 10 | 11 | except re.error: 12 | return False 13 | 14 | return True 15 | 16 | 17 | def check_all_regex(patterns: List[str]) -> bool: 18 | # patterns is a list of strings containing the regex patterns 19 | for pattern in patterns: 20 | if not pattern or not check_regex(pattern): 21 | return False 22 | 23 | return True 24 | 25 | 26 | def regex_list(patterns: List[str]) -> List[str]: 27 | if not check_all_regex(patterns): 28 | raise BadRequest("Invalid regex pattern") 29 | return patterns 30 | -------------------------------------------------------------------------------- /backend/analytics_server/mhq/utils/string.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | import re 3 | 4 | 5 | def uuid4_str(): 6 | return str(uuid4()) 7 | 8 | 9 | def is_bot_name(name: str) -> bool: 10 | pattern = re.compile( 11 | r"(?i)(\b[\w@-]*[-_\[\]@ ]+bot[-_\d\[\]]*\b|\[bot\]|_bot_|_bot$|^bot_)" 12 | ) 13 | return bool(pattern.search(name)) 14 | -------------------------------------------------------------------------------- /backend/analytics_server/sync_app.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | 3 | from flask import Flask 4 | 5 | from env import load_app_env 6 | 7 | load_app_env() 8 | 9 | from mhq.store import configure_db_with_app 10 | from mhq.api.hello import app as core_api 11 | from mhq.api.sync import app as sync_api 12 | 13 | SYNC_SERVER_PORT = getenv("SYNC_SERVER_PORT") 14 | 15 | app = Flask(__name__) 16 | 17 | app.register_blueprint(core_api) 18 | app.register_blueprint(sync_api) 19 | 20 | configure_db_with_app(app) 21 | 22 | if __name__ == "__main__": 23 | app.run(port=SYNC_SERVER_PORT) 24 | -------------------------------------------------------------------------------- /backend/analytics_server/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/exapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/exapi/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/factories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/factories/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/factories/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .code import ( 2 | get_repo_workflow_run, 3 | get_deployment, 4 | get_pull_request, 5 | get_pull_request_commit, 6 | get_pull_request_event, 7 | ) 8 | from .incidents import ( 9 | get_incident, 10 | get_change_failure_rate_metrics, 11 | get_org_incident_service, 12 | ) 13 | -------------------------------------------------------------------------------- /backend/analytics_server/tests/factories/models/exapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/factories/models/exapi/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/service/Incidents/sync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/service/Incidents/sync/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/service/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/service/code/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/service/code/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/service/code/sync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/service/code/sync/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/service/deployments/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/service/deployments/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/service/workflows/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/service/workflows/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/service/workflows/sync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/backend/analytics_server/tests/service/workflows/sync/__init__.py -------------------------------------------------------------------------------- /backend/analytics_server/tests/utilities.py: -------------------------------------------------------------------------------- 1 | def compare_objects_as_dicts(ob_1, ob_2, ignored_keys=None): 2 | """ 3 | This method can be used to compare between two objects in tests while ignoring keys that are generated as side effects like uuids or autogenerated date time fields. 4 | """ 5 | if not ignored_keys: 6 | ignored_keys = [] 7 | 8 | default_ignored_keys = ["_sa_instance_state"] 9 | final_ignored_keys = set(ignored_keys + default_ignored_keys) 10 | 11 | for key in final_ignored_keys: 12 | if key in ob_1.__dict__: 13 | del ob_1.__dict__[key] 14 | if key in ob_2.__dict__: 15 | del ob_2.__dict__[key] 16 | 17 | if not ob_1.__dict__ == ob_2.__dict__: 18 | print(ob_1.__dict__, "!=", ob_2.__dict__) 19 | return False 20 | return True 21 | -------------------------------------------------------------------------------- /backend/analytics_server/tests/utils/dict/test_get_average_of_dict_values.py: -------------------------------------------------------------------------------- 1 | from mhq.utils.dict import get_average_of_dict_values 2 | 3 | 4 | def test_empty_dict_returns_zero(): 5 | assert get_average_of_dict_values({}) == 0 6 | 7 | 8 | def test_nulls_counted_as_zero(): 9 | assert get_average_of_dict_values({"w1": 2, "w2": 4, "w3": None}) == 2 10 | 11 | 12 | def test_average_of_integers_with_integer_avg(): 13 | assert get_average_of_dict_values({"w1": 2, "w2": 4, "w3": 6}) == 4 14 | 15 | 16 | def test_average_of_integers_with_decimal_avg_rounded_off(): 17 | assert get_average_of_dict_values({"w1": 2, "w2": 4, "w3": 7}) == 4 18 | -------------------------------------------------------------------------------- /backend/analytics_server/tests/utils/dict/test_get_key_to_count_map.py: -------------------------------------------------------------------------------- 1 | from mhq.utils.dict import get_key_to_count_map_from_key_to_list_map 2 | 3 | 4 | def test_empty_dict_return_empty_dict(): 5 | assert get_key_to_count_map_from_key_to_list_map({}) == {} 6 | 7 | 8 | def test_dict_with_list_values(): 9 | assert get_key_to_count_map_from_key_to_list_map( 10 | {"a": [1, 2], "b": ["a", "p", "9"]} 11 | ) == {"a": 2, "b": 3} 12 | 13 | 14 | def test_dict_with_set_values(): 15 | assert get_key_to_count_map_from_key_to_list_map( 16 | {"a": {1, 2}, "b": {"a", "p", "9"}} 17 | ) == {"a": 2, "b": 3} 18 | 19 | 20 | def test_dict_with_non_set_or_list_values(): 21 | assert get_key_to_count_map_from_key_to_list_map( 22 | {"a": None, "b": 0, "c": "Ckk"} 23 | ) == {"a": 0, "b": 0, "c": 0} 24 | 25 | 26 | def test_dict_with_mixed_values(): 27 | assert get_key_to_count_map_from_key_to_list_map( 28 | {"a": None, "b": 0, "c": "Ckk", "e": [1], "g": {"A", "B"}} 29 | ) == {"a": 0, "b": 0, "c": 0, "e": 1, "g": 2} 30 | -------------------------------------------------------------------------------- /backend/analytics_server/tests/utils/string/test_is_bot_name.py: -------------------------------------------------------------------------------- 1 | from mhq.utils.string import is_bot_name 2 | 3 | 4 | def test_simple_bot_names(): 5 | assert is_bot_name("test_bot") 6 | assert is_bot_name("test-bot") 7 | 8 | 9 | def test_bot_with_prefixes_and_suffixes(): 10 | assert is_bot_name("my_bot") 11 | assert is_bot_name("my-bot") 12 | assert is_bot_name("my bot") 13 | assert is_bot_name("test_bot_123") 14 | assert is_bot_name("test-bot-123") 15 | assert is_bot_name("test bot 123") 16 | 17 | 18 | def test_special_patterns(): 19 | assert is_bot_name("name_bot_suffix") 20 | assert is_bot_name("name_bot") 21 | assert is_bot_name("bot_name") 22 | assert is_bot_name("my_bot_is_cool") 23 | 24 | 25 | def test_case_insensitivity(): 26 | assert is_bot_name("my_BOT") 27 | assert is_bot_name("MY-bot") 28 | assert is_bot_name("My Bot") 29 | 30 | 31 | def test_special_characters(): 32 | assert is_bot_name("test@bot") 33 | assert is_bot_name("[bot]") 34 | 35 | 36 | def test_negative_cases(): 37 | assert not is_bot_name("robotics") 38 | assert not is_bot_name("lobotomy") 39 | assert not is_bot_name("botany") 40 | assert not is_bot_name("about") 41 | assert not is_bot_name("robotic") 42 | assert not is_bot_name("bots") 43 | 44 | 45 | def test_edge_cases(): 46 | assert not is_bot_name("") 47 | assert not is_bot_name(" ") 48 | assert not is_bot_name("12345") 49 | -------------------------------------------------------------------------------- /backend/dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==7.1.1 2 | black==24.3.0 3 | pre-commit==3.8.0 4 | flake8==7.1.1 5 | -------------------------------------------------------------------------------- /backend/env.example: -------------------------------------------------------------------------------- 1 | DB_HOST=dora_db_host 2 | DB_NAME=dora_db_name 3 | DB_PASS=dora_db_pass 4 | DB_PORT=dora_db_port 5 | DB_USER=dora_db_user 6 | REDIS_HOST=dora_redis_host 7 | REDIS_PORT=dora_redis_port 8 | ANALYTICS_SERVER_PORT=5000 9 | SYNC_SERVER_PORT=5001 10 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.2 2 | voluptuous==0.12.2 3 | stringcase==1.2.0 4 | SQLAlchemy==2.0.29 5 | pytz==2022.1 6 | PyGithub==1.55 7 | pycryptodome==3.21.0 8 | aiohttp==3.9.4 9 | redis==5.0.3 10 | python-redis-lock==4.0.0 11 | psycopg2==2.9.3 12 | python-dotenv==1.0.1 13 | gunicorn==22.0.0 14 | Flask-SQLAlchemy==3.1.1 15 | -------------------------------------------------------------------------------- /cli/.eslintignore: -------------------------------------------------------------------------------- 1 | # .estlintignore file 2 | dist 3 | build 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /cli/.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /cli/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.log 4 | -------------------------------------------------------------------------------- /cli/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /cli/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "printWidth": 80, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSameLine": false 9 | } 10 | -------------------------------------------------------------------------------- /cli/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["gruntfuggly.todo-tree", "stringham.copy-with-imports"] 3 | } 4 | -------------------------------------------------------------------------------- /cli/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "debug cli", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "yarn dev" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /cli/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.editor.labelFormat": "short", 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "always" 5 | }, 6 | "editor.formatOnSave": false, 7 | "eslint.workingDirectories": [{ "mode": "auto" }], 8 | "tsEssentialPlugins.autoImport.changeSorting": { 9 | "createContext": ["react"] 10 | }, 11 | "tsEssentialPlugins.patchOutline": true, 12 | "editor.linkedEditing": true, 13 | "tsEssentialPlugins.enableMethodSnippets": false 14 | } 15 | -------------------------------------------------------------------------------- /cli/libdefs/ink.ts: -------------------------------------------------------------------------------- 1 | import 'ink-testing-library'; 2 | 3 | import { ReactNode } from 'react'; 4 | 5 | declare module 'ink-testing-library' { 6 | export function render(tree: ReactNode): RenderResponse; 7 | } 8 | -------------------------------------------------------------------------------- /cli/readme.md: -------------------------------------------------------------------------------- 1 | # cli 2 | 3 | > This readme is automatically generated by [create-ink-app](https://github.com/vadimdemedes/create-ink-app) 4 | 5 | ## Install 6 | 7 | ```bash 8 | $ npm install --global cli 9 | ``` 10 | 11 | ## CLI 12 | 13 | ``` 14 | $ cli --help 15 | 16 | Usage 17 | $ cli 18 | 19 | Options 20 | --name Your name 21 | 22 | Examples 23 | $ cli --name=Jane 24 | Hello, Jane 25 | ``` 26 | -------------------------------------------------------------------------------- /cli/source/__tests__/test.tsx: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import chalk from 'chalk'; 3 | import { render } from 'ink-testing-library'; 4 | 5 | import { App } from '../app.js'; 6 | 7 | test('greet unknown user', (t) => { 8 | const { lastFrame } = render(); 9 | 10 | t.is(lastFrame(), `Hello, ${chalk.green('Stranger')}`); 11 | }); 12 | 13 | test('greet user with a name', (t) => { 14 | const { lastFrame } = render(); 15 | 16 | t.is(lastFrame(), `Hello, ${chalk.green('Jane')}`); 17 | }); 18 | -------------------------------------------------------------------------------- /cli/source/cli.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { render } from 'ink'; 3 | import meow from 'meow'; 4 | 5 | import { App } from './app.js'; 6 | 7 | meow( 8 | ` 9 | Usage 10 | $ cli 11 | 12 | Options 13 | --name Your name 14 | 15 | Examples 16 | $ cli --name=Jane 17 | Hello, Jane 18 | `, 19 | { 20 | importMeta: import.meta, 21 | flags: { 22 | name: { 23 | type: 'string' 24 | } 25 | } 26 | } 27 | ); 28 | 29 | render(, { exitOnCtrlC: false }); 30 | -------------------------------------------------------------------------------- /cli/source/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, MutableRefObject } from 'react'; 2 | 3 | export const usePrevious = (value: T): T | undefined => { 4 | const ref: MutableRefObject = useRef(); 5 | useEffect(() => { 6 | ref.current = value; 7 | }, [value]); 8 | return ref.current; 9 | }; 10 | -------------------------------------------------------------------------------- /cli/source/precheck.ts: -------------------------------------------------------------------------------- 1 | import { isFreePort } from 'find-free-ports'; 2 | 3 | async function run(): Promise { 4 | const { DB_PORT, REDIS_PORT, PORT, SYNC_SERVER_PORT, ANALYTICS_SERVER_PORT } = 5 | process.env; 6 | 7 | const rawPorts = [ 8 | DB_PORT, 9 | REDIS_PORT, 10 | PORT, 11 | SYNC_SERVER_PORT, 12 | ANALYTICS_SERVER_PORT 13 | ]; 14 | const ports: number[] = rawPorts.map((p, i) => { 15 | const num = Number(p); 16 | if (typeof p !== 'string' || Number.isNaN(num)) { 17 | console.error(`❌ Invalid or missing port value at index ${i}:`, p); 18 | process.exit(1); 19 | } 20 | return num; 21 | }); 22 | 23 | try { 24 | const results = await Promise.allSettled(ports.map(isFreePort)); 25 | results.forEach((result, i) => { 26 | const port = ports[i]; 27 | if (result.status === 'rejected') { 28 | console.error(`❌ Failed to check port ${port}:`, result.reason); 29 | process.exit(1); 30 | } else if (!result.value) { 31 | console.error(`❌ Port ${port} is already in use.`); 32 | process.exit(1); 33 | } 34 | }); 35 | 36 | console.log('✅ All ports are free; starting app...'); 37 | } catch (err) { 38 | console.error('❌ Error checking ports:', err); 39 | process.exit(1); 40 | } 41 | } 42 | 43 | run(); 44 | -------------------------------------------------------------------------------- /cli/source/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import { 3 | useDispatch as useReduxDispatch, 4 | useSelector as useReduxSelector 5 | } from 'react-redux'; 6 | 7 | import { rootReducer } from './rootReducer.js'; 8 | 9 | import type { Action } from '@reduxjs/toolkit'; 10 | import type { TypedUseSelectorHook } from 'react-redux'; 11 | import type { ThunkAction } from 'redux-thunk'; 12 | 13 | export const store = configureStore({ 14 | reducer: rootReducer, 15 | devTools: true, 16 | middleware: (getDefaultMiddleware) => 17 | getDefaultMiddleware({ 18 | serializableCheck: false 19 | }) 20 | }); 21 | 22 | export type RootState = ReturnType; 23 | 24 | export type AppDispatch = typeof store.dispatch; 25 | 26 | export type AppThunk = ThunkAction>; 27 | 28 | export const useSelector: TypedUseSelectorHook = useReduxSelector; 29 | 30 | export const useDispatch = () => useReduxDispatch(); 31 | -------------------------------------------------------------------------------- /cli/source/store/rootReducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from '@reduxjs/toolkit'; 2 | 3 | import { appSlice } from '../slices/app.js'; 4 | 5 | export const rootReducer = combineReducers({ 6 | app: appSlice.reducer 7 | }); 8 | -------------------------------------------------------------------------------- /cli/source/utils/circularBuffer.ts: -------------------------------------------------------------------------------- 1 | class CircularBuffer { 2 | private buffer: T[]; 3 | private head: number; 4 | private tail: number; 5 | private size: number; 6 | private capacity: number; 7 | 8 | constructor(capacity: number) { 9 | this.buffer = new Array(capacity); 10 | this.head = 0; 11 | this.tail = 0; 12 | this.size = 0; 13 | this.capacity = capacity; 14 | } 15 | 16 | enqueue(item: T): void { 17 | if (this.size === this.capacity) { 18 | this.dequeue(); 19 | } 20 | this.buffer[this.tail] = item; 21 | this.tail = (this.tail + 1) % this.capacity; 22 | this.size++; 23 | } 24 | 25 | dequeue(): T | undefined { 26 | if (this.size === 0) { 27 | return undefined; 28 | } 29 | const item = this.buffer[this.head]; 30 | this.head = (this.head + 1) % this.capacity; 31 | this.size--; 32 | return item; 33 | } 34 | 35 | get length(): number { 36 | return this.size; 37 | } 38 | 39 | get items(): T[] { 40 | const items: T[] = []; 41 | let index = this.head; 42 | for (let i = 0; i < this.size; i++) { 43 | items.push(this.buffer[index]!); 44 | index = (index + 1) % this.capacity; 45 | } 46 | return items; 47 | } 48 | } 49 | 50 | export default CircularBuffer; 51 | -------------------------------------------------------------------------------- /cli/source/utils/line-limit.ts: -------------------------------------------------------------------------------- 1 | export const getLineLimit = () => { 2 | const width = process.stdout.columns; 3 | const lineLimit = width - 10; 4 | 5 | return lineLimit; 6 | }; 7 | -------------------------------------------------------------------------------- /cli/source/utils/project-root.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs'; 2 | import { join, resolve } from 'path'; 3 | 4 | export function findProjectRoot(currentDir: string): string { 5 | if (existsSync(join(currentDir, 'Dockerfile'))) { 6 | return currentDir; 7 | } else { 8 | const parentDir = resolve(currentDir, '..'); 9 | if (parentDir === currentDir) { 10 | // Reached the root directory 11 | throw new Error('Project root not found'); 12 | } 13 | return findProjectRoot(parentDir); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cli/source/utils/update-checker.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | const defaultBranch = 'main'; 4 | 5 | const fetchRemoteRepository = () => { 6 | try { 7 | execSync('git fetch origin'); 8 | } catch (error) { 9 | console.error(`Error fetching the remote repository: ${error}`); 10 | } 11 | }; 12 | 13 | const getLatestLocalCommitSHA = () => { 14 | fetchRemoteRepository(); 15 | const commitSHA = execSync(`git rev-parse ${defaultBranch}`) 16 | .toString() 17 | .trim(); 18 | return commitSHA; 19 | }; 20 | 21 | export async function isLocalBranchBehindRemote(): Promise { 22 | const latestLocalCommitSHA = getLatestLocalCommitSHA(); 23 | 24 | const behindCommits = execSync( 25 | `git rev-list ${latestLocalCommitSHA}..origin/main` 26 | ) 27 | .toString() 28 | .trim() 29 | .split('\n'); 30 | 31 | const behindCommitsCount = behindCommits.filter((commit) => commit).length; 32 | 33 | if (behindCommitsCount == 0) { 34 | return ''; 35 | } 36 | 37 | if (behindCommitsCount == 1) { 38 | return `(1 commit behind remote. pull and rebase)`; 39 | } 40 | 41 | return `(${behindCommitsCount} commits behind remote. pull and rebase)`; 42 | } 43 | -------------------------------------------------------------------------------- /cli/tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsxdev" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "allowJs": true, 5 | "allowSyntheticDefaultImports": true, 6 | "jsx": "react-jsx", 7 | "module": "Node16", 8 | "moduleResolution": "Node16", 9 | "moduleDetection": "force", 10 | "target": "ES2022", 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "resolveJsonModule": false, 13 | "declaration": true, 14 | "pretty": true, 15 | "newLine": "lf", 16 | "stripInternal": true, 17 | "strict": true, 18 | "noImplicitReturns": true, 19 | "noImplicitOverride": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noPropertyAccessFromIndexSignature": true, 23 | "noEmitOnError": true, 24 | "useDefineForClassFields": true, 25 | "forceConsistentCasingInFileNames": true, 26 | "skipLibCheck": true, 27 | "strictNullChecks": true 28 | }, 29 | "include": ["source"] 30 | } 31 | -------------------------------------------------------------------------------- /database-docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm 2 | ENV DEBIAN_FRONTEND noninteractive 3 | RUN apt-get update 4 | RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list 5 | RUN apt-get -y install gnupg 6 | RUN apt-get -y install curl 7 | RUN apt-get -y install wget 8 | RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - 9 | RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - 10 | RUN apt-get update 11 | RUN apt-get -y install postgresql-15 12 | 13 | RUN ARCH=$(dpkg --print-architecture) && \ 14 | curl -fsSL -o /usr/local/bin/dbmate https://github.com/amacneil/dbmate/releases/download/v1.16.0/dbmate-linux-${ARCH} && \ 15 | chmod +x /usr/local/bin/dbmate 16 | 17 | WORKDIR / 18 | RUN mkdir ./db 19 | COPY ./db/ ./db/ 20 | ENV PATH /usr/lib/postgresql/15/bin:$PATH 21 | ADD ./dbwait.sh /bin 22 | RUN chmod +x /bin/dbwait.sh 23 | -------------------------------------------------------------------------------- /database-docker/README.md: -------------------------------------------------------------------------------- 1 | We use this docker image in docker-compose.yml to setup containers that run 2 | migrations for each service using 3 | [`dbmate`](https://github.com/amacneil/dbmate) 4 | -------------------------------------------------------------------------------- /database-docker/db/migrations/20240430142502_delete_id_team_incident_service_and_team_repos_prod_branch.sql: -------------------------------------------------------------------------------- 1 | -- migrate:up 2 | ALTER TABLE public."TeamIncidentService" DROP CONSTRAINT "TeamIncidentService_pkey"; 3 | 4 | ALTER TABLE public."TeamIncidentService" DROP COLUMN id; 5 | 6 | ALTER TABLE public."TeamIncidentService" 7 | ADD CONSTRAINT "TeamIncidentService_composite_pkey" PRIMARY KEY (team_id, service_id); 8 | 9 | ALTER TABLE public."TeamRepos" DROP COLUMN prod_branch; 10 | 11 | -- migrate:down 12 | -------------------------------------------------------------------------------- /database-docker/db/migrations/20240503060203_delete_lead_time_column.sql: -------------------------------------------------------------------------------- 1 | -- migrate:up 2 | 3 | ALTER TABLE public."PullRequest" DROP COLUMN lead_time; 4 | 5 | -- migrate:down 6 | -------------------------------------------------------------------------------- /database-docker/db/migrations/20240503073715_add-url=in-incidents.sql: -------------------------------------------------------------------------------- 1 | -- migrate:up 2 | 3 | ALTER TABLE public."Incident" 4 | ADD COLUMN "url" character varying; 5 | 6 | -- migrate:down 7 | -------------------------------------------------------------------------------- /database-docker/dbwait.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -u 5 | set -x 6 | 7 | until dbmate wait 8 | do 9 | echo "inside dbmate" 10 | sleep 2 11 | done 12 | -------------------------------------------------------------------------------- /database-docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:14.3-alpine 4 | restart: always 5 | environment: 6 | POSTGRES_USER: postgres 7 | POSTGRES_PASSWORD: postgres 8 | POSTGRES_DB: mhq-oss 9 | ports: 10 | - "5434:5432" 11 | volumes: 12 | - pgdata:/var/lib/postgresql/data 13 | command: ["postgres", "-c", "max_prepared_transactions=0"] 14 | 15 | redis: 16 | image: redis:latest 17 | restart: always 18 | ports: 19 | - "6385:6379" 20 | 21 | dbmate: 22 | build: 23 | context: ./ 24 | dockerfile: Dockerfile 25 | depends_on: 26 | - db 27 | volumes: 28 | - ./db:/db 29 | environment: 30 | - DATABASE_URL=postgres://postgres:postgres@db:5432/mhq-oss?sslmode=disable 31 | working_dir: / 32 | command: bash -c "dbwait.sh && dbmate up && dbmate dump" 33 | 34 | volumes: 35 | pgdata: {} 36 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | ENVIRONMENT=dev 2 | POSTGRES_DB_ENABLED=true 3 | DB_INIT_ENABLED=true 4 | REDIS_ENABLED=true 5 | BACKEND_ENABLED=true 6 | FRONTEND_ENABLED=true 7 | CRON_ENABLED=true 8 | 9 | DB_HOST=localhost 10 | DB_NAME=mhq-oss 11 | DB_PASS=postgres 12 | DB_PORT=5434 13 | DB_USER=postgres 14 | REDIS_HOST=localhost 15 | REDIS_PORT=6385 16 | PORT=3333 17 | SYNC_SERVER_PORT=9697 18 | ANALYTICS_SERVER_PORT=9696 19 | INTERNAL_API_BASE_URL=http://localhost:9696 20 | INTERNAL_SYNC_API_BASE_URL=http://localhost:9697 21 | NEXT_PUBLIC_APP_ENVIRONMENT="development" 22 | DEFAULT_SYNC_DAYS=31 23 | BUILD_DATE=2024-06-05T10:21:34Z 24 | MERGE_COMMIT_SHA=5f9ff895ad1d7805edcb22bfe2fcc6129e33bd8c 25 | -------------------------------------------------------------------------------- /media_files/banner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/media_files/banner.gif -------------------------------------------------------------------------------- /media_files/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/media_files/banner.png -------------------------------------------------------------------------------- /media_files/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/media_files/logo.png -------------------------------------------------------------------------------- /media_files/product_demo_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/media_files/product_demo_1.gif -------------------------------------------------------------------------------- /setup_utils/cronjob.txt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Every 30 minutes, run the sync script 4 | */30 * * * * curl -X POST http://localhost:9697/sync >> /var/log/cron/cron.log 2>&1 5 | -------------------------------------------------------------------------------- /setup_utils/generate_config_ini.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | target_path=../backend/analytics_server/mhq/config 3 | # Parse command line arguments 4 | while [[ $# -gt 0 ]]; do 5 | key="$1" 6 | case $key in 7 | -t|--target) 8 | target_path="$2" 9 | shift 10 | shift 11 | ;; 12 | *) 13 | echo "Unknown option: $1" 14 | exit 1 15 | ;; 16 | esac 17 | done 18 | 19 | # Check if target path is provided 20 | if [ -z "$target_path" ]; then 21 | echo "Error: Please provide a target path using -t or --target." 22 | exit 1 23 | fi 24 | 25 | # Generate the RSA private key 26 | private_key=$(openssl genrsa 2048) 27 | 28 | # Extract the public key from the private key 29 | public_key=$(echo "$private_key" | openssl rsa -pubout) 30 | 31 | # Encode the keys in the desired format 32 | private_key=$(echo -e "$private_key"| openssl base64 | tr -d '\n') 33 | public_key=$(echo -e "$public_key" | openssl base64 | tr -d '\n') 34 | 35 | # Create the config file 36 | cat << EOF > "$target_path/config.ini" 37 | [KEYS] 38 | SECRET_PRIVATE_KEY=$private_key 39 | SECRET_PUBLIC_KEY=$public_key 40 | EOF 41 | 42 | echo "export SECRET_PUBLIC_KEY=$public_key" >> ~/.bashrc 43 | echo "export SECRET_PRIVATE_KEY=$private_key" >> ~/.bashrc 44 | 45 | echo "Keys file created: $target_path/config.ini" 46 | -------------------------------------------------------------------------------- /setup_utils/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo 'MHQ_EXTRACT_BACKEND_DEPENDENCIES' 4 | if [ -f /opt/venv.tar.gz ]; then 5 | mkdir -p /opt/venv 6 | tar xzf /opt/venv.tar.gz -C /opt/venv --strip-components=2 7 | rm -rf /opt/venv.tar.gz 8 | else 9 | echo "Tar file /opt/venv.tar.gz does not exist. Skipping extraction." 10 | fi 11 | 12 | echo 'MHQ_EXTRACT_FRONTEND' 13 | if [ -f /app/web-server.tar.gz ]; then 14 | mkdir -p /app/web-server 15 | tar xzf /app/web-server.tar.gz -C /app/web-server --strip-components=2 16 | rm -rf /app/web-server.tar.gz 17 | else 18 | echo "Tar file /app/web-server.tar.gz does not exist. Skipping extraction." 19 | fi 20 | 21 | echo 'MHQ_STARTING SUPERVISOR' 22 | 23 | if [ -f "/app/backend/analytics_server/mhq/config/config.ini" ]; then 24 | echo "config.ini found. Setting environment variables from config.ini..." 25 | while IFS='=' read -r key value; do 26 | if [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ && ! -z "$value" ]]; then 27 | echo "$key"="$value" >> ~/.bashrc 28 | fi 29 | done < "../backend/analytics_server/mhq/config/config.ini" 30 | else 31 | echo "config.ini not found. Running generate_config_ini.sh..." 32 | /app/setup_utils/generate_config_ini.sh -t /app/backend/analytics_server/mhq/config 33 | fi 34 | 35 | source ~/.bashrc 36 | 37 | /usr/bin/supervisord -c "/etc/supervisord.conf" 38 | -------------------------------------------------------------------------------- /setup_utils/start_api_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -u 4 | 5 | TOPIC="db_init" 6 | SUB_DIR="/tmp/pubsub" 7 | API_SERVER_PORT=$ANALYTICS_SERVER_PORT 8 | 9 | # Function to wait for message on a topic 10 | wait_for_message() { 11 | while [ ! -f "$SUB_DIR/$TOPIC" ]; do 12 | sleep 1 13 | done 14 | # Read message from topic file 15 | MESSAGE=$(cat "$SUB_DIR/$TOPIC") 16 | echo "Received message: $MESSAGE" 17 | } 18 | 19 | # Wait for message on the specified topic 20 | wait_for_message 21 | 22 | cd /app/backend/analytics_server || exit 23 | if [ "$ENVIRONMENT" == "prod" ]; then 24 | /opt/venv/bin/gunicorn -w 4 -b 0.0.0.0:$API_SERVER_PORT --timeout 0 --access-logfile '-' --error-logfile '-' app:app 25 | else 26 | /opt/venv/bin/gunicorn -w 4 -b 0.0.0.0:$API_SERVER_PORT --timeout 0 --access-logfile '-' --error-logfile '-' --reload app:app 27 | fi 28 | -------------------------------------------------------------------------------- /setup_utils/start_frontend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -u 4 | 5 | cd /app/web-server/ || exit 6 | 7 | if [ "$ENVIRONMENT" == "prod" ]; then 8 | yarn http 9 | else 10 | yarn dev 11 | fi 12 | -------------------------------------------------------------------------------- /setup_utils/start_sync_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -u 4 | 5 | TOPIC="db_init" 6 | SUB_DIR="/tmp/pubsub" 7 | SYNC_SERVER_PORT=$SYNC_SERVER_PORT 8 | 9 | # Function to wait for message on a topic 10 | wait_for_message() { 11 | while [ ! -f "$SUB_DIR/$TOPIC" ]; do 12 | sleep 1 13 | done 14 | # Read message from topic file 15 | MESSAGE=$(cat "$SUB_DIR/$TOPIC") 16 | echo "Received message: $MESSAGE" 17 | } 18 | 19 | # Wait for message on the specified topic 20 | wait_for_message 21 | 22 | cd /app/backend/analytics_server || exit 23 | if [ "$ENVIRONMENT" == "prod" ]; then 24 | /opt/venv/bin/gunicorn -w 2 -b 0.0.0.0:$SYNC_SERVER_PORT --timeout 0 --access-logfile '-' --error-logfile '-' sync_app:app 25 | else 26 | /opt/venv/bin/gunicorn -w 2 -b 0.0.0.0:$SYNC_SERVER_PORT --timeout 0 --access-logfile '-' --error-logfile '-' --reload sync_app:app 27 | fi 28 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | DOCKER_IMAGE_TAGS: 2 | MERGE_COMMIT_SHA: bb2f5ff8876af1ee52816961259e445ec431614b 3 | DOCKER_IMAGE_BUILD_DATE: 2024-05-28T09:58:08Z 4 | -------------------------------------------------------------------------------- /web-server/.eslintignore: -------------------------------------------------------------------------------- 1 | # .estlintignore file 2 | dist 3 | .next 4 | build 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /web-server/.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | dist 3 | .next 4 | build 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /web-server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "printWidth": 80, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSameLine": false 9 | } 10 | -------------------------------------------------------------------------------- /web-server/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["gruntfuggly.todo-tree", "stringham.copy-with-imports"] 3 | } 4 | -------------------------------------------------------------------------------- /web-server/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "yarn dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node-terminal", 19 | "request": "launch", 20 | "command": "yarn dev", 21 | "serverReadyAction": { 22 | "pattern": "started server on .+, url: (https?://.+)", 23 | "uriFormat": "%s", 24 | "action": "debugWithChrome" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /web-server/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.editor.labelFormat": "short", 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "always" 5 | }, 6 | "editor.formatOnSave": false, 7 | "eslint.workingDirectories": [{ "mode": "auto" }], 8 | "tsEssentialPlugins.autoImport.changeSorting": { 9 | "track": ["@/constants/events"], 10 | "useTheme": ["@mui/material"], 11 | "captureException": ["@sentry/node"], 12 | "useDispatch": ["@/store"], 13 | "useSelector": ["@/store"], 14 | "Line": ["@/components/Text", "../Text"], 15 | "createContext": ["react"] 16 | }, 17 | "tsEssentialPlugins.patchOutline": true, 18 | "editor.linkedEditing": true, 19 | "tsEssentialPlugins.enableMethodSnippets": false, 20 | "cSpell.words": [ 21 | "collab", 22 | "openai", 23 | "opsgenie", 24 | "pagerduty", 25 | "Serie", 26 | "zenduty" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /web-server/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest/presets/js-with-babel', // Use the TypeScript preset with Babel 3 | testEnvironment: 'jsdom', // Use jsdom as the test environment (for browser-like behavior) 4 | setupFiles: ['/jest.setup.js'], 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 6 | testMatch: [ 7 | '**/__tests__/**/*.test.(ts|tsx|js|jsx)', 8 | '**/*.test.ts', 9 | '**/*.test.tsx' 10 | ], 11 | transform: { 12 | '^.+\\.(ts|tsx)$': 'ts-jest' // Transform TypeScript files using ts-jest 13 | }, 14 | testPathIgnorePatterns: ['/node_modules/', 'auth.spec.ts'], 15 | moduleNameMapper: { 16 | '^@/public/(.*)$': '/public/$1', 17 | '^@/api/(.*)$': '/pages/api/$1', 18 | '^@/(.*)$': '/src/$1', 19 | '^uuid$': require.resolve('uuid') 20 | }, 21 | moduleDirectories: ['node_modules', 'src'] 22 | }; 23 | -------------------------------------------------------------------------------- /web-server/jest.setup.js: -------------------------------------------------------------------------------- 1 | const { TextEncoder, TextDecoder } = require('util'); 2 | global.TextEncoder = TextEncoder; 3 | global.TextDecoder = TextDecoder; 4 | -------------------------------------------------------------------------------- /web-server/libdefs/chartjs-plugin-trendline.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for chartjs-plugin-trendline 1.0 2 | // Project: https://github.com/Makanz/chartjs-plugin-trendline 3 | // Definitions by: Ferotiq 4 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 5 | 6 | import type { Plugin } from 'chart.js'; 7 | 8 | declare module 'chart.js' { 9 | interface ChartDatasetProperties<_TType extends ChartType, _TData> { 10 | trendlineLinear?: TrendlineLinearPlugin.TrendlineLinearOptions; 11 | } 12 | } 13 | 14 | declare namespace TrendlineLinearPlugin { 15 | interface TrendlineLinearOptions { 16 | colorMin: 'string'; 17 | colorMax: 'string'; 18 | lineStyle: 'dotted' | 'solid'; 19 | width: number; 20 | projection?: boolean; 21 | } 22 | } 23 | 24 | declare const TrendlineLinearPlugin: Plugin; 25 | 26 | export = TrendlineLinearPlugin; 27 | export as namespace TrendlineLinearPlugin; 28 | -------------------------------------------------------------------------------- /web-server/libdefs/font.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.ttf' { 2 | const content: any; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /web-server/libdefs/img.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: any; 3 | export default content; 4 | } 5 | declare module '*.png' { 6 | const content: any; 7 | export default content; 8 | } 9 | -------------------------------------------------------------------------------- /web-server/libdefs/intl.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Intl { 2 | type Key = 3 | | 'calendar' 4 | | 'collation' 5 | | 'currency' 6 | | 'numberingSystem' 7 | | 'timeZone' 8 | | 'unit'; 9 | 10 | function supportedValuesOf(input: Key): string[]; 11 | } 12 | -------------------------------------------------------------------------------- /web-server/libdefs/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unused-imports/no-unused-imports */ 2 | import NextAuth from 'next-auth'; 3 | 4 | declare module 'next-auth' { 5 | /** 6 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 7 | */ 8 | interface Session { 9 | org: Org; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /web-server/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | import { getFeaturesFromReq } from '@/api-helpers/features'; 4 | import { defaultFlags } from '@/constants/feature'; 5 | 6 | export async function middleware(request: NextRequest) { 7 | const flagOverrides = getFeaturesFromReq(request as any); 8 | const flags = { ...defaultFlags, ...flagOverrides }; 9 | 10 | const url = request.nextUrl.clone(); 11 | 12 | // Forward as-is if it's a next-auth URL, except /session 13 | if ( 14 | url.pathname.startsWith('/api/auth') && 15 | !url.pathname.startsWith('/api/auth/session') 16 | ) { 17 | return NextResponse.next(); 18 | } 19 | 20 | url.searchParams.append('feature_flags', JSON.stringify(flags)); 21 | 22 | return NextResponse.rewrite(url); 23 | } 24 | 25 | export const config = { 26 | matcher: [ 27 | '/api/auth/:path*', 28 | '/api/integrations/:path*', 29 | '/api/internal/:path*', 30 | '/api/resources/:path*' 31 | ] 32 | }; 33 | -------------------------------------------------------------------------------- /web-server/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /web-server/next.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NextComponentType, 3 | NextPageContext 4 | } from 'next/dist/shared/lib/utils'; 5 | import type { ReactElement, ReactNode } from 'react'; 6 | 7 | declare module 'next' { 8 | export declare type NextPage

= NextComponentType< 9 | NextPageContext, 10 | IP, 11 | P 12 | > & { 13 | getLayout?: (page: ReactElement) => ReactNode; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /web-server/pages/api/db_status.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 2 | import { db, getCountFromQuery } from '@/utils/db'; 3 | 4 | const endpoint = new Endpoint(nullSchema, { unauthenticated: true }); 5 | 6 | endpoint.handle.GET(nullSchema, async (_req, res) => { 7 | const lastBuildDate = 8 | process.env.NEXT_PUBLIC_BUILD_TIME && 9 | new Date(process.env.NEXT_PUBLIC_BUILD_TIME); 10 | 11 | const start = new Date(); 12 | const dataCheck = await db('Organization').count('*').then(getCountFromQuery); 13 | const diff = new Date().getTime() - start.getTime(); 14 | 15 | res.send({ 16 | status: 'OK', 17 | environment: process.env.NEXT_PUBLIC_APP_ENVIRONMENT, 18 | build_time: lastBuildDate, 19 | data_check: dataCheck, 20 | latency: diff 21 | }); 22 | }); 23 | 24 | export default endpoint.serve(); 25 | -------------------------------------------------------------------------------- /web-server/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 4 | 5 | const getSchema = yup.object().shape({ 6 | name: yup.string().optional() 7 | }); 8 | 9 | const endpoint = new Endpoint(nullSchema, { unauthenticated: true }); 10 | 11 | endpoint.handle.GET(getSchema, async (req, res) => { 12 | const { name } = req.payload; 13 | 14 | res 15 | .status(200) 16 | .send(name ? { hello: name } : { message: 'Usage: ?name=' }); 17 | }); 18 | 19 | export default endpoint.serve(); 20 | -------------------------------------------------------------------------------- /web-server/pages/api/integrations/github/selected.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 4 | import { Columns, Table } from '@/constants/db'; 5 | import { selectedDBReposMock } from '@/mocks/github'; 6 | import { db } from '@/utils/db'; 7 | 8 | const getSchema = yup.object().shape({ 9 | org_id: yup.string().uuid().required() 10 | }); 11 | 12 | const endpoint = new Endpoint(nullSchema); 13 | 14 | endpoint.handle.GET(getSchema, async (req, res) => { 15 | if (req.meta?.features?.use_mock_data) { 16 | return res.send(selectedDBReposMock); 17 | } 18 | 19 | const { org_id } = req.payload; 20 | 21 | const data = await db(Table.OrgRepo) 22 | .select('*') 23 | .where({ org_id, provider: 'github' }) 24 | .andWhereNot(Columns[Table.OrgRepo].is_active, false); 25 | 26 | res.send(data); 27 | }); 28 | 29 | export default endpoint.serve(); 30 | -------------------------------------------------------------------------------- /web-server/pages/api/integrations/index.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 4 | import { Columns, Table } from '@/constants/db'; 5 | import { Integration } from '@/constants/integrations'; 6 | import { db, getFirstRow } from '@/utils/db'; 7 | 8 | const getSchema = yup.object().shape({ 9 | user_id: yup.string().uuid().required() 10 | }); 11 | 12 | const deleteSchema = yup.object().shape({ 13 | provider: yup.string().oneOf(Object.values(Integration)), 14 | org_id: yup.string().uuid().required() 15 | }); 16 | 17 | const endpoint = new Endpoint(nullSchema); 18 | 19 | endpoint.handle.GET(getSchema, async (req, res) => { 20 | const data = await db(Table.UserIdentity) 21 | .select(Columns[Table.UserIdentity].provider) 22 | .where(Columns[Table.UserIdentity].user_id, req.payload.user_id); 23 | 24 | res.send({ integrations: data }); 25 | }); 26 | 27 | endpoint.handle.DELETE(deleteSchema, async (req, res) => { 28 | const data = await db(Table.Integration) 29 | .delete() 30 | .where({ 31 | [Columns[Table.Integration].org_id]: req.payload.org_id, 32 | [Columns[Table.Integration].name]: req.payload.provider 33 | }) 34 | .returning('*') 35 | .then(getFirstRow); 36 | 37 | res.send(data); 38 | }); 39 | 40 | export default endpoint.serve(); 41 | -------------------------------------------------------------------------------- /web-server/pages/api/integrations/integrations-map.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { getLastSyncedAtForCodeProvider } from '@/api/internal/[org_id]/sync_repos'; 4 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 5 | import { CODE_PROVIDER_INTEGRATIONS_MAP } from '@/constants/api'; 6 | 7 | import { getOrgIntegrations } from '../auth/session'; 8 | 9 | const endpoint = new Endpoint(nullSchema); 10 | 11 | const getSchema = yup.object().shape({ 12 | org_id: yup.string().required() 13 | }); 14 | 15 | endpoint.handle.GET(getSchema, async (req, res) => { 16 | const { org_id } = req.payload; 17 | const [integrationsLinkedAtMap, codeProviderLastSyncedAt] = await Promise.all( 18 | [getOrgIntegrations(), getLastSyncedAtForCodeProvider(org_id)] 19 | ); 20 | const integrations = {} as IntegrationsMap; 21 | Object.entries(integrationsLinkedAtMap).forEach( 22 | ([integrationName, integrationLinkedAt]) => { 23 | integrations[integrationName as keyof IntegrationsMap] = { 24 | integrated: true, 25 | linked_at: integrationLinkedAt, 26 | last_synced_at: CODE_PROVIDER_INTEGRATIONS_MAP[ 27 | integrationName as keyof typeof CODE_PROVIDER_INTEGRATIONS_MAP 28 | ] 29 | ? codeProviderLastSyncedAt 30 | : null 31 | }; 32 | } 33 | ); 34 | res.send(integrations); 35 | }); 36 | 37 | export default endpoint.serve(); 38 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/[org_id]/incident_services.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { handleRequest } from '@/api-helpers/axios'; 4 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 5 | import { OrgIncidentServicesApiResponse } from '@/types/resources'; 6 | 7 | const pathSchema = yup.object().shape({ 8 | org_id: yup.string().uuid().required() 9 | }); 10 | 11 | const endpoint = new Endpoint(pathSchema); 12 | 13 | endpoint.handle.GET(nullSchema, async (req, res) => { 14 | const { org_id } = req.payload; 15 | return res.send(getSelectedIncidentServices(org_id)); 16 | }); 17 | 18 | export const getSelectedIncidentServices = (org_id: ID) => 19 | handleRequest( 20 | `/orgs/${org_id}/incident_services` 21 | ).then((r) => r.incident_services); 22 | 23 | export default endpoint.serve(); 24 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/[org_id]/integrations/incident_providers.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { handleRequest } from '@/api-helpers/axios'; 4 | import { Endpoint } from '@/api-helpers/global'; 5 | 6 | type OrgIncidentProviderType = { incident_providers: string[] }; 7 | 8 | const pathSchema = yup.object().shape({ 9 | org_id: yup.string().uuid().required() 10 | }); 11 | 12 | const endpoint = new Endpoint(pathSchema); 13 | 14 | endpoint.handle.GET(null, async (req, res) => { 15 | const { org_id } = req.payload; 16 | return res.send(await getOrgIncidentsProviders(org_id)); 17 | }); 18 | 19 | export const getOrgIncidentsProviders = async (org_id: ID) => { 20 | return await handleRequest( 21 | `/orgs/${org_id}/incident_providers` 22 | ); 23 | }; 24 | 25 | export default endpoint.serve(); 26 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/ai/models.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { handleRequest } from '@/api-helpers/axios'; 4 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 5 | 6 | const getSchema = yup.object().shape({}); 7 | 8 | const endpoint = new Endpoint(nullSchema); 9 | 10 | endpoint.handle.GET(getSchema, async (_req, res) => { 11 | const response = await handleRequest('ai/models', { method: 'GET' }); 12 | res.send(Object.keys(response)); 13 | }); 14 | 15 | export default endpoint.serve(); 16 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/deployments/prs.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { mockDeploymentPrs } from '@/api/internal/team/[team_id]/deployment_prs'; 4 | import { handleRequest } from '@/api-helpers/axios'; 5 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 6 | import { adaptPr } from '@/api-helpers/pr'; 7 | import { BasePR } from '@/types/resources'; 8 | 9 | const getSchema = yup.object().shape({ 10 | deployment_id: yup.string().required() 11 | }); 12 | 13 | const endpoint = new Endpoint(nullSchema); 14 | 15 | endpoint.handle.GET(getSchema, async (req, res) => { 16 | if (req.meta?.features?.use_mock_data) return res.send(mockDeploymentPrs); 17 | 18 | const { deployment_id } = req.payload; 19 | return res.send( 20 | await handleRequest<{ data: BasePR[]; total_count: number }>( 21 | `/deployments/${deployment_id}/prs` 22 | ).then((r) => r.data.map(adaptPr)) 23 | ); 24 | }); 25 | 26 | export default endpoint.serve(); 27 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/hello.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { handleRequest } from '@/api-helpers/axios'; 4 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 5 | 6 | const getSchema = yup.object().shape({ 7 | log_text: yup.string().required() 8 | }); 9 | 10 | const endpoint = new Endpoint(nullSchema); 11 | 12 | // @ts-ignore 13 | endpoint.handle.GET(getSchema, async (req, res) => { 14 | return res.send(await handleRequest(`/hello`)); 15 | }); 16 | 17 | export default endpoint.serve(); 18 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/team/[team_id]/deployment_freq.ts: -------------------------------------------------------------------------------- 1 | import { handleRequest } from '@/api-helpers/axios'; 2 | import { 3 | TeamDeploymentsConfigured, 4 | RepoWithSingleWorkflow 5 | } from '@/types/resources'; 6 | 7 | export const fetchWorkflowConfiguredRepos = async (team_id: ID) => { 8 | const [assignedReposConfig, workflowConfiguration] = await Promise.all([ 9 | handleRequest<{ 10 | repos_included: RepoWithSingleWorkflow[]; 11 | all_team_repos: RepoWithSingleWorkflow[]; 12 | }>(`/teams/${team_id}/lead_time/repos`).catch((e) => { 13 | console.error(e); 14 | return { 15 | repos_included: [], 16 | all_team_repos: [] 17 | }; 18 | }), 19 | handleRequest( 20 | `/teams/${team_id}/deployments_configured` 21 | ) 22 | ]); 23 | return { 24 | ...assignedReposConfig, 25 | ...workflowConfiguration 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/team/[team_id]/deployment_prs.ts: -------------------------------------------------------------------------------- 1 | export const mockDeploymentPrs = [ 2 | { 3 | number: '129', 4 | title: 'Fix login issues', 5 | state: 'MERGED', 6 | first_response_time: 1070, 7 | rework_time: 0, 8 | merge_time: 302, 9 | cycle_time: 1372, 10 | author: { 11 | username: 'shivam-bit', 12 | linked_user: { 13 | id: 'd3731fae-68e9-4ef5-92fb-a7cfb228b888', 14 | name: 'Shivam Singh', 15 | email: 'shivam@middlewarehq.com' 16 | } 17 | }, 18 | reviewers: [ 19 | { 20 | username: 'jayantbh' 21 | } 22 | ], 23 | repo_name: 'web-manager-dash', 24 | pr_link: 'https://github.com/monoclehq/web-manager-dash/pull/129', 25 | base_branch: 'main', 26 | head_branch: 'GROW-336', 27 | created_at: '2023-04-03T10:45:41+00:00', 28 | updated_at: '2023-04-03T11:08:33+00:00', 29 | state_changed_at: '2023-04-03T11:08:33+00:00', 30 | commits: 2, 31 | additions: 95, 32 | deletions: 30, 33 | changed_files: 8, 34 | comments: 1, 35 | provider: 'github', 36 | rework_cycles: 0 37 | } 38 | ]; 39 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/team/[team_id]/revert_prs.ts: -------------------------------------------------------------------------------- 1 | import { batchPaginatedRequest } from '@/api-helpers/internal'; 2 | import { updatePrFilterParams } from '@/api-helpers/team'; 3 | import { RevertedAndOriginalPrPair } from '@/types/resources'; 4 | 5 | export const getTeamRevertedPrs = async ( 6 | params: Awaited> & { 7 | from_time: string; 8 | to_time: string; 9 | team_id: ID; 10 | } 11 | ) => { 12 | const { team_id, from_time, to_time, pr_filter } = params; 13 | 14 | const response = await batchPaginatedRequest( 15 | `/teams/${team_id}/revert_prs`, 16 | { 17 | page: 1, 18 | page_size: 100, 19 | ...{ 20 | from_time, 21 | to_time, 22 | pr_filter 23 | } 24 | } 25 | ).then((r) => r.data); 26 | return adaptRevertedPrs(response); 27 | }; 28 | 29 | const adaptRevertedPrs = (revertedPrsSetArray: RevertedAndOriginalPrPair[]) => 30 | revertedPrsSetArray.map((revertedPrsSet) => { 31 | revertedPrsSet.revert_pr.original_reverted_pr = 32 | revertedPrsSet.original_reverted_pr; 33 | return revertedPrsSet.revert_pr; 34 | }); 35 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/team/[team_id]/settings.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { handleRequest } from '@/api-helpers/axios'; 4 | import { Endpoint } from '@/api-helpers/global'; 5 | import { FetchTeamSettingsAPIResponse } from '@/types/resources'; 6 | 7 | const pathSchema = yup.object().shape({ 8 | team_id: yup.string().uuid().required() 9 | }); 10 | const getSchema = yup.object().shape({ 11 | setting_type: yup.string().required() 12 | }); 13 | 14 | const putSchema = yup.object().shape({ 15 | setting_type: yup.string().required(), 16 | setting_data: yup.object() 17 | }); 18 | 19 | const endpoint = new Endpoint(pathSchema); 20 | 21 | endpoint.handle.PUT(putSchema, async (req, res) => { 22 | const { team_id, setting_data, setting_type } = req.payload; 23 | return res.send( 24 | await handleRequest( 25 | `/teams/${team_id}/settings`, 26 | { 27 | method: 'PUT', 28 | data: { 29 | setting_type, 30 | setting_data 31 | } 32 | } 33 | ) 34 | ); 35 | }); 36 | 37 | endpoint.handle.GET(getSchema, async (req, res) => { 38 | const { team_id, setting_type } = req.payload; 39 | return res.send( 40 | await handleRequest( 41 | `/teams/${team_id}/settings`, 42 | { 43 | params: { 44 | setting_type 45 | } 46 | } 47 | ) 48 | ); 49 | }); 50 | 51 | export default endpoint.serve(); 52 | -------------------------------------------------------------------------------- /web-server/pages/api/internal/track.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 4 | 5 | const postSchema = yup.object().shape({}); 6 | 7 | const endpoint = new Endpoint(nullSchema); 8 | 9 | endpoint.handle.POST(postSchema, async (_req, res) => { 10 | res.send({ success: true }); 11 | }); 12 | 13 | export default endpoint.serve(); 14 | -------------------------------------------------------------------------------- /web-server/pages/api/internal_status.ts: -------------------------------------------------------------------------------- 1 | import { handleRequest } from '@/api-helpers/axios'; 2 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 3 | 4 | const endpoint = new Endpoint(nullSchema, { unauthenticated: true }); 5 | 6 | endpoint.handle.GET(nullSchema, async (_req, res) => { 7 | const lastBuildDate = 8 | process.env.NEXT_PUBLIC_BUILD_TIME && 9 | new Date(process.env.NEXT_PUBLIC_BUILD_TIME); 10 | 11 | const start = new Date(); 12 | const dataCheck = await handleRequest('/'); 13 | const diff = new Date().getTime() - start.getTime(); 14 | 15 | res.send({ 16 | status: 'OK', 17 | environment: process.env.NEXT_PUBLIC_APP_ENVIRONMENT, 18 | build_time: lastBuildDate, 19 | data_check: dataCheck, 20 | latency: diff 21 | }); 22 | }); 23 | 24 | export default endpoint.serve(); 25 | -------------------------------------------------------------------------------- /web-server/pages/api/resources/orgs/[org_id]/filter_users.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { Endpoint } from '@/api-helpers/global'; 4 | import { Table } from '@/constants/db'; 5 | import { db } from '@/utils/db'; 6 | 7 | const getSchema = yup.object().shape({ 8 | user_id: yup.string().uuid().required(), 9 | type: yup.string().required() 10 | }); 11 | 12 | const pathnameSchema = yup.object().shape({ 13 | org_id: yup.string().uuid().required() 14 | }); 15 | 16 | const endpoint = new Endpoint(pathnameSchema); 17 | 18 | endpoint.handle.GET(getSchema, async (req, res) => { 19 | if (req.meta?.features?.use_mock_data) { 20 | return res.send([]); 21 | } 22 | 23 | const { org_id, user_id } = req.payload; 24 | 25 | const allRelations = await db('TeamRelations').select('*').where({ org_id }); 26 | 27 | const usersAsManagers = allRelations.map((relation) => relation.user_id); 28 | const usersAsDirects = allRelations.map( 29 | (relation) => relation.related_user_id 30 | ); 31 | 32 | const usersToExclude = Array.from( 33 | new Set([...usersAsManagers, ...usersAsDirects, user_id]) 34 | ); 35 | 36 | const data = await db(Table.Users) 37 | .select('*') 38 | .where({ org_id }) 39 | .and.not.whereIn('id', usersToExclude); 40 | 41 | res.send(data); 42 | }); 43 | 44 | export default endpoint.serve(); 45 | -------------------------------------------------------------------------------- /web-server/pages/api/resources/orgs/[org_id]/repos.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 4 | import { teamReposMock } from '@/mocks/repos'; 5 | import { db } from '@/utils/db'; 6 | 7 | const pathSchema = yup.object().shape({ 8 | org_id: yup.string().uuid().required() 9 | }); 10 | 11 | const endpoint = new Endpoint(pathSchema); 12 | 13 | endpoint.handle.GET(nullSchema, async (req, res) => { 14 | if (req.meta?.features?.use_mock_data) { 15 | return res.send(teamReposMock); 16 | } 17 | 18 | const data = await db('OrgRepo') 19 | .select('*') 20 | .where('is_active', true) 21 | .andWhere('org_id', req.payload.org_id); 22 | 23 | res.send(data); 24 | }); 25 | 26 | export default endpoint.serve(); 27 | -------------------------------------------------------------------------------- /web-server/pages/api/resources/orgs/[org_id]/teams/team_branch_map.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { 4 | getAllTeamsReposProdBranchesForOrg, 5 | transformTeamRepoBranchesToMap 6 | } from '@/api/internal/team/[team_id]/repo_branches'; 7 | import { Endpoint } from '@/api-helpers/global'; 8 | import { getTeamV2Mock } from '@/mocks/teams'; 9 | 10 | const getSchema = yup.object().shape({}); 11 | 12 | const pathnameSchema = yup.object().shape({ 13 | org_id: yup.string().uuid().required() 14 | }); 15 | 16 | const endpoint = new Endpoint(pathnameSchema); 17 | 18 | endpoint.handle.GET(getSchema, async (req, res) => { 19 | if (req.meta?.features?.use_mock_data) { 20 | return res.send(getTeamV2Mock['teamReposProdBranchMap']); 21 | } 22 | 23 | const { org_id } = req.payload; 24 | 25 | const teamsReposProductionBranchDetails = 26 | await getAllTeamsReposProdBranchesForOrg(org_id); 27 | 28 | const teamReposProdBranchMap = transformTeamRepoBranchesToMap( 29 | teamsReposProductionBranchDetails 30 | ); 31 | 32 | res.send({ teamReposProdBranchMap }); 33 | }); 34 | 35 | export default endpoint.serve(); 36 | -------------------------------------------------------------------------------- /web-server/pages/api/resources/search/teams.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 4 | import { Columns, Table } from '@/constants/db'; 5 | import { db } from '@/utils/db'; 6 | 7 | const getSchema = yup.object().shape({ 8 | name: yup.string().optional().nullable(), 9 | org_id: yup.string().uuid().required() 10 | }); 11 | 12 | const endpoint = new Endpoint(nullSchema); 13 | 14 | endpoint.handle.GET(getSchema, async (req, res) => { 15 | const { name, org_id } = req.payload; 16 | 17 | const query = db('Team') 18 | .select('*') 19 | .where(Columns[Table.Team].org_id, org_id) 20 | .andWhere(Columns[Table.Team].is_deleted, false); 21 | 22 | if (!name) return res.send(await query); 23 | res.send(await query.whereILike('name', `%${name}%`)); 24 | }); 25 | 26 | export default endpoint.serve(); 27 | -------------------------------------------------------------------------------- /web-server/pages/api/resources/search/user.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 4 | import { Columns, Table } from '@/constants/db'; 5 | import { db } from '@/utils/db'; 6 | 7 | const getSchema = yup.object().shape({ 8 | name: yup.string().optional().nullable(), 9 | org_id: yup.string().uuid().required(), 10 | exclude_users: yup.array().of(yup.string().uuid()).optional() 11 | }); 12 | 13 | const endpoint = new Endpoint(nullSchema); 14 | 15 | endpoint.handle.GET(getSchema, async (req, res) => { 16 | const { name, org_id } = req.payload; 17 | 18 | const query = db(Table.Users) 19 | .select('*') 20 | .where(Columns[Table.Users].org_id, org_id) 21 | .andWhere(Columns[Table.Team].is_deleted, false); 22 | 23 | if (req.payload.exclude_users?.length) { 24 | query.whereNotIn('id', req.payload.exclude_users); 25 | } 26 | if (!name) return res.send(await query); 27 | res.send(await query.whereILike('name', `%${name}%`)); 28 | }); 29 | 30 | export default endpoint.serve(); 31 | -------------------------------------------------------------------------------- /web-server/pages/api/resources/teams/[team_id]/unsynced_repos.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { getTeamRepos } from '@/api/resources/team_repos'; 4 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 5 | import { Table } from '@/constants/db'; 6 | import { uuid } from '@/utils/datatype'; 7 | import { db } from '@/utils/db'; 8 | 9 | const pathSchema = yup.object().shape({ 10 | team_id: yup.string().uuid().required() 11 | }); 12 | 13 | const endpoint = new Endpoint(pathSchema); 14 | 15 | endpoint.handle.GET(nullSchema, async (req, res) => { 16 | if (req.meta?.features?.use_mock_data) { 17 | return res.send([uuid(), uuid()]); 18 | } 19 | 20 | res.send(await getUnsyncedRepos(req.payload.team_id)); 21 | }); 22 | 23 | export const getUnsyncedRepos = async (teamId: ID) => { 24 | const query = db(Table.Bookmark).select('repo_id'); 25 | 26 | const teamRepoIds = await getTeamRepos(teamId).then((res) => 27 | res.map((repo) => repo.id) 28 | ); 29 | 30 | const syncedRepos = (await query 31 | .whereIn('repo_id', teamRepoIds) 32 | .then((res) => res.map((item) => item?.repo_id))) as ID[]; 33 | 34 | const unsyncedRepos = teamRepoIds.filter( 35 | (repo) => !syncedRepos.includes(repo) 36 | ); 37 | 38 | return unsyncedRepos; 39 | }; 40 | 41 | export default endpoint.serve(); 42 | -------------------------------------------------------------------------------- /web-server/pages/api/status.ts: -------------------------------------------------------------------------------- 1 | import { isBefore } from 'date-fns'; 2 | import * as yup from 'yup'; 3 | 4 | import { Endpoint, nullSchema } from '@/api-helpers/global'; 5 | 6 | const endpoint = new Endpoint(nullSchema, { unauthenticated: true }); 7 | 8 | const getSchema = yup.object().shape({ 9 | build_time: yup.date().optional() 10 | }); 11 | 12 | endpoint.handle.GET(getSchema, async (req, res) => { 13 | const { build_time } = req.payload; 14 | 15 | const lastBuildDate = 16 | process.env.NEXT_PUBLIC_BUILD_TIME && 17 | new Date(process.env.NEXT_PUBLIC_BUILD_TIME); 18 | 19 | const newBuildReady = 20 | lastBuildDate && 21 | build_time && 22 | isBefore(new Date(build_time), lastBuildDate); 23 | 24 | res.send({ 25 | status: 'OK', 26 | environment: process.env.NEXT_PUBLIC_APP_ENVIRONMENT, 27 | build_time: lastBuildDate, 28 | new_build_available: newBuildReady 29 | }); 30 | }); 31 | 32 | export default endpoint.serve(); 33 | -------------------------------------------------------------------------------- /web-server/pages/dora-metrics/index.tsx: -------------------------------------------------------------------------------- 1 | import ExtendedSidebarLayout from 'src/layouts/ExtendedSidebarLayout'; 2 | 3 | import { Authenticated } from '@/components/Authenticated'; 4 | import { FlexBox } from '@/components/FlexBox'; 5 | import Loader from '@/components/Loader'; 6 | import { FetchState } from '@/constants/ui-states'; 7 | import { useRedirectWithSession } from '@/constants/useRoute'; 8 | import { DoraMetricsBody } from '@/content/DoraMetrics/DoraMetricsBody'; 9 | import { PageWrapper } from '@/content/PullRequests/PageWrapper'; 10 | import { useAuth } from '@/hooks/useAuth'; 11 | import { useSelector } from '@/store'; 12 | import { PageLayout } from '@/types/resources'; 13 | function Page() { 14 | useRedirectWithSession(); 15 | const isLoading = useSelector( 16 | (s) => s.doraMetrics.requests?.metrics_summary === FetchState.REQUEST 17 | ); 18 | const { integrationList } = useAuth(); 19 | 20 | return ( 21 | 24 | DORA metrics 25 | 26 | } 27 | pageTitle="DORA metrics" 28 | isLoading={isLoading} 29 | teamDateSelectorMode="single" 30 | > 31 | {integrationList.length > 0 ? : } 32 | 33 | ); 34 | } 35 | 36 | Page.getLayout = (page: PageLayout) => ( 37 | 38 | {page} 39 | 40 | ); 41 | 42 | export default Page; 43 | -------------------------------------------------------------------------------- /web-server/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, LinearProgress, styled } from '@mui/material'; 2 | import Head from 'next/head'; 3 | import { ReactElement } from 'react'; 4 | import { Authenticated } from 'src/components/Authenticated'; 5 | import ExtendedSidebarLayout from 'src/layouts/ExtendedSidebarLayout'; 6 | 7 | import { FlexBox } from '@/components/FlexBox'; 8 | import { Line } from '@/components/Text'; 9 | import { useRedirectWithSession } from '@/constants/useRoute'; 10 | 11 | const OverviewWrapper = styled(Box)( 12 | ({ theme }) => ` 13 | overflow: auto; 14 | background: ${theme.palette.common.white}; 15 | flex: 1; 16 | overflow-x: hidden; 17 | ` 18 | ); 19 | 20 | function Overview() { 21 | useRedirectWithSession(); 22 | 23 | return ( 24 | 25 | 26 | MiddlewareHQ 27 | 28 | 29 | 30 | Please wait while we load the session for you... 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | 38 | export default Overview; 39 | 40 | Overview.getLayout = function getLayout(page: ReactElement) { 41 | return ( 42 | 43 | {page} 44 | 45 | ); 46 | }; 47 | 48 | // Overview.getInitialProps = redirectPage(DEFAULT_HOME_ROUTE.PATH); 49 | -------------------------------------------------------------------------------- /web-server/pages/server-admin.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { Authenticated } from 'src/components/Authenticated'; 3 | import ExtendedSidebarLayout from 'src/layouts/ExtendedSidebarLayout'; 4 | 5 | import { useRedirectWithSession } from '@/constants/useRoute'; 6 | import { PageLayout } from '@/types/resources'; 7 | 8 | function Integrations() { 9 | useRedirectWithSession(); 10 | return ( 11 | <> 12 | 13 | Server Admin 14 | 15 | 16 | ); 17 | } 18 | 19 | Integrations.getLayout = (page: PageLayout) => ( 20 | 21 | {page} 22 | 23 | ); 24 | 25 | export default Integrations; 26 | -------------------------------------------------------------------------------- /web-server/pages/system-logs.tsx: -------------------------------------------------------------------------------- 1 | import { Authenticated } from 'src/components/Authenticated'; 2 | 3 | import { FlexBox } from '@/components/FlexBox'; 4 | import { SystemStatus } from '@/components/Service/SystemStatus'; 5 | import { useRedirectWithSession } from '@/constants/useRoute'; 6 | import { PageWrapper } from '@/content/PullRequests/PageWrapper'; 7 | import ExtendedSidebarLayout from '@/layouts/ExtendedSidebarLayout'; 8 | import { useSelector } from '@/store'; 9 | import { PageLayout } from '@/types/resources'; 10 | 11 | function Service() { 12 | useRedirectWithSession(); 13 | 14 | const loading = useSelector((state) => state.service.loading); 15 | 16 | return ( 17 | 20 | System logs 21 | 22 | } 23 | hideAllSelectors 24 | pageTitle="System logs" 25 | showEvenIfNoTeamSelected={true} 26 | isLoading={loading} 27 | > 28 | 29 | 30 | ); 31 | } 32 | 33 | Service.getLayout = (page: PageLayout) => ( 34 | 35 | {page} 36 | 37 | ); 38 | 39 | export default Service; 40 | -------------------------------------------------------------------------------- /web-server/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | require('dotenv').config({ path: '.env.local' }); 4 | 5 | const config: PlaywrightTestConfig = { 6 | reporter: [['html', { open: 'never' }]] 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /web-server/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /web-server/public/assets/PAT_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/public/assets/PAT_permissions.png -------------------------------------------------------------------------------- /web-server/public/assets/gitlabPAT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/public/assets/gitlabPAT.png -------------------------------------------------------------------------------- /web-server/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/public/favicon.ico -------------------------------------------------------------------------------- /web-server/public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/public/icon-192x192.png -------------------------------------------------------------------------------- /web-server/public/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/public/icon-256x256.png -------------------------------------------------------------------------------- /web-server/public/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/public/icon-384x384.png -------------------------------------------------------------------------------- /web-server/public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/public/icon-512x512.png -------------------------------------------------------------------------------- /web-server/public/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/public/icon-96x96.png -------------------------------------------------------------------------------- /web-server/public/imageStatusApiWorker.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('message', async (event) => { 2 | const { apiUrl, interval } = event.data; 3 | if (!apiUrl || !interval) { 4 | self.postMessage({ error: 'apiUrl and interval are required' }); 5 | return; 6 | } 7 | const fetchData = async () => { 8 | try { 9 | const response = await fetch(apiUrl); 10 | 11 | const data = await response.json(); 12 | self.postMessage({ data }); 13 | } catch (error) { 14 | self.postMessage({ error: error.message }); 15 | } 16 | }; 17 | 18 | // Fetch data immediately 19 | await fetchData(); 20 | 21 | // Set interval to fetch data periodically 22 | const intervalId = setInterval(fetchData, interval); 23 | 24 | // Listen for stop message to clear the interval 25 | self.addEventListener('message', (e) => { 26 | if (e.data === 'stop') { 27 | clearInterval(intervalId); 28 | } 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /web-server/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#6B1FB7", 3 | "background_color": "#FFFFFF", 4 | "display": "standalone", 5 | "start_url": ".", 6 | "short_name": "MiddlwareHQ", 7 | "name": "MiddlwareHQ", 8 | "icons": [ 9 | { 10 | "src": "/icon-96x96.png", 11 | "sizes": "96x96", 12 | "type": "image/png" 13 | }, 14 | { 15 | "src": "/icon-192x192.png", 16 | "sizes": "192x192", 17 | "type": "image/png" 18 | }, 19 | { 20 | "src": "/icon-256x256.png", 21 | "sizes": "256x256", 22 | "type": "image/png" 23 | }, 24 | { 25 | "src": "/icon-384x384.png", 26 | "sizes": "384x384", 27 | "type": "image/png" 28 | }, 29 | { 30 | "src": "/icon-512x512.png", 31 | "sizes": "512x512", 32 | "type": "image/png" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /web-server/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /web-server/public/static/images/placeholders/logo/google-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web-server/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | DIR=$(dirname $0) 5 | 6 | source $DIR/utils.sh 7 | catch_force_exit 8 | is_project_root 9 | 10 | NEXT_MANUAL_SIG_HANDLE=true 11 | yarn run next build 12 | 13 | echo "EXITED $?" 14 | 15 | rm -rf .next/cache 16 | yarn run zip 17 | -------------------------------------------------------------------------------- /web-server/scripts/server-init.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | DIR=$(dirname $0) 5 | 6 | source $DIR/utils.sh 7 | is_project_root 8 | 9 | install_yarn_cmd_if_not_exists pm2 10 | # install_if_missing_vector 11 | 12 | set +e 13 | pm2 delete MHQ_HTTP_SERVER 14 | set -e 15 | 16 | NEXT_MANUAL_SIG_HANDLE=true 17 | pm2 start "yarn http" --name MHQ_HTTP_SERVER 18 | -------------------------------------------------------------------------------- /web-server/scripts/utils.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | function catch_force_exit() { 5 | # stty -echoctl 6 | trap force_exit INT 7 | 8 | force_exit() { 9 | echo "Force exiting..." 10 | } 11 | } 12 | 13 | function is_project_root() { 14 | if [ -f ./package.json ] && [ -f ./next.config.js ]; then 15 | return 0 16 | else 17 | echo "You must run this command from the project root."; 18 | exit 1 19 | fi 20 | } 21 | 22 | function install_yarn_cmd_if_not_exists() { 23 | if ! command -v $1; then 24 | yarn global add $1 25 | if ! command -v $1; then 26 | export PATH=$PATH:$(yarn global bin); 27 | if ! command -v $1; then echo "$1 command not found. exiting..."; fi 28 | fi 29 | else 30 | echo "$1 command exists" 31 | fi 32 | } 33 | -------------------------------------------------------------------------------- /web-server/scripts/zip.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -e 3 | 4 | DIR=$(dirname $0) 5 | 6 | source $DIR/utils.sh 7 | is_project_root 8 | 9 | echo "Starting artifact creation" 10 | 11 | rm -rf artifacts 12 | mkdir artifacts 13 | tar -czf \ 14 | artifacts/artifact.tar.gz \ 15 | package.json \ 16 | yarn.lock \ 17 | http-server.js \ 18 | .next \ 19 | next.config.js \ 20 | public \ 21 | scripts \ 22 | 23 | 24 | echo "Completed artifact creation" 25 | -------------------------------------------------------------------------------- /web-server/src/api-helpers/axios-api-instance.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosPromise, AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import axiosRetry from 'axios-retry'; 3 | 4 | import { loggerInterceptor } from '@/api-helpers/axios'; 5 | 6 | const _api = axios.create({ 7 | baseURL: '/api' 8 | }); 9 | 10 | const browserInterceptor = loggerInterceptor('browser'); 11 | _api.interceptors.request.use(browserInterceptor); 12 | 13 | export const api = _api; 14 | axiosRetry(api, { retries: 2 }); 15 | axiosRetry(axios, { retries: 2 }); 16 | 17 | export const handleApiRaw = ( 18 | url: string, 19 | params: AxiosRequestConfig = { method: 'get' } 20 | ): AxiosPromise => 21 | api({ 22 | url, 23 | ...params 24 | }); 25 | 26 | export const handleApi = ( 27 | url: string, 28 | params: AxiosRequestConfig = { method: 'get' } 29 | ): Promise => 30 | handleApiRaw(url, { 31 | ...params, 32 | headers: { 'Content-Type': 'application/json' } 33 | }) 34 | .then(handleThen) 35 | .catch(handleCatch); 36 | 37 | export const handleThen = (r: AxiosResponse) => r.data; 38 | export const handleCatch = (r: { response: AxiosResponse }) => { 39 | throw r.response; 40 | }; 41 | -------------------------------------------------------------------------------- /web-server/src/api-helpers/features.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from 'next/server'; 2 | import { NextApiRequest } from 'next/types'; 3 | 4 | import { PERSISTED_FLAG_KEY } from '@/constants/api'; 5 | import { Features } from '@/constants/feature'; 6 | import { ApiRequest } from '@/types/request'; 7 | 8 | export const getFeaturesFromReq = ( 9 | request: NextRequest | NextApiRequest 10 | ): Features => { 11 | const cookie = 12 | (request as NextRequest).cookies.get?.(PERSISTED_FLAG_KEY)?.value || 13 | (request as NextApiRequest).cookies[PERSISTED_FLAG_KEY] || 14 | '{}'; 15 | 16 | return typeof cookie === 'string' ? JSON.parse(cookie) : cookie; 17 | }; 18 | 19 | export const getFlagsFromRequest = ( 20 | req: NextApiRequest 21 | ): ApiRequest['meta'] => { 22 | if (!req.query?.feature_flags) return undefined; 23 | return { 24 | features: JSON.parse(req.query.feature_flags as string) 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /web-server/src/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/src/assets/background.png -------------------------------------------------------------------------------- /web-server/src/assets/dora-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web-server/src/assets/fonts/Inter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/src/assets/fonts/Inter.ttf -------------------------------------------------------------------------------- /web-server/src/assets/git-merge-line.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /web-server/src/components/AiButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps, useTheme } from '@mui/material'; 2 | import { FC, useRef } from 'react'; 3 | import { SiOpenai } from 'react-icons/si'; 4 | 5 | import { track } from '@/constants/events'; 6 | 7 | export const AiButton: FC void }> = ({ 8 | onClickCallback, 9 | ...props 10 | }) => { 11 | const theme = useTheme(); 12 | const mouseEnterRef = useRef(null); 13 | 14 | return ( 15 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /web-server/src/components/AnimatedInputWrapper/slider.module.css: -------------------------------------------------------------------------------- 1 | .animationWrapper { 2 | position: absolute; 3 | top: 50%; 4 | transform: translate(0%, -50%); 5 | font-size: 1em; 6 | display: flex; 7 | gap: 4px; 8 | } 9 | 10 | .placeholder { 11 | height: 1.4em; 12 | width: 10rem; 13 | position: relative; 14 | } 15 | 16 | .repo { 17 | height: 1.4em; 18 | } 19 | 20 | .text { 21 | position: relative; 22 | transition: all 0.5s ease; 23 | } 24 | 25 | .textslide { 26 | display: flex; 27 | height: 1.4em; 28 | align-items: flex-start; 29 | } 30 | -------------------------------------------------------------------------------- /web-server/src/components/AvatarPageTitle.tsx: -------------------------------------------------------------------------------- 1 | import { styled, Avatar, alpha } from '@mui/material'; 2 | 3 | export const AvatarPageTitle = styled(Avatar)(({ theme }) => ({ 4 | width: theme.spacing(4), 5 | height: theme.spacing(4), 6 | color: theme.colors.primary.main, 7 | marginTop: theme.spacing(-1), 8 | marginBottom: theme.spacing(-1), 9 | marginRight: theme.spacing(2), 10 | background: alpha(theme.colors.alpha.trueWhite[100], 0.05) 11 | })); 12 | -------------------------------------------------------------------------------- /web-server/src/components/Chart2/index.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic'; 2 | 3 | export const Chart2 = dynamic(() => import('./InternalChart2'), { 4 | ssr: false 5 | }); 6 | 7 | export type { 8 | ChartLabels, 9 | ChartOnClick, 10 | ChartOnZoom, 11 | ChartOptions, 12 | ChartProps, 13 | ChartSeries, 14 | ChartType 15 | } from './InternalChart2'; 16 | 17 | export { getChartZoomResetBtn, resetChartById } from './InternalChart2'; 18 | -------------------------------------------------------------------------------- /web-server/src/components/ErrorBoundaryFallback/err-pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/src/components/ErrorBoundaryFallback/err-pattern.png -------------------------------------------------------------------------------- /web-server/src/components/FeatureFlagOverrides.tsx: -------------------------------------------------------------------------------- 1 | import { Slide } from '@mui/material'; 2 | import { TransitionProps } from '@mui/material/transitions'; 3 | import { forwardRef, ReactElement, Ref } from 'react'; 4 | 5 | export const Transition = forwardRef(function Transition( 6 | props: TransitionProps & { children: ReactElement }, 7 | ref: Ref 8 | ) { 9 | return ; 10 | }); 11 | -------------------------------------------------------------------------------- /web-server/src/components/HeaderBtn.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingButton, LoadingButtonProps } from '@mui/lab'; 2 | import { useTheme } from '@mui/material'; 3 | import { forwardRef } from 'react'; 4 | 5 | export const HeaderBtn = forwardRef((props: LoadingButtonProps, ref: any) => { 6 | const theme = useTheme(); 7 | 8 | return ( 9 | 37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /web-server/src/components/InsightChip.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowForwardRounded, InfoOutlined } from '@mui/icons-material'; 2 | import { useTheme } from '@mui/material'; 3 | import { FC, ReactNode } from 'react'; 4 | 5 | import { FlexBox, FlexBoxProps } from '@/components/FlexBox'; 6 | import { deepMerge } from '@/utils/datatype'; 7 | 8 | export const InsightChip: FC< 9 | { startIcon?: ReactNode; endIcon?: ReactNode; cta?: ReactNode } & FlexBoxProps 10 | > = ({ 11 | startIcon = startIconDefault, 12 | endIcon = endIconDefault, 13 | cta, 14 | children, 15 | ...props 16 | }) => { 17 | const theme = useTheme(); 18 | 19 | return ( 20 | 35 | {startIcon} 36 | {children} 37 | {cta} 38 | {endIcon} 39 | 40 | ); 41 | }; 42 | 43 | const startIconDefault = ; 44 | const endIconDefault = ; 45 | -------------------------------------------------------------------------------- /web-server/src/components/LegendItem.tsx: -------------------------------------------------------------------------------- 1 | import { Box, useTheme } from '@mui/material'; 2 | import { FC, ReactNode } from 'react'; 3 | 4 | export const LegendItem: FC<{ 5 | size?: 'default' | 'small'; 6 | color: string; 7 | label: ReactNode; 8 | }> = ({ size: _size = 'default', color, label }) => { 9 | const theme = useTheme(); 10 | const size = _size === 'default' ? 1.5 : 1; 11 | const fontSize = _size === 'default' ? '1em' : '0.8em'; 12 | const gap = _size === 'default' ? 1 : 0.5; 13 | return ( 14 | 15 | 21 | 22 | {label} 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /web-server/src/components/LegendsMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Box, useTheme } from '@mui/material'; 2 | import { Serie } from '@nivo/line'; 3 | import { FC } from 'react'; 4 | 5 | import { FlexBox } from './FlexBox'; 6 | 7 | export const LegendsMenu: FC<{ 8 | series: Serie[]; 9 | }> = ({ series }) => { 10 | const theme = useTheme(); 11 | 12 | return ( 13 | 21 | {series?.map((dataset, index) => { 22 | return ( 23 | 24 | 25 | 26 | {dataset.id} 27 | 35 | 36 | 37 | 38 | ); 39 | })} 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /web-server/src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress } from '@mui/material'; 2 | 3 | import { getRandomLoadMsg } from '@/utils/loading-messages'; 4 | 5 | import { Line } from '../Text'; 6 | 7 | function Loader(props: { mode?: 'full-screen' | 'full-size' }) { 8 | const position = props.mode === 'full-size' ? 'absolute' : 'fixed'; 9 | 10 | return ( 11 | 19 | 20 | {getRandomLoadMsg()} 21 | 22 | Getting app data. Takes a moment... 23 | 24 | 25 | ); 26 | } 27 | 28 | export default Loader; 29 | -------------------------------------------------------------------------------- /web-server/src/components/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { FC, HTMLProps } from 'react'; 2 | 3 | import LogoLongSvg from './logo-long.svg'; 4 | import LogoSvg from './logo.svg'; 5 | 6 | export const Logo: FC< 7 | HTMLProps & { mode?: 'short' | 'long' } 8 | > = (props) => { 9 | if (props.mode === 'long') return ; 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /web-server/src/components/Logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /web-server/src/components/MiniButton.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIconComponent } from '@mui/icons-material'; 2 | import { LoadingButton, LoadingButtonProps } from '@mui/lab'; 3 | import { FC, forwardRef } from 'react'; 4 | 5 | export const MiniButton: FC< 6 | LoadingButtonProps & { Icon?: SvgIconComponent; place?: 'start' | 'end' } 7 | > = forwardRef(({ Icon, place = 'start', loading, ...props }, ref) => { 8 | return ( 9 | 30 | ) 31 | } 32 | endIcon={ 33 | Icon && 34 | place === 'end' && ( 35 | 36 | ) 37 | } 38 | > 39 | {props.children} 40 | 41 | ); 42 | }); 43 | -------------------------------------------------------------------------------- /web-server/src/components/MiniLoader.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps, CircularProgress, LinearProgress } from '@mui/material'; 2 | import { FC, ReactNode } from 'react'; 3 | 4 | import { FlexBox, FlexBoxProps } from './FlexBox'; 5 | 6 | export const MiniLoader: FC<{ label: ReactNode } & BoxProps> = ({ 7 | label, 8 | ...props 9 | }) => ( 10 | 11 | {label} 12 | 13 | 14 | ); 15 | 16 | export const MiniCircularLoader: FC< 17 | { 18 | label: ReactNode; 19 | position?: 'start' | 'end'; 20 | } & FlexBoxProps 21 | > = ({ label, position = 'start', ...props }) => ( 22 | 23 | {position === 'end' && label} 24 | 25 | {position === 'start' && label} 26 | 27 | ); 28 | -------------------------------------------------------------------------------- /web-server/src/components/MotionComponents.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material'; 2 | import { motion } from 'framer-motion'; 3 | 4 | export const MotionBox = motion(Box); 5 | -------------------------------------------------------------------------------- /web-server/src/components/NoTeamSelected.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Typography } from '@mui/material'; 2 | import Link from 'next/link'; 3 | import { FC } from 'react'; 4 | 5 | import NoTeamSelectedSvg from '@/assets/no-team-selected.svg'; 6 | import { ROUTES } from '@/constants/routes'; 7 | 8 | export const NoTeamSelected: FC = () => { 9 | return ( 10 | 17 | 18 | 19 | Select a team to get started 20 | 21 | 22 | Want to create a new one? 23 | 24 | 25 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /web-server/src/components/OverlayComponents/ChangeFailureRate.tsx: -------------------------------------------------------------------------------- 1 | import { FlexBox } from '../FlexBox'; 2 | 3 | export const ChangeFailureRate = () => { 4 | return ChangeFailureRate; 5 | }; 6 | -------------------------------------------------------------------------------- /web-server/src/components/OverlayComponents/Dummy.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | import { FlexBox } from '../FlexBox'; 4 | import { Line } from '../Text'; 5 | 6 | export const Dummy = () => { 7 | const router = useRouter(); 8 | return ( 9 | 10 | 11 | Hi! This is a dummy component! 12 | 13 | 14 | {JSON.stringify(router.query, null, ' ')} 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /web-server/src/components/OverlayComponents/TeamEdit.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | 3 | import { CRUDProps } from '@/components/Teams/CreateTeams'; 4 | import { usePageRefreshCallback } from '@/hooks/usePageRefreshCallback'; 5 | 6 | import { FlexBox } from '../FlexBox'; 7 | import { useOverlayPage } from '../OverlayPageContext'; 8 | import { CreateEditTeams } from '../Teams/CreateTeams'; 9 | 10 | export const TeamEdit: FC = ({ teamId }) => { 11 | const { removeAll } = useOverlayPage(); 12 | const pageRefreshCallback = usePageRefreshCallback(); 13 | 14 | return ( 15 | 16 | { 20 | removeAll(); 21 | pageRefreshCallback(); 22 | }} 23 | /> 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /web-server/src/components/PageContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { ErrorBoundary } from 'react-error-boundary'; 3 | 4 | import { ErrorBoundaryFallback } from '@/components/ErrorBoundaryFallback/index'; 5 | 6 | import { FlexBox, FlexBoxProps } from './FlexBox'; 7 | import Scrollbar from './Scrollbar'; 8 | 9 | export const PageContentWrapper: FC< 10 | FlexBoxProps & { noScrollbars?: boolean } 11 | > = ({ children, noScrollbars, ...props }) => { 12 | const content = ( 13 | 24 | 25 | {children} 26 | 27 | 28 | ); 29 | 30 | if (noScrollbars) return content; 31 | 32 | return {content}; 33 | }; 34 | -------------------------------------------------------------------------------- /web-server/src/components/PageTitleWrapper/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material'; 2 | import { BoxProps } from '@mui/system'; 3 | import PropTypes from 'prop-types'; 4 | import { FC } from 'react'; 5 | 6 | const DefaultPageTitleWrapper: FC = ({ children, ...props }) => { 7 | return ( 8 | 14 | {children} 15 | 16 | ); 17 | }; 18 | 19 | DefaultPageTitleWrapper.propTypes = { 20 | children: PropTypes.node.isRequired 21 | }; 22 | 23 | export default DefaultPageTitleWrapper; 24 | 25 | export const PageTitleWrapper: FC = ({ children, ...props }) => { 26 | return ( 27 | 34 | {children} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /web-server/src/components/RepoCard.tsx: -------------------------------------------------------------------------------- 1 | import { DataObjectRounded } from '@mui/icons-material'; 2 | import { Typography, styled } from '@mui/material'; 3 | 4 | import GitBranch from '@/assets/git-merge-line.svg'; 5 | import { Repo } from '@/types/github'; 6 | import { DB_OrgRepo } from '@/types/resources'; 7 | 8 | export const RepoTitle = styled(Typography)(() => ({ 9 | textOverflow: 'ellipsis', 10 | whiteSpace: 'nowrap', 11 | overflow: 'hidden', 12 | display: 'block', 13 | cursor: 'pointer' 14 | })); 15 | 16 | export const RepoDescription = styled(Typography)(() => ({ 17 | width: '100%', 18 | textOverflow: 'ellipsis', 19 | overflow: 'hidden', 20 | display: 'block' 21 | })); 22 | 23 | export const RepoLangIcon = styled(DataObjectRounded)(({ theme }) => ({ 24 | marginLeft: theme.spacing(-1 / 4), 25 | marginRight: theme.spacing(1 / 2), 26 | opacity: 0.8, 27 | height: '0.7em', 28 | width: '0.7em' 29 | })); 30 | 31 | export const GitBranchIcon = styled(GitBranch)(({ theme }) => ({ 32 | marginRight: theme.spacing(0.5), 33 | opacity: 0.8, 34 | height: '1.5em', 35 | width: '1.5em' 36 | })); 37 | 38 | export const adaptDbRepo = (repo: DB_OrgRepo): Partial => ({ 39 | name: repo.name, 40 | html_url: `//${repo.provider}.com/${repo.org_name}/${repo.name}`, 41 | language: repo.language, 42 | default_branch: repo.default_branch 43 | }); 44 | -------------------------------------------------------------------------------- /web-server/src/components/Service/SystemLog/PlainLog.tsx: -------------------------------------------------------------------------------- 1 | import { Line } from '@/components/Text'; 2 | 3 | export const PlainLog = ({ log }: { log: string; index: number }) => { 4 | return ( 5 | 6 | {log} 7 | 8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /web-server/src/components/TeamSelector/defaultPopoverProps.tsx: -------------------------------------------------------------------------------- 1 | import { PopoverProps } from '@mui/material'; 2 | 3 | export const defaultPopoverProps: Partial = { 4 | disableScrollLock: true, 5 | anchorOrigin: { 6 | vertical: 'top', 7 | horizontal: 'left' 8 | }, 9 | transformOrigin: { 10 | vertical: 'top', 11 | horizontal: 'left' 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /web-server/src/components/TicketsTableAddons/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | import { SearchRounded } from '@mui/icons-material'; 2 | import ClearRoundedIcon from '@mui/icons-material/ClearRounded'; 3 | import { IconButton, InputAdornment, TextField } from '@mui/material'; 4 | import { FC } from 'react'; 5 | 6 | export const SearchInput: FC<{ 7 | inputHandler: (inputText: string) => void; 8 | inputText: string; 9 | }> = ({ inputHandler, inputText }) => { 10 | return ( 11 | inputHandler(e.target.value)} 16 | InputProps={{ 17 | startAdornment: , 18 | endAdornment: ( 19 | 20 | {inputText && ( 21 | inputHandler('')} 24 | edge="end" 25 | > 26 | 27 | 28 | )} 29 | 30 | ) 31 | }} 32 | sx={{ width: '350px' }} 33 | /> 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /web-server/src/components/TopLevelLogicComponent.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from 'react'; 2 | 3 | import { useImageUpdateStatusWorker } from '@/hooks/useImageUpdateStatusWorker'; 4 | import { getGithubRepoStars } from '@/slices/app'; 5 | import { useDispatch } from '@/store'; 6 | 7 | export const TopLevelLogicComponent: FC = () => { 8 | const dispatch = useDispatch(); 9 | 10 | useEffect(() => { 11 | dispatch(getGithubRepoStars()); 12 | }, [dispatch, getGithubRepoStars]); 13 | 14 | useImageUpdateStatusWorker(); 15 | 16 | return null; 17 | }; 18 | -------------------------------------------------------------------------------- /web-server/src/constants/api.ts: -------------------------------------------------------------------------------- 1 | import { Integration } from './integrations'; 2 | 3 | export const PERSISTED_FLAG_KEY = `application-persisted-feature-flags`; 4 | 5 | export const CODE_PROVIDER_INTEGRATIONS_MAP = { 6 | [Integration.GITHUB]: Integration.GITHUB, 7 | [Integration.GITLAB]: Integration.GITLAB, 8 | [Integration.BITBUCKET]: Integration.BITBUCKET 9 | }; 10 | -------------------------------------------------------------------------------- /web-server/src/constants/feature.ts: -------------------------------------------------------------------------------- 1 | export const defaultFlags = { 2 | dummy_function_flag: (_args: { orgId: ID }) => true, 3 | use_mock_data: false, 4 | enable_pr_cycle_time_comparison: false, 5 | use_hotkeys: true, 6 | show_deployment_settings: false, 7 | show_incident_settings: false 8 | }; 9 | 10 | export type Features = typeof defaultFlags; 11 | -------------------------------------------------------------------------------- /web-server/src/constants/generic.ts: -------------------------------------------------------------------------------- 1 | export const MAX_INT = 2147483647 - 1; 2 | export const SAFE_DELIMITER = '╡'; 3 | export const ASCII_SAFE_DELIM = '/`/,/`/'; 4 | export const ONE_KB = 1024 * 1024; 5 | export const ONE_MB = ONE_KB * 1024; 6 | export const END_OF_TIME = new Date(1e15); 7 | -------------------------------------------------------------------------------- /web-server/src/constants/integrations.ts: -------------------------------------------------------------------------------- 1 | export enum Integration { 2 | GOOGLE = 'google', 3 | JIRA = 'jira', 4 | SLACK = 'slack', 5 | GITHUB = 'github', 6 | BITBUCKET = 'bitbucket', 7 | GITLAB = 'gitlab', 8 | ZENDUTY = 'zenduty', 9 | PAGERDUTY = 'pagerduty', 10 | OPSGENIE = 'opsgenie', 11 | MICROSOFT = 'azure-ad', 12 | CIRCLECI = 'circle_ci' 13 | } 14 | 15 | export enum CIProvider { 16 | GITHUB_ACTIONS = 'GITHUB_ACTIONS', 17 | CIRCLE_CI = 'CIRCLE_CI' 18 | } 19 | 20 | export enum WorkflowType { 21 | DEPLOYMENT = 'DEPLOYMENT' 22 | } 23 | -------------------------------------------------------------------------------- /web-server/src/constants/log-formatter.ts: -------------------------------------------------------------------------------- 1 | export const generalLogRegex = 2 | /^\[(.*?)\] \[(\d+)\] \[(INFO|ERROR|WARN|DEBUG|WARNING|CRITICAL)\] (.+)$/; 3 | export const httpLogRegex = 4 | /^(\S+) (\S+) (\S+) \[([^\]]+)\] "([^"]*)" (\d+) (\d+) "([^"]*)" "([^"]*)"$/; 5 | export const redisLogRegex = 6 | /^(?\d+:[XCMS]) (?\d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2}\.\d{3}) (?[\.\-\#\*]) (?.*)$/; 7 | export const postgresLogRegex = 8 | /^(?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC) \[\d+\] (?[A-Z]+):\s*(?(.|\n)+?)$/; 9 | export const dataSyncLogRegex = /\[(\w+)\]\s(.+?)\sfor\s(\w+)\s(.+)/; 10 | -------------------------------------------------------------------------------- /web-server/src/constants/notification.ts: -------------------------------------------------------------------------------- 1 | export const DURATION = 2000; 2 | export const SUCCESS = 'success'; 3 | export const ERROR = 'error'; 4 | export const DIRECT_ADDED = 'Direct Added Successfully!!'; 5 | export const MANAGER_ADDED = 'Manager Added successfully!!'; 6 | export const ERROR_ADDING_USER = 'Error Adding User'; 7 | export const UNKNOWN_ERROR = 'Unknown Error Occured'; 8 | export const USER_DELETED = 'User Deleted Succesfully'; 9 | -------------------------------------------------------------------------------- /web-server/src/constants/relations.ts: -------------------------------------------------------------------------------- 1 | export enum UserRelations { 2 | MANAGER = 'MANAGER', 3 | DIRECT = 'DIRECT' 4 | } 5 | -------------------------------------------------------------------------------- /web-server/src/constants/service.ts: -------------------------------------------------------------------------------- 1 | export enum ServiceNames { 2 | API_SERVER = 'api-server-service', 3 | REDIS = 'redis-service', 4 | POSTGRES = 'postgres-service', 5 | SYNC_SERVER = 'sync-server-service' 6 | } 7 | -------------------------------------------------------------------------------- /web-server/src/constants/stream.ts: -------------------------------------------------------------------------------- 1 | import { ServiceNames } from './service'; 2 | 3 | type LogFile = { 4 | path: string; 5 | serviceName: ServiceNames; 6 | }; 7 | 8 | type ServiceStatus = Record; 9 | 10 | type LogUpdateData = { 11 | serviceName: ServiceNames; 12 | content: string; 13 | }; 14 | 15 | type StatusUpdateData = { 16 | statuses: ServiceStatus; 17 | }; 18 | 19 | type SendEventData = LogUpdateData | StatusUpdateData; 20 | 21 | const UPDATE_INTERVAL = 10000; 22 | 23 | const LOG_FILES: LogFile[] = [ 24 | { 25 | path: '/var/log/apiserver/apiserver.log', 26 | serviceName: ServiceNames.API_SERVER 27 | }, 28 | { 29 | path: '/var/log/sync_server/sync_server.log', 30 | serviceName: ServiceNames.SYNC_SERVER 31 | }, 32 | { 33 | path: '/var/log/redis/redis.log', 34 | serviceName: ServiceNames.REDIS 35 | }, 36 | { 37 | path: '/var/log/postgres/postgres.log', 38 | serviceName: ServiceNames.POSTGRES 39 | } 40 | ]; 41 | 42 | enum StreamEventType { 43 | StatusUpdate = 'status-update', 44 | LogUpdate = 'log-update' 45 | } 46 | 47 | enum FileEvent { 48 | Change = 'change' 49 | } 50 | 51 | export type { LogFile, ServiceStatus, SendEventData }; 52 | export { UPDATE_INTERVAL, LOG_FILES, StreamEventType, FileEvent }; 53 | -------------------------------------------------------------------------------- /web-server/src/constants/ui-states.ts: -------------------------------------------------------------------------------- 1 | export enum FetchState { 2 | REQUEST = 'REQUEST', 3 | SUCCESS = 'SUCCESS', 4 | FAILURE = 'FAILURE', 5 | DORMANT = 'DORMANT', 6 | RETRIAL = 'RETRIAL' 7 | } 8 | -------------------------------------------------------------------------------- /web-server/src/constants/urls.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_GH_URL = 'https://api.github.com'; 2 | -------------------------------------------------------------------------------- /web-server/src/content/Cockpit/codeMetrics/shared.tsx: -------------------------------------------------------------------------------- 1 | import { DateValueTuple } from '@/types/resources'; 2 | 3 | export const getTrendsDataFromArray = (trendsArr: DateValueTuple[]) => { 4 | return trendsArr?.map((t) => t[1]).flat() || []; 5 | }; 6 | -------------------------------------------------------------------------------- /web-server/src/content/Dashboards/githubIntegration.tsx: -------------------------------------------------------------------------------- 1 | import faker from '@faker-js/faker'; 2 | import { GitHub } from '@mui/icons-material'; 3 | 4 | import GitlabIcon from '@/mocks/icons/gitlab.svg'; 5 | 6 | export const githubIntegrationsDisplay = { 7 | id: faker.datatype.uuid(), 8 | type: 'github', 9 | name: 'Github', 10 | description: 'Code insights & blockers', 11 | color: '#fff', 12 | bg: `linear-gradient(135deg, hsla(160, 10%, 61%, 0.6) 0%, hsla(247, 0%, 21%, 0.6) 100%)`, 13 | icon: 14 | }; 15 | 16 | export const gitLabIntegrationDisplay = { 17 | id: '39936e43-178a-4272-bef3-948d770bc98f', 18 | type: 'gitlab', 19 | name: 'Gitlab', 20 | description: 'Code insights & blockers', 21 | color: '#554488', 22 | bg: 'linear-gradient(-45deg, hsla(17, 95%, 50%, 0.6) 0%, hsla(42, 94%, 67%, 0.6) 100%)', 23 | icon: 24 | } as IntegrationItem; 25 | 26 | export type IntegrationItem = typeof githubIntegrationsDisplay; 27 | -------------------------------------------------------------------------------- /web-server/src/content/Dashboards/useIntegrationHandlers.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import { Integration } from '@/constants/integrations'; 4 | import { ConfigureGitlabModalBody } from '@/content/Dashboards/ConfigureGitlabModalBody'; 5 | import { useModal } from '@/contexts/ModalContext'; 6 | import { useAuth } from '@/hooks/useAuth'; 7 | import { unlinkProvider } from '@/utils/auth'; 8 | 9 | import { ConfigureGithubModalBody } from './ConfigureGithubModalBody'; 10 | 11 | export const useIntegrationHandlers = () => { 12 | const { orgId } = useAuth(); 13 | 14 | const { addModal, closeAllModals } = useModal(); 15 | 16 | return useMemo(() => { 17 | const handlers = { 18 | link: { 19 | github: () => 20 | addModal({ 21 | title: 'Configure Github', 22 | body: , 23 | showCloseIcon: true 24 | }), 25 | gitlab: () => 26 | addModal({ 27 | title: 'Configure Gitlab', 28 | body: , 29 | showCloseIcon: true 30 | }) 31 | }, 32 | unlink: { 33 | github: () => unlinkProvider(orgId, Integration.GITHUB), 34 | gitlab: () => unlinkProvider(orgId, Integration.GITLAB) 35 | } 36 | }; 37 | 38 | return handlers; 39 | }, [addModal, closeAllModals, orgId]); 40 | }; 41 | -------------------------------------------------------------------------------- /web-server/src/content/DoraMetrics/DoraCards/NoIncidentsLabel.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | 3 | import { Line } from '@/components/Text'; 4 | 5 | export const NoIncidentsLabel: FC<{ deploymentsCount?: number }> = ({ 6 | deploymentsCount 7 | }) => (deploymentsCount ? No incidents : No CFR data); 8 | -------------------------------------------------------------------------------- /web-server/src/content/DoraMetrics/DoraCards/sharedComponents.tsx: -------------------------------------------------------------------------------- 1 | import { Paper, useTheme } from '@mui/material'; 2 | import Img from 'next/image'; 3 | 4 | import { FlexBox, FlexBoxProps } from '@/components/FlexBox'; 5 | 6 | export const CardRoot = (props: FlexBoxProps) => ( 7 | 24 | ); 25 | 26 | export const NoDataImg = () => { 27 | const theme = useTheme(); 28 | return ( 29 | no-data 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /web-server/src/content/DoraMetrics/MetricsClassificationsThreshold.ts: -------------------------------------------------------------------------------- 1 | import { 2 | secondsInDay, 3 | secondsInHour, 4 | secondsInMonth, 5 | secondsInWeek 6 | } from 'date-fns/constants'; 7 | 8 | export const changeTimeThresholds = { 9 | elite: secondsInDay, 10 | high: secondsInWeek, 11 | medium: secondsInMonth 12 | }; 13 | 14 | export const deploymentFrequencyThresholds = { 15 | elite: 7, 16 | high: 1, 17 | medium: 1 / 30 18 | }; 19 | 20 | export const updatedDeploymentFrequencyThresholds = (metric: { 21 | count: number; 22 | interval: 'day' | 'week' | 'month'; 23 | }): 'elite' | 'high' | 'medium' | 'low' => { 24 | switch (metric.interval) { 25 | case 'day': 26 | if (metric.count >= 1) return 'elite'; 27 | break; 28 | case 'week': 29 | if (metric.count >= 1) return 'high'; 30 | break; 31 | case 'month': 32 | if (metric.count === 1) return 'medium'; 33 | else if (metric.count > 1) return 'high'; 34 | break; 35 | default: 36 | return 'low'; 37 | } 38 | return 'low'; 39 | }; 40 | 41 | export const changeFailureRateThresholds = { 42 | elite: 5, 43 | high: 10, 44 | medium: 15 45 | }; 46 | 47 | export const meanTimeToRestoreThresholds = { 48 | elite: secondsInHour, 49 | high: secondsInDay, 50 | medium: secondsInWeek 51 | }; 52 | -------------------------------------------------------------------------------- /web-server/src/content/DoraMetrics/getDoraLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { GoLinkExternal } from 'react-icons/go'; 3 | 4 | import { Line } from '@/components/Text'; 5 | import { OPEN_IN_NEW_TAB_PROPS } from '@/utils/url'; 6 | 7 | export const getDoraLink = (text: string) => ( 8 | 13 | 27 | {text} 28 | 29 | 30 | ); 31 | -------------------------------------------------------------------------------- /web-server/src/content/PullRequests/DeploymentFrequencyGraph.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { GoLinkExternal } from 'react-icons/go'; 3 | 4 | import { Line } from '@/components/Text'; 5 | import { OPEN_IN_NEW_TAB_PROPS } from '@/utils/url'; 6 | 7 | export const getDoraLink = (text: string) => ( 8 | 13 | 27 | {text} 28 | 29 | 30 | ); 31 | -------------------------------------------------------------------------------- /web-server/src/contexts/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": ["add-react-displayname"] 4 | } 5 | -------------------------------------------------------------------------------- /web-server/src/contexts/SidebarContext.tsx: -------------------------------------------------------------------------------- 1 | import { useState, ReactNode, createContext } from 'react'; 2 | type SidebarContext = { 3 | sidebarToggle: any; 4 | toggleSidebar: () => void; 5 | closeSidebar: () => void; 6 | }; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-redeclare 9 | export const SidebarContext = createContext( 10 | {} as SidebarContext 11 | ); 12 | 13 | type Props = { 14 | children: ReactNode; 15 | }; 16 | 17 | export function SidebarProvider({ children }: Props) { 18 | const [sidebarToggle, setSidebarToggle] = useState(false); 19 | const toggleSidebar = () => { 20 | setSidebarToggle(!sidebarToggle); 21 | }; 22 | 23 | const closeSidebar = () => { 24 | setSidebarToggle(false); 25 | }; 26 | 27 | return ( 28 | 31 | {children} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /web-server/src/createEmotionCache.ts: -------------------------------------------------------------------------------- 1 | import createCache from '@emotion/cache'; 2 | // import stylisRTLPlugin from 'stylis-plugin-rtl'; 3 | 4 | export default function createEmotionCache() { 5 | return createCache({ 6 | key: 'css' 7 | // // @ts-ignore 8 | // stylisPlugins: [stylisRTLPlugin] 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /web-server/src/hooks/useActiveRouteEvent.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useMemo } from 'react'; 3 | 4 | import { TrackEvents } from '@/constants/events'; 5 | import { ROUTES } from '@/constants/routes'; 6 | 7 | type AllowedEventTypes = 'APP_TEAM_CHANGE_SINGLE' | 'APP_DATE_RANGE_CHANGED'; 8 | 9 | // Please sync event names in events.ts too while updating below map 10 | const FEATURE_EVENT_PREFIX_MAP = { 11 | [ROUTES.DORA_METRICS.PATH]: 'DORA_METRICS' 12 | }; 13 | 14 | export const useActiveRouteEvent = ( 15 | appEventType: AllowedEventTypes 16 | ): keyof typeof TrackEvents => { 17 | const router = useRouter(); 18 | const activePath = router.pathname; 19 | return useMemo( 20 | () => 21 | (FEATURE_EVENT_PREFIX_MAP[activePath] 22 | ? FEATURE_EVENT_PREFIX_MAP[activePath] + appEventType.split('APP')[1] 23 | : appEventType) as keyof typeof TrackEvents, 24 | [activePath, appEventType] 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /web-server/src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { AuthContext } from 'src/contexts/ThirdPartyAuthContext'; 3 | 4 | export const useAuth = () => useContext(AuthContext); 5 | -------------------------------------------------------------------------------- /web-server/src/hooks/useCountUp.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const STEPS = 7; // number of steps to reach the target value 4 | const INTERVAL = 75; // in ms 5 | 6 | export const useCountUp = ( 7 | targetValue: number, 8 | decimalPlaces: number = 0 9 | ): number => { 10 | const [count, setCount] = useState(0); 11 | 12 | useEffect(() => { 13 | let currentStep = 0; 14 | const stepValue = targetValue / STEPS; 15 | 16 | const timer = setInterval(() => { 17 | currentStep++; 18 | 19 | if (currentStep >= STEPS) { 20 | setCount(parseFloat(targetValue.toFixed(decimalPlaces))); 21 | clearInterval(timer); 22 | } else { 23 | const newValue = stepValue * currentStep; 24 | setCount(parseFloat(newValue.toFixed(decimalPlaces))); 25 | } 26 | }, INTERVAL); 27 | 28 | return () => clearInterval(timer); 29 | }, [targetValue, decimalPlaces]); 30 | 31 | return count; 32 | }; 33 | -------------------------------------------------------------------------------- /web-server/src/hooks/useDoraMetricsGraph/utils.ts: -------------------------------------------------------------------------------- 1 | import { secondsInDay, secondsInHour } from 'date-fns/constants'; 2 | 3 | import { indexify } from '@/utils/datatype'; 4 | 5 | export const calculateMaxScale = ( 6 | graphData: { id: string; value: number }[] 7 | ) => { 8 | const maxVal = Math.max(...graphData.map((s) => s.value)); 9 | return Math.ceil(maxVal / secondsInDay) * secondsInDay; 10 | }; 11 | 12 | export const calculateTicks = (maxScale: number) => { 13 | const days = Math.round(maxScale / secondsInDay); 14 | const hours = Math.round(maxScale / secondsInHour); 15 | const inDays = days > 2; 16 | const inWeeks = days > 7; 17 | 18 | if (inWeeks) { 19 | const WEEK_FACTOR = calculateWeekFactor(days); 20 | const ticks = Array.from( 21 | { length: Math.round(days / WEEK_FACTOR) + 1 }, 22 | indexify 23 | ); 24 | return ticks.map((tick) => tick * secondsInDay * WEEK_FACTOR); 25 | } else if (inDays) { 26 | const ticks = Array.from({ length: days + 1 }, indexify); 27 | return ticks.map((tick) => tick * secondsInDay); 28 | } 29 | 30 | // Sub 2 days 31 | const HOUR_FACTOR = hours >= 24 ? 6 : 3; 32 | const ticks = Array.from( 33 | { length: Math.round(hours / HOUR_FACTOR) + 1 }, 34 | indexify 35 | ); 36 | return ticks.map((tick) => tick * secondsInHour * HOUR_FACTOR); 37 | }; 38 | 39 | export const calculateWeekFactor = (days: number) => { 40 | if (days < 12) return 2; 41 | if (days >= 12 && days < 21) return 3; 42 | return 7; 43 | }; 44 | -------------------------------------------------------------------------------- /web-server/src/hooks/usePageRefreshCallback.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | import { ROUTES } from '@/constants/routes'; 4 | import { useAuth } from '@/hooks/useAuth'; 5 | import { 6 | useBranchesForPrFilters, 7 | useSingleTeamConfig 8 | } from '@/hooks/useStateTeamConfig'; 9 | import { fetchTeamDoraMetrics } from '@/slices/dora_metrics'; 10 | import { useDispatch } from '@/store'; 11 | 12 | export const usePageRefreshCallback = () => { 13 | const router = useRouter(); 14 | const dispatch = useDispatch(); 15 | const { orgId } = useAuth(); 16 | const { dates, singleTeamId } = useSingleTeamConfig(); 17 | const branchPayloadForPrFilters = useBranchesForPrFilters(); 18 | 19 | switch (router.pathname) { 20 | case ROUTES.DORA_METRICS.PATH: 21 | return () => 22 | dispatch( 23 | fetchTeamDoraMetrics({ 24 | orgId, 25 | teamId: singleTeamId, 26 | fromDate: dates.start, 27 | toDate: dates.end, 28 | ...branchPayloadForPrFilters 29 | }) 30 | ); 31 | default: 32 | return () => {}; 33 | } 34 | // TODO: Pending routes to implement 35 | // ROUTES.PROJECT_MANAGEMENT.PATH 36 | // ROUTES.COLLABORATE.METRICS.PATH 37 | // ROUTES.COLLABORATE.METRICS.USER.PATH 38 | // ROUTES.COLLABORATE.METRICS.CODEBASE.PATH 39 | }; 40 | -------------------------------------------------------------------------------- /web-server/src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, MutableRefObject } from 'react'; 2 | 3 | export const usePrevious = (value: T): T => { 4 | const ref: MutableRefObject = useRef(); 5 | useEffect(() => { 6 | ref.current = value; 7 | }, [value]); 8 | return ref.current; 9 | }; 10 | -------------------------------------------------------------------------------- /web-server/src/hooks/useRefMounted.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | 3 | export const useRefMounted = () => { 4 | const isRef = useRef(false); 5 | 6 | useEffect(() => { 7 | isRef.current = true; 8 | 9 | return () => { 10 | isRef.current = false; 11 | }; 12 | }, []); 13 | 14 | return useCallback(() => isRef.current, []); 15 | }; 16 | -------------------------------------------------------------------------------- /web-server/src/hooks/useResizeEventTracking.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { track } from '@/constants/events'; 4 | 5 | export const useResizeEventTracking = () => { 6 | const timerRef = useRef>(null); 7 | 8 | useEffect(() => { 9 | if (typeof window === 'undefined') return; 10 | 11 | const listener = () => { 12 | clearTimeout(timerRef.current); 13 | timerRef.current = setTimeout(() => track('WINDOW_RESIZE'), 1000); 14 | }; 15 | 16 | window.addEventListener('resize', listener); 17 | 18 | return () => { 19 | window.removeEventListener('resize', listener); 20 | }; 21 | }, []); 22 | }; 23 | -------------------------------------------------------------------------------- /web-server/src/hooks/useScrollTop.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | 4 | const useScrollTop = (): null => { 5 | const location = useRouter(); 6 | 7 | useEffect(() => { 8 | window.scrollTo(0, 0); 9 | }, [location.pathname]); 10 | 11 | return null; 12 | }; 13 | 14 | export default useScrollTop; 15 | -------------------------------------------------------------------------------- /web-server/src/hooks/useSystemLogs.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import { ServiceNames } from '@/constants/service'; 4 | import { useSelector } from '@/store'; 5 | export const useSystemLogs = ({ 6 | serviceName 7 | }: { 8 | serviceName?: ServiceNames; 9 | }) => { 10 | const services = useSelector((state) => state.service.services); 11 | const loading = useSelector((state) => state.service.loading); 12 | const logs = useMemo( 13 | () => services[serviceName]?.logs || [], 14 | [serviceName, services] 15 | ); 16 | 17 | return { 18 | services, 19 | loading, 20 | logs 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /web-server/src/layouts/ExtendedSidebarLayout/Sidebar/SidebarMenu/MenuWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Box, styled } from '@mui/material'; 2 | 3 | export const MenuWrapper = styled(Box)( 4 | ({ theme }) => ` 5 | .MuiList-root { 6 | padding: ${theme.spacing(1)}; 7 | 8 | & > .MuiList-root { 9 | padding: 0 ${theme.spacing(0)} ${theme.spacing(1)}; 10 | } 11 | } 12 | 13 | .MuiListSubheader-root { 14 | text-transform: uppercase; 15 | font-weight: bold; 16 | font-size: ${theme.typography.pxToRem(12)}; 17 | color: ${theme.colors.alpha.trueWhite[50]}; 18 | padding: ${theme.spacing(0, 2.5)}; 19 | line-height: 1.4; 20 | } 21 | ` 22 | ); 23 | -------------------------------------------------------------------------------- /web-server/src/layouts/ExtendedSidebarLayout/Sidebar/SidebarTopSection/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material'; 2 | 3 | import { Logo } from '@/components/Logo/Logo'; 4 | 5 | function SidebarTopSection() { 6 | return ( 7 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export default SidebarTopSection; 23 | -------------------------------------------------------------------------------- /web-server/src/mocks/icons/circleci-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web-server/src/mocks/icons/jira-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web-server/src/mocks/icons/zenduty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/middlewarehq/middleware/e0a05821509101745fab8eb361e69cad84ab934a/web-server/src/mocks/icons/zenduty.png -------------------------------------------------------------------------------- /web-server/src/mocks/repos.ts: -------------------------------------------------------------------------------- 1 | export const teamReposMock = [ 2 | { 3 | org_id: '23d9e173-e98d-4ffd-b025-b5e7dbf0962f', 4 | name: 'web-manager-dash', 5 | provider: 'github', 6 | created_at: '2022-04-15T16:26:40.855853+00:00', 7 | updated_at: '2022-05-02T16:06:39.386+00:00', 8 | id: '328d4e5d-ae5d-45f9-9818-66f56110a3a9', 9 | org_name: 'monoclehq', 10 | is_active: true 11 | }, 12 | { 13 | org_id: '23d9e173-e98d-4ffd-b025-b5e7dbf0962f', 14 | name: 'monorepo', 15 | provider: 'github', 16 | created_at: '2022-04-15T16:26:40.855853+00:00', 17 | updated_at: '2022-05-02T16:06:39.386+00:00', 18 | id: '5b79d8e1-7133-48dc-876d-0670495800c2', 19 | org_name: 'monoclehq', 20 | is_active: true 21 | } 22 | ]; 23 | 24 | export const incidentSourceMock = { 25 | created_at: '2024-04-05T07:37:06.720174+00:00', 26 | updated_at: '2024-04-05T07:37:06.720231+00:00', 27 | org_id: 'd9b3d829-9b51-457f-85ab-107d20119524', 28 | setting: { 29 | incident_sources: ['INCIDENT_SERVICE'] 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /web-server/src/slices/actions.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | interface State { 4 | sortOrder: 'asc' | 'desc'; 5 | showActionsWithoutTeams: boolean; 6 | } 7 | 8 | const initialState: State = { 9 | sortOrder: 'asc', 10 | showActionsWithoutTeams: false 11 | }; 12 | 13 | export const actionsSlice = createSlice({ 14 | name: 'actions', 15 | initialState, 16 | reducers: { 17 | toggleOrder(state: State): void { 18 | state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'; 19 | }, 20 | toggleActionsWithoutTeams(state: State): void { 21 | state.showActionsWithoutTeams = !state.showActionsWithoutTeams; 22 | } 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /web-server/src/store/rootReducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from '@reduxjs/toolkit'; 2 | 3 | import { actionsSlice } from '@/slices/actions'; 4 | import { appSlice } from '@/slices/app'; 5 | import { authSlice } from '@/slices/auth'; 6 | import { doraMetricsSlice } from '@/slices/dora_metrics'; 7 | import { loadLinkSlice } from '@/slices/loadLink'; 8 | import { orgSlice } from '@/slices/org'; 9 | import { serviceSlice } from '@/slices/service'; 10 | import { teamSlice } from '@/slices/team'; 11 | 12 | export const rootReducer = combineReducers({ 13 | app: appSlice.reducer, 14 | auth: authSlice.reducer, 15 | actions: actionsSlice.reducer, 16 | team: teamSlice.reducer, 17 | org: orgSlice.reducer, 18 | doraMetrics: doraMetricsSlice.reducer, 19 | loadLink: loadLinkSlice.reducer, 20 | service: serviceSlice.reducer 21 | }); 22 | -------------------------------------------------------------------------------- /web-server/src/theme/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from '@mui/material'; 2 | import { StylesProvider } from '@mui/styles'; 3 | import { FC, useState, createContext, useEffect } from 'react'; 4 | 5 | import { themeCreator } from './base'; 6 | 7 | export const ThemeContext = createContext((_themeName: string): void => {}); 8 | 9 | const ThemeProviderWrapper: FC = (props) => { 10 | const [themeName, _setThemeName] = useState('NebulaFighterTheme'); 11 | 12 | useEffect(() => { 13 | const curThemeName = 14 | window.localStorage.getItem('appTheme') || 'NebulaFighterTheme'; 15 | _setThemeName(curThemeName); 16 | }, []); 17 | 18 | const theme = themeCreator(themeName); 19 | const setThemeName = (themeName: string): void => { 20 | window.localStorage.setItem('appTheme', themeName); 21 | _setThemeName(themeName); 22 | }; 23 | 24 | return ( 25 | 26 | 27 | {props.children} 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default ThemeProviderWrapper; 34 | -------------------------------------------------------------------------------- /web-server/src/types/api/teams.ts: -------------------------------------------------------------------------------- 1 | export interface Team { 2 | id: string; 3 | org_id: string; 4 | name: string; 5 | member_ids: string[]; 6 | manager_id?: string; 7 | created_at: Date; 8 | updated_at: Date; 9 | is_deleted: boolean; 10 | member_filter_enabled?: boolean; 11 | } 12 | 13 | export type BaseTeam = { 14 | id: string; 15 | name: string; 16 | member_ids: string[]; 17 | org_id?: string; 18 | }; 19 | -------------------------------------------------------------------------------- /web-server/src/types/github.ts: -------------------------------------------------------------------------------- 1 | import { GhType, Github } from './octokit'; 2 | 3 | export type GhRepo = GhType[number]; 4 | export type Repo = GhRepo; 5 | 6 | export type LoadedOrg = { 7 | name?: string; 8 | avatar_url: string; 9 | login: string; 10 | repos: string[]; 11 | web_url: string; 12 | }; 13 | -------------------------------------------------------------------------------- /web-server/src/types/octokit.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest'; 2 | 3 | export const Github = new Octokit(); 4 | export type { GetResponseDataTypeFromEndpointMethod as GhType } from '@octokit/types'; 5 | -------------------------------------------------------------------------------- /web-server/src/types/redux.ts: -------------------------------------------------------------------------------- 1 | import { FetchState } from '@/constants/ui-states'; 2 | 3 | export type StateRequests = Partial< 4 | Record, FetchState> 5 | >; 6 | 7 | export type StateErrors = Partial, any>>; 8 | 9 | export type StateFetchConfig = S & { 10 | requests?: StateRequests; 11 | errors?: StateErrors; 12 | }; 13 | -------------------------------------------------------------------------------- /web-server/src/types/request.ts: -------------------------------------------------------------------------------- 1 | import { Features } from '@/constants/feature'; 2 | 3 | import type { NextApiRequest, NextApiResponse } from 'next/types'; 4 | 5 | export type HttpMethods = 6 | | 'GET' 7 | | 'POST' 8 | | 'PUT' 9 | | 'PATCH' 10 | | 'DELETE' 11 | | 'OPTIONS' 12 | | 'HEAD'; 13 | 14 | export type ApiRequest = Omit< 15 | NextApiRequest, 16 | 'body' | 'query' | 'method' 17 | > & { 18 | /** @deprecated Use `req.payload` instead */ 19 | body: T; 20 | /** @deprecated Use `req.payload` instead */ 21 | query: T; 22 | payload: T; 23 | meta?: { 24 | features: Partial; 25 | }; 26 | method: HttpMethods; 27 | }; 28 | 29 | export type ApiResponse = NextApiResponse; 30 | -------------------------------------------------------------------------------- /web-server/src/utils/__tests__/domainCheck.test.ts: -------------------------------------------------------------------------------- 1 | import { checkDomainWithRegex } from '../domainCheck'; 2 | 3 | describe('checkDomainWithRegex', () => { 4 | const validDomains = [ 5 | 'http://example.com', 6 | 'https://example.com', 7 | 'https://sub.example.co.uk', 8 | 'http://example.io:8080', 9 | 'https://example.io:8080', 10 | 'https://example.com/', 11 | 'https://123domain.net', 12 | 'http://my-domain.org' 13 | ]; 14 | 15 | test.each(validDomains)('returns true for %s', (domain) => { 16 | expect(checkDomainWithRegex(domain)).toBe(true); 17 | }); 18 | 19 | const invalidDomains = [ 20 | 'example.com', 21 | 'ftp://example.com', 22 | 'http:/example.com', 23 | 'https//example.com', 24 | 'https://-example.com', 25 | 'https://example-.com', 26 | 'https://example', 27 | 'https://.com', 28 | 'https://example:toolongtsadasds', 29 | 'https://example.com:999999', 30 | 'https://example .com', 31 | 'https://example.com/ path', 32 | '', 33 | 'https://', 34 | 'https:///' 35 | ]; 36 | 37 | test.each(invalidDomains)('returns false for %s', (domain) => { 38 | expect(checkDomainWithRegex(domain)).toBe(false); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /web-server/src/utils/adapt_deployments.ts: -------------------------------------------------------------------------------- 1 | import { descend, mapObjIndexed, prop, sort } from 'ramda'; 2 | 3 | import { 4 | Deployment, 5 | UpdatedDeployment, 6 | UpdatedTeamDeploymentsApiResponse 7 | } from '@/types/resources'; 8 | 9 | export function adaptDeploymentsMap(curr: UpdatedDeployment): Deployment { 10 | return { 11 | id: curr.id, 12 | status: curr.status, 13 | head_branch: curr.head_branch, 14 | event_actor: { 15 | username: curr.event_actor.username, 16 | linked_user: curr.event_actor.linked_user 17 | }, 18 | created_at: '', 19 | updated_at: '', 20 | conducted_at: curr.conducted_at, 21 | pr_count: curr.pr_count, 22 | html_url: curr.html_url, 23 | repo_workflow_id: curr.meta.repo_workflow_id, 24 | run_duration: curr.duration 25 | }; 26 | } 27 | 28 | export const adaptedDeploymentsMap = ( 29 | deploymentsMap: UpdatedTeamDeploymentsApiResponse['deployments_map'] 30 | ) => { 31 | const x = Object.entries(deploymentsMap).map(([key, value]) => { 32 | return [key, value.map(adaptDeploymentsMap)]; 33 | }); 34 | const adaptedDeployments: Record = 35 | Object.fromEntries(x); 36 | 37 | return mapObjIndexed(sort(descend(prop('conducted_at'))), adaptedDeployments); 38 | }; 39 | -------------------------------------------------------------------------------- /web-server/src/utils/auth-supplementary.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { splitEvery } from 'ramda'; 3 | 4 | import { privateDecrypt, publicEncrypt } from 'crypto'; 5 | 6 | const CHUNK_SIZE = 127; 7 | export const enc = (data?: string) => { 8 | const key = Buffer.from(process.env.SECRET_PUBLIC_KEY, 'base64'); 9 | try { 10 | return data 11 | ? splitEvery(CHUNK_SIZE, data).map((chunk) => 12 | publicEncrypt(key, Buffer.from(chunk)).toString('base64') 13 | ) 14 | : null; 15 | } catch (e) { 16 | return null; 17 | } 18 | }; 19 | 20 | export const dec = (chunks: string[]) => { 21 | const key = Buffer.from(process.env.SECRET_PRIVATE_KEY, 'base64'); 22 | return chunks 23 | .map((chunk) => privateDecrypt(key, Buffer.from(chunk, 'base64'))) 24 | .join(''); 25 | }; 26 | 27 | export const INTEGRATION_CONFLICT_COLUMNS = ['org_id', 'name']; 28 | 29 | export const validateGithubToken = async (token: string) => { 30 | try { 31 | const response = await axios.get('https://api.github.com/user/repos', { 32 | headers: { 33 | // @ts-ignore 34 | Authorization: `token ${dec(token)}` 35 | } 36 | }); 37 | return response.status === 200; 38 | } catch (error: any) { 39 | console.error('Token validation error:', error.response); 40 | return false; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /web-server/src/utils/code.ts: -------------------------------------------------------------------------------- 1 | import { PR } from '@/types/resources'; 2 | 3 | export const getKeyForPr = (pr: PR) => `${pr.number}/${pr.repo_name}`; 4 | 5 | export const getCycleTimeForPr = (pr: PR) => 6 | pr.first_response_time + pr.rework_time + pr.merge_time || 0; 7 | -------------------------------------------------------------------------------- /web-server/src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export const debounce = any>( 2 | fn: T, 3 | duration: number 4 | ): any => { 5 | let timerId: NodeJS.Timeout | null = null; 6 | 7 | return (...args: any[]) => { 8 | timerId && clearTimeout(timerId); 9 | timerId = setTimeout(() => fn(...args), duration); 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /web-server/src/utils/domainCheck.ts: -------------------------------------------------------------------------------- 1 | export const checkDomainWithRegex = (domain: string) => { 2 | const regex = 3 | /^(https?:\/\/)[A-Za-z0-9]+([-.][A-Za-z0-9]+)*\.[A-Za-z]{2,}(:[0-9]{1,5})?(\/\S*)?$/; 4 | return regex.test(domain); 5 | }; 6 | -------------------------------------------------------------------------------- /web-server/src/utils/enum.ts: -------------------------------------------------------------------------------- 1 | export const objectEnum = (EnumArg: EnumT) => { 2 | type EnumKeys = keyof typeof EnumArg; 3 | 4 | return Object.keys(EnumArg).reduce( 5 | // @ts-ignore 6 | (obj, key) => ({ ...obj, [key]: key }), 7 | {} as { [Property in EnumKeys]: Property } 8 | ); 9 | }; 10 | 11 | export const objectEnumFromFn = (enumFn: () => EnumT) => 12 | objectEnum(enumFn()); 13 | -------------------------------------------------------------------------------- /web-server/src/utils/fn.ts: -------------------------------------------------------------------------------- 1 | export const depFn = (fn: T, ...args: Parameters) => 2 | fn?.(...args); 3 | 4 | export const noOp = () => {}; 5 | -------------------------------------------------------------------------------- /web-server/src/utils/loading-messages.ts: -------------------------------------------------------------------------------- 1 | const LOADING_MESSAGES = [ 2 | 'Hoping your numbers look great? Me too! 🤞', 3 | "Hang on. I'll grab lunch and get fresh data for you... any minute now...", 4 | 'And the oscar goes to... YOU! For hanging on while we get these stats for you', 5 | 'Loading... like watching paint dry, but with data 🎨', 6 | 'Hang on, one of our employees ran away with your data 🏃‍♂️ (jk 👀)', 7 | 'Grabbing lunch! Erm... data. I mean data.', 8 | 'How badly do you actually want that cycle time/sprint spillover?', 9 | 'Your team is doing juuuust fiiiine... trust me.', 10 | 'How many times did you ask your team for updates today?', 11 | 'Samad is bringing your insights on a skateboard 🛹', 12 | 'Shivam is literally typing out the API response right now. 1 sec ⏳', 13 | 'Eshaan stayed up all night to do the math for this 🌙', 14 | "Look out of your window! It's Amogh with your data! 📨", 15 | "Adnan doesn't think your stats are half bad. He thinks they are half good! 👌" 16 | ]; 17 | 18 | function getRandomSeededInt(max: number): number { 19 | const currentTime = new Date().getTime(); 20 | const timeBasedSeed = Math.floor(currentTime / 5000); // 5 seconds window 21 | const seed = timeBasedSeed % (max + 1); 22 | return seed; 23 | } 24 | 25 | export const getRandomLoadMsg = () => 26 | LOADING_MESSAGES[getRandomSeededInt(LOADING_MESSAGES.length - 1)]; 27 | -------------------------------------------------------------------------------- /web-server/src/utils/mock.ts: -------------------------------------------------------------------------------- 1 | import faker from '@faker-js/faker'; 2 | 3 | export const staticArray = ( 4 | length: number, 5 | proc = (num: number) => num 6 | ) => 7 | Array(Math.max(Number.isFinite(length) ? length : 0, 0)) 8 | .fill(0) 9 | .map((_, i) => proc(i)) as unknown as T[]; 10 | 11 | export const arraySize = (max: number = 5, min: number = 0) => 12 | staticArray(randInt(max, min)); 13 | 14 | export const flexibleArray = arraySize; 15 | 16 | export const randomDuration = () => 17 | faker.datatype.number({ min: 0, max: 10, precision: 0.1 }); 18 | 19 | export const randInt = (n1: number, n2: number = 0) => 20 | faker.datatype.number({ min: Math.min(n1, n2), max: Math.max(n1, n2) }); 21 | 22 | export const arrayDivByN = (arr: Array, parts: number = 1) => { 23 | const chunkSize = Math.floor(arr.length / parts); 24 | const rem = arr.length % parts; 25 | let start = 0; 26 | let end = chunkSize + rem; 27 | const res = [arr.slice(start, end)]; 28 | for (let i = 1; i < parts; i++) { 29 | start = end; 30 | end = end + chunkSize; 31 | res.push(arr.slice(start, end)); 32 | } 33 | return res; 34 | }; 35 | -------------------------------------------------------------------------------- /web-server/src/utils/objectArray.ts: -------------------------------------------------------------------------------- 1 | import { path } from 'ramda'; 2 | 3 | export const groupBy = []>( 4 | arr: T, 5 | key: keyof T[number] = 'id', 6 | keyPath?: string 7 | ): Record => 8 | arr.reduce((acc, cur) => { 9 | acc[path(((keyPath || key) as string).split('.'), cur)] = cur; 10 | return acc; 11 | }, {}); 12 | 13 | export const groupObj = groupBy; 14 | 15 | export default groupBy; 16 | -------------------------------------------------------------------------------- /web-server/src/utils/randomId.ts: -------------------------------------------------------------------------------- 1 | export const randomId = (): string => { 2 | const arr = new Uint8Array(12); 3 | window.crypto.getRandomValues(arr); 4 | return Array.from(arr, (v) => v.toString(16).padStart(2, '0')).join(''); 5 | }; 6 | -------------------------------------------------------------------------------- /web-server/src/utils/redux.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AsyncThunk, 3 | ActionReducerMapBuilder, 4 | Draft, 5 | PayloadAction 6 | } from '@reduxjs/toolkit'; 7 | 8 | import { FetchState } from '@/constants/ui-states'; 9 | import { StateFetchConfig } from '@/types/redux'; 10 | 11 | export const addFetchCasesToReducer = < 12 | S extends StateFetchConfig<{}>, 13 | T extends AsyncThunk 14 | >( 15 | builder: ActionReducerMapBuilder, 16 | thunk: T, 17 | key: keyof S['requests'], 18 | onSuccess?: ( 19 | state: Draft, 20 | action: PayloadAction['payload']> 21 | ) => any, 22 | onFailure?: (state: Draft, action: PayloadAction) => any 23 | ) => { 24 | builder.addCase(thunk.fulfilled, (state, action) => { 25 | if (!state.requests) state.requests = {}; 26 | 27 | onSuccess?.(state, action); 28 | // @ts-ignore 29 | state.requests[key] = FetchState.SUCCESS; 30 | }); 31 | 32 | builder.addCase(thunk.pending, (state) => { 33 | if (!state.requests) state.requests = {}; 34 | // @ts-ignore 35 | state.requests[key] = FetchState.REQUEST; 36 | }); 37 | 38 | builder.addCase(thunk.rejected, (state, action) => { 39 | if (!state.requests) state.requests = {}; 40 | if (!state.errors) state.errors = {}; 41 | // @ts-ignore 42 | state.requests[key as string] = FetchState.FAILURE; 43 | // @ts-ignore 44 | state.errors[key as string] = action.error as string; 45 | onFailure?.(state, action); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /web-server/src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import Cookies, { CookieAttributes } from 'js-cookie'; 2 | 3 | const attrs: CookieAttributes = { 4 | expires: 30, // days 5 | path: '/', 6 | secure: true 7 | }; 8 | 9 | export const storage = { 10 | get: (key: string) => { 11 | if (typeof window === 'undefined') return; 12 | const value = Cookies.get(key); 13 | try { 14 | return value && JSON.parse(value); 15 | } catch (e) { 16 | return value; 17 | } 18 | }, 19 | set: (key: string, value: any) => { 20 | if (typeof window === 'undefined') return; 21 | if (typeof value === 'string') { 22 | Cookies.set(key, value, attrs); 23 | } else { 24 | Cookies.set(key, JSON.stringify(value), attrs); 25 | } 26 | }, 27 | remove: (key: string) => { 28 | if (typeof window === 'undefined') return; 29 | Cookies.remove(key, attrs); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /web-server/src/utils/stringFormatting.ts: -------------------------------------------------------------------------------- 1 | import { DatumValue } from '@nivo/core'; 2 | import pluralize from 'pluralize'; 3 | export const trimWithEllipsis = ( 4 | text: string, 5 | maxTextLength: number, 6 | addInStart?: boolean 7 | ) => { 8 | const diff = text.length - maxTextLength; 9 | if (diff <= 3) return text; 10 | const textStr = addInStart 11 | ? `...${text.slice(text.length - maxTextLength)}` 12 | : `${text.slice(0, maxTextLength)}...`; 13 | return textStr; 14 | }; 15 | 16 | export const pluralizePrCount = (value: number) => 17 | `${value === 1 ? 'PR' : 'PRs'}`; 18 | 19 | export const formatAsPercent = (value: DatumValue) => 20 | value ? `${value}%` : `0%`; 21 | 22 | export const formatAsDeployment = (value: number) => 23 | value >= 1000 24 | ? `${value / 1000}k Deps` 25 | : `${value} ${pluralize('deps', value)}`; 26 | 27 | export const joinNames = (names: string[]): string => { 28 | if (names.length === 0) { 29 | return ''; 30 | } else if (names.length === 1) { 31 | return names[0]; 32 | } else { 33 | const lastNames = names.slice(-1); 34 | const otherNames = names.slice(0, -1); 35 | return `${otherNames.join(', ')} and ${lastNames[0]}`; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /web-server/src/utils/unistring.ts: -------------------------------------------------------------------------------- 1 | import FastestValidator from 'fastest-validator'; 2 | 3 | // unid = unistring ID 4 | 5 | const uuidValidator = new FastestValidator().compile({ 6 | uuid: { type: 'uuid' } 7 | }); 8 | 9 | export const isUuid = (id: ID) => uuidValidator({ uuid: id }); 10 | 11 | /** 12 | * #### Unistring ID utils 13 | * --- 14 | * UNID Examples 15 | * 16 | * Team: `TEAM_` / 17 | * User: `USER_` 18 | */ 19 | export const unid = { 20 | /** Get unistring ID for a user, from a valid UUID */ 21 | u: (id: string) => isUuid(id) && `USER_${id}`, 22 | /** Get unistring ID for a team, from a valid UUID */ 23 | t: (id: string) => isUuid(id) && `TEAM_${id}`, 24 | /** Get UUID from a unistring ID */ 25 | id: (un_id: string) => un_id.split('_')[1] 26 | }; 27 | 28 | const isValidUnid = (unid: ID, type: string) => { 29 | const [typePart, idPart] = unid.split('_')[0]; 30 | return type === typePart && isUuid(idPart); 31 | }; 32 | 33 | export const isUnid = { 34 | u: (id: string) => isValidUnid(id, 'USER'), 35 | t: (id: string) => isValidUnid(id, 'TEAM') 36 | }; 37 | -------------------------------------------------------------------------------- /web-server/src/utils/url.ts: -------------------------------------------------------------------------------- 1 | export const getUrlParam = (param: string) => { 2 | if (typeof window === 'undefined') return null; 3 | return new URLSearchParams(window.location.search).get(param); 4 | }; 5 | 6 | export const OPEN_IN_NEW_TAB_PROPS = { 7 | target: '_blank', 8 | rel: 'noopener nofollow noreferrer' 9 | }; 10 | -------------------------------------------------------------------------------- /web-server/src/utils/user.ts: -------------------------------------------------------------------------------- 1 | import { Row } from '@/constants/db'; 2 | import { brandColors } from '@/theme/schemes/theme'; 3 | import { BaseUser } from '@/types/resources'; 4 | 5 | export type UserProfile = User; 6 | 7 | export const getAvatar = (user?: User): string | undefined => { 8 | if (user?.identities?.github?.username) 9 | return getGHAvatar(user?.identities?.github?.username); 10 | else if (user?.identities?.bitbucket?.meta?.avatar_url) 11 | return user?.identities?.bitbucket?.meta?.avatar_url; 12 | }; 13 | 14 | export const getAvatarObj = (url: string) => ({ 15 | avatar_url: { href: url } 16 | }); 17 | 18 | export const getGHAvatar = (handle?: string, size: number = 128) => 19 | handle && `https://github.com/${handle}.png?size=${size}`; 20 | 21 | export const getLangIcon = (lang: string) => 22 | `https://cdn.jsdelivr.net/gh/devicons/devicon/icons/${lang}/${lang}-plain.svg`; 23 | 24 | export const getBaseUserFromRowUser = (user: Row<'Users'>): BaseUser => ({ 25 | email: user.primary_email, 26 | id: user.id, 27 | name: user.name, 28 | avatar_url: null 29 | }); 30 | 31 | export const getColorByStatus = (status: 'MERGED' | 'CLOSED' | 'OPEN') => { 32 | switch (status) { 33 | case 'OPEN': 34 | return brandColors.pr.open; 35 | case 'CLOSED': 36 | return brandColors.pr.close; 37 | case 'MERGED': 38 | default: 39 | return brandColors.pr.merge; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /web-server/src/utils/wait.ts: -------------------------------------------------------------------------------- 1 | export const wait = (milliseconds: number): Promise => 2 | new Promise((res) => setTimeout(res, milliseconds)); 3 | -------------------------------------------------------------------------------- /web-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"], 6 | "@/public/*": ["./public/*"], 7 | "@/api/*": ["./pages/api/*"] 8 | }, 9 | "allowJs": true, 10 | "allowSyntheticDefaultImports": true, 11 | "jsx": "preserve", 12 | "lib": ["dom", "es2017", "ES2021", "es2021.intl", "ES2022"], 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "noEmit": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "preserveConstEnums": true, 19 | "removeComments": false, 20 | "skipLibCheck": true, 21 | "sourceMap": true, 22 | "strict": true, 23 | "strictPropertyInitialization": false, 24 | "strictNullChecks": false, 25 | "target": "esnext", 26 | "forceConsistentCasingInFileNames": true, 27 | "esModuleInterop": true, 28 | "resolveJsonModule": true, 29 | "isolatedModules": true, 30 | "noFallthroughCasesInSwitch": true, 31 | "incremental": true, 32 | "plugins": [ 33 | { 34 | "name": "next" 35 | } 36 | ] 37 | }, 38 | "exclude": ["node_modules"], 39 | "include": [ 40 | "src", 41 | "next-env.d.ts", 42 | "**/*.ts", 43 | "**/*.tsx", 44 | ".next/types/**/*.ts" 45 | ] 46 | } 47 | --------------------------------------------------------------------------------