├── .adr-dir ├── .dockerignore ├── .editorconfig ├── .env.development ├── .env.development.local.example ├── .env.e2e ├── .env.e2e.local.example ├── .env.example ├── .env.test ├── .eslintignore ├── .eslintrc.json ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── config.yml ├── pull_request_template.md └── workflows │ ├── codeql-analysis.yml │ ├── codeql.yml │ ├── dummy-deployment.yml │ └── on-push.yml ├── .gitignore ├── .husky ├── .gitignore ├── create-env.sh ├── post-merge └── pre-commit ├── .localstack ├── dynamodb.sh └── sqs.sh ├── .nvmrc ├── .sequelizerc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.prod ├── LICENSE ├── README.md ├── SUPPORT.md ├── app.json ├── app.yml ├── bin └── start-server-micros.sh ├── db-migration.sh ├── db ├── config.json └── migrations │ ├── 20180612233338-create-installation.js │ ├── 20180614222557-create-subscription.js │ ├── 20180619181057-add-secrets.js │ ├── 20180619181227-remove-unencrypted-secrets.js │ ├── 20180621181228-add-enable-flag-for-installations.js │ ├── 20180626204822-add-client-key.js │ ├── 20180706184633-update-subscriptions-column.js │ ├── 20180914203755-analytics-views.js │ ├── 20180917195216-add-sync-columns.js │ ├── 20181024000949-add-projects.js │ ├── 20181024214811-more-analytics-views.js │ ├── 20190610222557-update-subscription.js │ ├── 20190716143013-migrate-clientKey.js │ ├── 20190807230019-add-indexes.js │ ├── 20190807234327-add-indexes-on-projects.js │ ├── 20190808001912-add-indexes-on-installations.js │ ├── 20190828145758-add-sync-warning-column-on-subscriptions.js │ ├── 20200206225834-update-views.js │ ├── 20200304150704-more-looker-updates.js │ ├── 20210705175800-remove-projects-table.js │ ├── 20211110150400-create-sync-state-table.js │ ├── 20211110173000-add-extra-subscription-columns.js │ ├── 20220112110000-remove-reposyncstate-column.js │ ├── 20220420145000-add-repository-cursor.js │ ├── 20220509002323-create-git-hub-server-app.js │ ├── 20220511143000-add-github-app-id.js │ ├── 20220524110000-update-build-deploy-sync.js │ ├── 20220609044825-move-github-app-id-to-subscriptions.js │ ├── 20220614055633-readd-github-app-id.js │ ├── 20220615035112-update-github-server-apps.js │ ├── 20220630040916-add-app-id.js │ ├── 20220630052212-add-github-client-secrets-to-github-server-apps.js │ ├── 20220705052312-add-cryptor-sharesecret-column-to-installation-table.js │ ├── 20220803110900-add-config-columns.js │ ├── 20220808010636-cleanup-gsha.js │ ├── 20220829153300-github-app-id-default-value.js │ ├── 20221117154600-add-new-client-key-columns.js │ ├── 20230202110100-add-repo-sync-state-fk.js │ ├── 20230220142300-add-subs-bkfill-date-col.js │ ├── 20230310104700-add-reposync-failedcode-col.js │ ├── 20230316051227-add-commit-from.js │ ├── 20230320085600-add-commit-from-all-entities.js │ ├── 20230503000639-github-server-app-add-api-key-columns.js │ ├── 20230711040027-add-avatar-to-subscription.js │ ├── 20230717232437-add-dependabot-alert-cols.js │ ├── 20230731063755-add-security-permission-col.js │ ├── 20230808062435-add-secret-scanning-alert-cols.js │ └── 20230823013417-add-code-scanning-alert-cols.js ├── deploy └── lambda │ └── auto-deployment.js ├── docker-compose.yml ├── docs ├── DevInfo-v0.10-ImplementationGuidance-062218.pdf ├── DevInfoAPI.json ├── DevInfoAPI_ExampleUseCases.pdf ├── FAQs.md ├── JiraDevelopmentInformationAPI-VendorImplementationGuidance.pdf ├── architecture.md ├── builds.md ├── data-model.md ├── deployments.md ├── draw.io │ ├── data-model.drawio.xml │ ├── network-traffic-diagram--manual.drawio │ └── network-traffic-diagram.drawio ├── e2e-tests.md ├── images │ ├── architecture-overview.png │ ├── associating-builds.png │ ├── associating-deployments.png │ ├── author-icons-in-jira-for-non-matching-atlassian-emails.png │ ├── builds-data-jira-dev-panel.png │ ├── code-in-jira-missing-user.png │ ├── connect-gh-org-to-jira.png │ ├── correctly-mapped-deployment-environments.png │ ├── data-model.jpg │ ├── deployments-in-jira.png │ ├── devinfo.png │ ├── edit-github-settings.png │ ├── get-started.png │ ├── github-ip-allowlist.png │ ├── install-app-atlassian.png │ ├── install-app-in-github.png │ ├── issue-board-view-missing-user.png │ ├── jira-issue.png │ ├── network-traffic-diagram--manual.png │ ├── network-traffic-diagram.png │ ├── public-profile.png │ ├── read-and-write-permissions-issues-and-prs.png │ ├── restart-backfill.png │ ├── select-backfill-date.png │ ├── simple-jira-architecture.png │ ├── unmapped-deployment-environments.png │ └── untick-private-email.png ├── ip-allowlist.md ├── jira-dev-info-0.10-swagger.yaml ├── legacy-features.md ├── sample-reverse-proxy-nginx.conf ├── security.md └── undefined-environment.png ├── etc ├── app-install │ ├── Dockerfile │ └── app-install.sh ├── cryptor-mock │ ├── Dockerfile │ └── cryptor-mock.js ├── poco │ ├── README.md │ └── bundle │ │ ├── extras-prod-test.json │ │ ├── extras-prod.json │ │ ├── extras-stg-test.json │ │ ├── extras-stg.json │ │ ├── main-test.json │ │ └── main.json ├── scripts │ ├── api-replay-failed-entities-from-csv.py │ ├── api-resync-failed-tasks.py │ ├── build-and-deploy-workflow-example.yml │ ├── generate-github-test-data.py │ ├── resync-from-csv.py │ └── sync-configured-state-from-csv.py └── spa-build │ └── spa-build.sh ├── github-for-jira.sd.yml ├── github-server └── github-server-stack.yml ├── jest.config.js ├── package.json ├── playwright.config.js ├── prestart.ts ├── project-descriptor.yml ├── spa ├── .eslintrc.cjs ├── .gitignore ├── .yarnrc ├── README.md ├── config-overrides.js ├── jest.config.json ├── package.json ├── public │ └── index.html ├── setup.ts ├── src │ ├── analytics │ │ ├── analytics-proxy-client.test.ts │ │ ├── analytics-proxy-client.ts │ │ ├── index.ts │ │ └── types.ts │ ├── api │ │ ├── apps │ │ │ └── index.ts │ │ ├── auth │ │ │ └── index.ts │ │ ├── axiosInstance.ts │ │ ├── deferral │ │ │ └── index.ts │ │ ├── github │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── orgs │ │ │ └── index.ts │ │ ├── subscriptions │ │ │ └── index.ts │ │ └── token │ │ │ └── index.ts │ ├── app.tsx │ ├── common │ │ ├── LoggedinInfo.tsx │ │ ├── Scrollbars.tsx │ │ └── Wrapper.tsx │ ├── components │ │ ├── Error │ │ │ ├── KnownErrors │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── test.tsx │ │ ├── GithubConnectedHeader │ │ │ └── index.tsx │ │ ├── Step │ │ │ ├── index.tsx │ │ │ └── test.tsx │ │ └── SyncHeader │ │ │ └── index.tsx │ ├── feature-flags.ts │ ├── global.d.ts │ ├── index.tsx │ ├── pages │ │ ├── ConfigSteps │ │ │ ├── OrgsContainer │ │ │ │ └── index.tsx │ │ │ ├── SkeletonForLoading │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── test.tsx │ │ ├── Connected │ │ │ ├── index.tsx │ │ │ └── test.tsx │ │ ├── Connections │ │ │ ├── GHCloudConnections │ │ │ │ └── index.tsx │ │ │ ├── GHEnterpriseConnections │ │ │ │ ├── GHEnterpriseAppHeader.tsx │ │ │ │ ├── GHEnterpriseApplication.tsx │ │ │ │ └── index.tsx │ │ │ ├── Modals │ │ │ │ ├── DisconnectGHEServerModal.test.tsx │ │ │ │ ├── DisconnectGHEServerModal.tsx │ │ │ │ ├── DisconnectSubscriptionModal.test.tsx │ │ │ │ ├── DisconnectSubscriptionModal.tsx │ │ │ │ ├── RestartBackfillModal.test.tsx │ │ │ │ └── RestartBackfillModal.tsx │ │ │ ├── SkeletonForLoading │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── DeferredInstallation │ │ │ ├── ConnectState │ │ │ │ ├── index.tsx │ │ │ │ └── test.tsx │ │ │ ├── ErrorState │ │ │ │ ├── index.tsx │ │ │ │ └── test.tsx │ │ │ ├── ForbiddenState │ │ │ │ ├── index.tsx │ │ │ │ └── test.tsx │ │ │ ├── index.tsx │ │ │ └── test.tsx │ │ ├── InstallationRequested │ │ │ ├── index.tsx │ │ │ └── test.tsx │ │ └── StartConnection │ │ │ ├── index.tsx │ │ │ └── test.tsx │ ├── rest-interfaces │ ├── sentry.ts │ ├── services │ │ ├── app-manager │ │ │ └── index.ts │ │ ├── deferral-manager │ │ │ └── index.ts │ │ ├── oauth-manager │ │ │ ├── index.ts │ │ │ └── test.ts │ │ └── subscription-manager │ │ │ └── index.ts │ └── utils │ │ ├── dynamicTableHelper.tsx │ │ ├── index.ts │ │ └── modifyError.tsx ├── tsconfig.json └── yarn.lock ├── src ├── app.test.ts ├── app.ts ├── backfill │ ├── backfill.types.ts │ ├── branch-processor.ts │ ├── commit-processor.ts │ ├── looper │ │ ├── backoff-retry-strategy.ts │ │ └── capped-delay-ratelimit-strategy.ts │ └── pull-request-processor.ts ├── config │ ├── cidr-validator.test.ts │ ├── cidr-validator.ts │ ├── dynamodb.ts │ ├── env.test.ts │ ├── env.ts │ ├── errors.ts │ ├── feature-flags.test.ts │ ├── feature-flags.ts │ ├── interfaces.ts │ ├── jira-test-site-check.test.ts │ ├── jira-test-site-check.ts │ ├── logger.test.ts │ ├── logger.ts │ ├── metric-helpers.test.ts │ ├── metric-helpers.ts │ ├── metric-names.ts │ ├── redis-info.test.ts │ ├── redis-info.ts │ ├── sentry.ts │ └── statsd.ts ├── github │ ├── branch.test.ts │ ├── branch.ts │ ├── client │ │ ├── app-token-holder.test.ts │ │ ├── app-token-holder.ts │ │ ├── auth-token.ts │ │ ├── github-anonymous-client.ts │ │ ├── github-app-client.ts │ │ ├── github-client-cloud.test.ts │ │ ├── github-client-constants.ts │ │ ├── github-client-errors.test.ts │ │ ├── github-client-errors.ts │ │ ├── github-client-interceptors.test.ts │ │ ├── github-client-interceptors.ts │ │ ├── github-client-server.test.ts │ │ ├── github-client.nock.test.ts │ │ ├── github-client.test.ts │ │ ├── github-client.ts │ │ ├── github-client.types.ts │ │ ├── github-installation-client.test.ts │ │ ├── github-installation-client.ts │ │ ├── github-queries.ts │ │ ├── github-user-client.ts │ │ ├── installation-id.test.ts │ │ ├── installation-id.ts │ │ ├── installation-token-cache.test.ts │ │ ├── installation-token-cache.ts │ │ ├── key-locator.test.ts │ │ ├── key-locator.ts │ │ └── token-cache.test.ts │ ├── code-scanning-alert.test.ts │ ├── code-scanning-alert.ts │ ├── dependabot-alert.test.ts │ ├── dependabot-alert.ts │ ├── deployment.test.ts │ ├── deployment.ts │ ├── installation.test.ts │ ├── installation.ts │ ├── issue-comment.test.ts │ ├── issue-comment.ts │ ├── issue.test.ts │ ├── issue.ts │ ├── pull-request.test.ts │ ├── pull-request.ts │ ├── push.test.ts │ ├── push.ts │ ├── repository.test.ts │ ├── repository.ts │ ├── secret-scanning-alert.test.ts │ ├── secret-scanning-alert.ts │ ├── workflow.test.ts │ └── workflow.ts ├── interfaces │ ├── common.ts │ ├── github.ts │ └── jira.ts ├── jira │ ├── client │ │ ├── axios.test.ts │ │ ├── axios.ts │ │ ├── jira-client-audit-log-helper.test.ts │ │ ├── jira-client-audit-log-helper.ts │ │ ├── jira-client-deployment-helper.test.ts │ │ ├── jira-client-deployment-helper.ts │ │ ├── jira-client-issue-key-helper.test.ts │ │ ├── jira-client-issue-key-helper.ts │ │ ├── jira-client.test.ts │ │ └── jira-client.ts │ ├── extract-installation-from-jira-callback.ts │ ├── util │ │ ├── id.test.ts │ │ ├── id.ts │ │ ├── jira-client-util.test.ts │ │ ├── jira-client-util.ts │ │ ├── jwt.test.ts │ │ ├── jwt.ts │ │ └── query-atlassian-connect-public-key.ts │ ├── verify-installation.test.ts │ └── verify-installation.ts ├── main.ts ├── middleware │ ├── cookiesession-middleware.test.ts │ ├── cookiesession-middleware.ts │ ├── csrf-middleware.ts │ ├── frontend-log-middleware.test.ts │ ├── frontend-log-middleware.ts │ ├── github-server-app-middleware.test.ts │ ├── github-server-app-middleware.ts │ ├── github-webhook-middleware.test.ts │ ├── github-webhook-middleware.ts │ ├── jira-admin-permission-middleware.test.ts │ ├── jira-admin-permission-middleware.ts │ ├── jira-symmetric-jwt-middleware.test.ts │ └── jira-symmetric-jwt-middleware.ts ├── models │ ├── axios-error-event-decorator.test.ts │ ├── axios-error-event-decorator.ts │ ├── encrypted-model.test.ts │ ├── encrypted-model.ts │ ├── github-server-app.test.ts │ ├── github-server-app.ts │ ├── installation.test.ts │ ├── installation.ts │ ├── jira-client.test.ts │ ├── jira-client.ts │ ├── models.test.ts │ ├── octokit-error.test.ts │ ├── octokit-error.ts │ ├── reposyncstate.test.ts │ ├── reposyncstate.ts │ ├── sentry-scope-proxy.ts │ ├── sentry-scrope-proxy.test.ts │ ├── sequelize.ts │ ├── subscription.test.ts │ ├── subscription.ts │ └── sync-state.example.json ├── rest-interfaces │ └── index.ts ├── rest │ ├── helper │ │ └── index.ts │ ├── middleware │ │ ├── error │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── jira-admin │ │ │ ├── jira-admin-check.test.ts │ │ │ └── jira-admin-check.ts │ │ └── jwt │ │ │ ├── github-token.test.ts │ │ │ ├── github-token.ts │ │ │ ├── jwt-handler.test.ts │ │ │ └── jwt-handler.ts │ ├── rest-router.ts │ └── routes │ │ ├── analytics-proxy │ │ ├── index.test.ts │ │ └── index.ts │ │ ├── deferred │ │ ├── deferred-analytics-proxy.ts │ │ ├── deferred-check-ownership-and-connect.test.ts │ │ ├── deferred-check-ownership-and-connect.ts │ │ ├── deferred-installation-url.test.ts │ │ ├── deferred-installation-url.ts │ │ ├── deferred-request-parse.test.ts │ │ ├── deferred-request-parse.ts │ │ └── index.ts │ │ ├── enterprise │ │ ├── delete-ghe-app.test.ts │ │ ├── delete-ghe-app.ts │ │ ├── delete-ghe-server.test.ts │ │ ├── delete-ghe-server.ts │ │ └── index.ts │ │ ├── github-apps │ │ ├── index.test.ts │ │ └── index.ts │ │ ├── github-callback │ │ ├── index.test.ts │ │ └── index.ts │ │ ├── github-orgs │ │ ├── index.test.ts │ │ ├── index.ts │ │ └── service.ts │ │ ├── index.d.ts │ │ ├── jira │ │ ├── index.test.ts │ │ └── index.ts │ │ ├── oauth │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── service.test.ts │ │ └── service.ts │ │ └── subscriptions │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── service.ts │ │ ├── sync.test.ts │ │ └── sync.ts ├── routes │ ├── api │ │ ├── api-hash-post.ts │ │ ├── api-ping-get.ts │ │ ├── api-ping-post.ts │ │ ├── api-recrypt-post.ts │ │ ├── api-replay-failed-entities-from-data-depot.test.ts │ │ ├── api-replay-failed-entities-from-data-depot.ts │ │ ├── api-reset-subscription-failed-tasks.ts │ │ ├── api-resync-failed-tasks.test.ts │ │ ├── api-resync-failed-tasks.ts │ │ ├── api-resync-post.test.ts │ │ ├── api-resync-post.ts │ │ ├── api-router.test.ts │ │ ├── api-router.ts │ │ ├── api-utils.ts │ │ ├── audit-log │ │ │ ├── audit-log-api-router.ts │ │ │ ├── audit-log-get-by-sub-id.test.ts │ │ │ └── audit-log-get-by-sub-id.ts │ │ ├── client-key │ │ │ ├── client-key-regex.test.ts │ │ │ ├── client-key-regex.ts │ │ │ └── recover-client-key.ts │ │ ├── commits-from-date │ │ │ ├── recover-commits-from-dates.ts │ │ │ └── reset-failed-and-pending-deployment-cursors.ts │ │ ├── configuration │ │ │ ├── api-configuration-get.test.ts │ │ │ ├── api-configuration-get.ts │ │ │ ├── api-configuration-post.test.ts │ │ │ ├── api-configuration-post.ts │ │ │ └── api-configuration-router.ts │ │ ├── data-cleanup │ │ │ ├── cleanup-reposyncstates.test.ts │ │ │ ├── cleanup-reposyncstates.ts │ │ │ └── data-cleanup-router.ts │ │ ├── db-migrations │ │ │ ├── db-migration-down.test.ts │ │ │ ├── db-migration-down.ts │ │ │ ├── db-migration-router.ts │ │ │ ├── db-migration-up.test.ts │ │ │ ├── db-migration-up.ts │ │ │ ├── db-migration-utils.test.ts │ │ │ └── db-migration-utils.ts │ │ ├── ghes-app-encryption-ctx │ │ │ └── re-encrypt-ghes-app-keys.ts │ │ ├── ghes-app-verification │ │ │ ├── ghes-app-verification-router.ts │ │ │ ├── ghes-app-verify-get-apps.test.ts │ │ │ └── ghes-app-verify-get-apps.ts │ │ ├── installation │ │ │ ├── api-installation-delete-pollinator.test.ts │ │ │ ├── api-installation-delete-pollinator.ts │ │ │ ├── api-installation-delete.ts │ │ │ ├── api-installation-get.test.ts │ │ │ ├── api-installation-get.ts │ │ │ ├── api-installation-router.test.ts │ │ │ ├── api-installation-router.ts │ │ │ ├── api-installation-sync-post.test.ts │ │ │ ├── api-installation-sync-post.ts │ │ │ ├── api-installation-syncstate-get.test.ts │ │ │ └── api-installation-syncstate-get.ts │ │ └── jira │ │ │ ├── api-jira-get.ts │ │ │ ├── api-jira-router.ts │ │ │ ├── api-jira-uninstall-post.ts │ │ │ └── api-jira-verify-post.ts │ ├── error-router.ts │ ├── github │ │ ├── branch │ │ │ ├── github-branch-get.test.ts │ │ │ ├── github-branch-get.ts │ │ │ └── github-branch-router.ts │ │ ├── configuration │ │ │ ├── github-configuration-app-installs-get.test.ts │ │ │ ├── github-configuration-app-installs-get.ts │ │ │ ├── github-configuration-get.test.ts │ │ │ ├── github-configuration-get.ts │ │ │ ├── github-configuration-post.test.ts │ │ │ ├── github-configuration-post.ts │ │ │ ├── github-configuration-router.route.test.ts │ │ │ ├── github-configuration-router.test.ts │ │ │ └── github-configuration-router.ts │ │ ├── create-branch │ │ │ ├── github-branches-get.test.ts │ │ │ ├── github-branches-get.ts │ │ │ ├── github-create-branch-get.test.ts │ │ │ ├── github-create-branch-get.ts │ │ │ ├── github-create-branch-options-get.test.ts │ │ │ ├── github-create-branch-options-get.ts │ │ │ ├── github-create-branch-post.frontend.test.ts │ │ │ ├── github-create-branch-post.test.ts │ │ │ ├── github-create-branch-post.ts │ │ │ ├── github-create-branch-router.ts │ │ │ ├── github-remove-session.test.ts │ │ │ └── github-remove-session.ts │ │ ├── github-5ku-router.test.ts │ │ ├── github-5ku-router.ts │ │ ├── github-oauth.test.ts │ │ ├── github-oauth.ts │ │ ├── github-router.test.ts │ │ ├── github-router.ts │ │ ├── manifest │ │ │ ├── github-manifest-complete-get.test.ts │ │ │ ├── github-manifest-complete-get.ts │ │ │ ├── github-manifest-get.test.ts │ │ │ ├── github-manifest-get.ts │ │ │ └── github-manifest-router.ts │ │ ├── repository │ │ │ ├── github-repository-get.test.ts │ │ │ ├── github-repository-get.ts │ │ │ └── github-repository-router.ts │ │ ├── setup │ │ │ ├── github-setup-get.ts │ │ │ ├── github-setup-post.ts │ │ │ ├── github-setup-router.test.ts │ │ │ └── github-setup-router.ts │ │ ├── subscription-deferred-install │ │ │ ├── github-subscription-deferred-install-get.test.ts │ │ │ ├── github-subscription-deferred-install-get.ts │ │ │ ├── github-subscription-deferred-install-post.test.ts │ │ │ ├── github-subscription-deferred-install-post.ts │ │ │ └── github-subscription-deferred-install-router.ts │ │ ├── subscription │ │ │ ├── github-subscription-delete.test.ts │ │ │ ├── github-subscription-delete.ts │ │ │ └── github-subscription-router.ts │ │ └── webhook │ │ │ ├── webhook-context.ts │ │ │ ├── webhook-logging-extra.ts │ │ │ ├── webhook-receiver-post.test.ts │ │ │ └── webhook-receiver-post.ts │ ├── healthcheck │ │ ├── deepcheck-get.ts │ │ ├── healthcheck-get-post.ts │ │ ├── healthcheck-router.test.ts │ │ └── healthcheck-router.ts │ ├── jira │ │ ├── atlassian-connect │ │ │ ├── jira-atlassian-connect-get.test.ts │ │ │ └── jira-atlassian-connect-get.ts │ │ ├── configuration │ │ │ └── jira-configuration-router.ts │ │ ├── connect │ │ │ ├── enterprise │ │ │ │ ├── app │ │ │ │ │ ├── jira-connect-enterprise-app-create-or-edit-get.frontend.test.ts │ │ │ │ │ ├── jira-connect-enterprise-app-create-or-edit-get.test.ts │ │ │ │ │ ├── jira-connect-enterprise-app-create-or-edit-get.ts │ │ │ │ │ ├── jira-connect-enterprise-app-delete.test.ts │ │ │ │ │ ├── jira-connect-enterprise-app-delete.ts │ │ │ │ │ ├── jira-connect-enterprise-app-post.test.ts │ │ │ │ │ ├── jira-connect-enterprise-app-post.ts │ │ │ │ │ ├── jira-connect-enterprise-app-put.test.ts │ │ │ │ │ ├── jira-connect-enterprise-app-put.ts │ │ │ │ │ ├── jira-connect-enterprise-app-router.test.ts │ │ │ │ │ ├── jira-connect-enterprise-app-router.ts │ │ │ │ │ ├── jira-connect-enterprise-apps-get.test.ts │ │ │ │ │ └── jira-connect-enterprise-apps-get.ts │ │ │ │ ├── jira-connect-enterprise-delete.test.ts │ │ │ │ ├── jira-connect-enterprise-delete.ts │ │ │ │ ├── jira-connect-enterprise-get.frontend.test.ts │ │ │ │ ├── jira-connect-enterprise-get.test.ts │ │ │ │ ├── jira-connect-enterprise-get.ts │ │ │ │ ├── jira-connect-enterprise-post.test.ts │ │ │ │ ├── jira-connect-enterprise-post.ts │ │ │ │ └── jira-connect-enterprise-router.ts │ │ │ ├── jira-connect-get.test.ts │ │ │ ├── jira-connect-get.ts │ │ │ └── jira-connect-router.ts │ │ ├── events │ │ │ ├── jira-events-install-post.test.ts │ │ │ ├── jira-events-install-post.ts │ │ │ ├── jira-events-router.ts │ │ │ ├── jira-events-uninstall-post.test.ts │ │ │ └── jira-events-uninstall-post.ts │ │ ├── jira-connected-repos-get.test.ts │ │ ├── jira-connected-repos-get.ts │ │ ├── jira-delete.test.ts │ │ ├── jira-delete.ts │ │ ├── jira-get.test.ts │ │ ├── jira-get.ts │ │ ├── jira-router.ts │ │ ├── security │ │ │ └── workspaces │ │ │ │ ├── containers │ │ │ │ ├── jira-security-workspaces-containers-post.test.ts │ │ │ │ ├── jira-security-workspaces-containers-post.ts │ │ │ │ ├── jira-security-workspaces-containers-router.ts │ │ │ │ ├── jira-security-workspaces-containers-search-get.test.ts │ │ │ │ ├── jira-security-workspaces-containers-search-get.ts │ │ │ │ └── jira-security-workspaces-containers.types.ts │ │ │ │ ├── jira-security-workspaces-post.test.ts │ │ │ │ ├── jira-security-workspaces-post.ts │ │ │ │ └── jira-security-workspaces-router.ts │ │ ├── sync │ │ │ ├── jira-sync-post.test.ts │ │ │ └── jira-sync-post.ts │ │ └── workspaces │ │ │ ├── jira-workspaces-get.test.ts │ │ │ ├── jira-workspaces-get.ts │ │ │ ├── jira-workspaces-router.ts │ │ │ └── repositories │ │ │ ├── jira-workspaces-repositories-associate.test.ts │ │ │ ├── jira-workspaces-repositories-associate.ts │ │ │ ├── jira-workspaces-repositories-get.test.ts │ │ │ ├── jira-workspaces-repositories-get.ts │ │ │ └── jira-workspaces-repositories-router.ts │ ├── maintenance │ │ ├── maintenance-get.ts │ │ ├── maintenance-router.test.ts │ │ └── maintenance-router.ts │ ├── microscope │ │ ├── microscope-dlq-router.ts │ │ └── operations │ │ │ └── microscope-dlq-operations.ts │ ├── public │ │ └── public-router.ts │ ├── router.test.ts │ ├── router.ts │ ├── session │ │ ├── session-get.test.ts │ │ └── session-get.ts │ ├── spa │ │ └── spa-router.ts │ └── version │ │ ├── version-get.test.ts │ │ └── version-get.ts ├── services │ ├── audit-log-service.test.ts │ ├── audit-log-service.ts │ ├── cluster │ │ ├── listen-command.ts │ │ └── send-command.ts │ ├── deployment-cache-service.test.ts │ ├── deployment-cache-service.ts │ ├── generate-once-coredump-generator.test.ts │ ├── generate-once-coredump-generator.ts │ ├── generate-once-per-node-headump-generator.ts │ ├── generate-once-per-node-heapdump-generator.test.ts │ ├── github │ │ └── user.ts │ ├── micros │ │ └── lifecycle.ts │ ├── subscription-deferred-install-service.test.ts │ ├── subscription-deferred-install-service.ts │ ├── subscription-installation-service.test.ts │ ├── subscription-installation-service.ts │ ├── user-config-service.test.ts │ └── user-config-service.ts ├── spa-proxy.ts ├── sqs │ ├── backfill-discovery.test.ts │ ├── backfill-error-handler.test.ts │ ├── backfill-error-handler.ts │ ├── backfill.test.ts │ ├── backfill.ts │ ├── branch.test.ts │ ├── branch.ts │ ├── deployment.test.ts │ ├── deployment.ts │ ├── error-handlers.test.ts │ ├── error-handlers.ts │ ├── push.test.ts │ ├── push.ts │ ├── queues.ts │ ├── sqs.test.ts │ ├── sqs.ts │ └── sqs.types.ts ├── sync │ ├── branch.test.ts │ ├── branches.ts │ ├── build.test.ts │ ├── build.ts │ ├── code-scanning-alerts.test.ts │ ├── code-scanning-alerts.ts │ ├── commits.test.ts │ ├── commits.ts │ ├── deduplicator.test.ts │ ├── deduplicator.ts │ ├── dependabot-alerts.test.ts │ ├── dependabot-alerts.ts │ ├── deployment.test.ts │ ├── deployment.ts │ ├── discovery.test.ts │ ├── discovery.ts │ ├── installation.test.ts │ ├── installation.ts │ ├── page-counter-cursor.test.ts │ ├── page-counter-cursor.ts │ ├── parallel-page-fetcher.test.ts │ ├── parallel-page-fetcher.ts │ ├── pull-request.ts │ ├── pull-requests.test.ts │ ├── scheduler.test.ts │ ├── scheduler.ts │ ├── secret-scanning-alerts.test.ts │ ├── secret-scanning-alerts.ts │ ├── sync-utils.test.ts │ ├── sync-utils.ts │ ├── sync.types.ts │ └── transforms │ │ └── branch.ts ├── transforms │ ├── push.test.ts │ ├── push.ts │ ├── transform-branch.ts │ ├── transform-code-scanning-alert.test.ts │ ├── transform-code-scanning-alert.ts │ ├── transform-commit.ts │ ├── transform-dependabot-alert.test.ts │ ├── transform-dependabot-alert.ts │ ├── transform-deployment.test.ts │ ├── transform-deployment.ts │ ├── transform-pull-request.test.ts │ ├── transform-pull-request.ts │ ├── transform-repository-id.test.ts │ ├── transform-repository-id.ts │ ├── transform-repository.ts │ ├── transform-secret-scanning-alert.test.ts │ ├── transform-secret-scanning-alert.ts │ ├── transform-workflow.ts │ └── util │ │ ├── github-api-requests.test.ts │ │ ├── github-api-requests.ts │ │ ├── github-get-pull-request-reviews.test.ts │ │ ├── github-get-pull-request-reviews.ts │ │ ├── github-security-alerts.ts │ │ ├── pull-request-link-generator.test.ts │ │ └── pull-request-link-generator.ts ├── util │ ├── analytics-client.test.ts │ ├── analytics-client.ts │ ├── api-key-validator.test.ts │ ├── api-key-validator.ts │ ├── app-properties-utils.ts │ ├── axios │ │ ├── jira-auth-middleware.test.ts │ │ ├── jira-auth-middleware.ts │ │ ├── url-params-middleware.test.ts │ │ └── url-params-middleware.ts │ ├── create-url-with-query-string.test.ts │ ├── create-url-with-query-string.ts │ ├── curl │ │ ├── curl-utils.test.ts │ │ └── curl-utils.ts │ ├── encryption-client.test.ts │ ├── encryption-client.ts │ ├── encryption.test.ts │ ├── encryption.ts │ ├── env-utils.ts │ ├── error-string-from-unknown.ts │ ├── filtering-http-logs-stream.ts │ ├── get-cloud-or-server.ts │ ├── get-github-client-config.test.ts │ ├── get-github-client-config.ts │ ├── ghe-connect-config-temp-storage.test.ts │ ├── ghe-connect-config-temp-storage.ts │ ├── github-installations-helper.ts │ ├── github-sync-helper.ts │ ├── github-utils.test.ts │ ├── github-utils.ts │ ├── handlebars │ │ ├── handlebar-helpers.test.ts │ │ ├── handlebar-helpers.ts │ │ └── handlebar-partials.ts │ ├── healthcheck-stopper.test.ts │ ├── healthcheck-stopper.ts │ ├── heap-size-utils.test.ts │ ├── heap-size-utils.ts │ ├── http-headers.test.ts │ ├── http-headers.ts │ ├── is-connected.ts │ ├── is-node-env.ts │ ├── jira-issue-check-redis-util.test.ts │ ├── jira-issue-check-redis-util.ts │ ├── jira-utils.test.ts │ ├── jira-utils.ts │ ├── log-sampled.test.ts │ ├── log-sampled.ts │ ├── logger-utils.test.ts │ ├── logger-utils.ts │ ├── match-route-with-pattern.test.ts │ ├── match-route-with-pattern.ts │ ├── not-empty.ts │ ├── paginate-response.test.ts │ ├── paginate-response.ts │ ├── preemptive-rate-limit.test.ts │ ├── preemptive-rate-limit.ts │ ├── regex.ts │ ├── validate-url.test.ts │ ├── validate-url.ts │ ├── validations.ts │ ├── webhook-timeout.test.ts │ ├── webhook-timeout.ts │ ├── webhook-utils.ts │ ├── webhooks.test.ts │ ├── workers-health-monitor.test.ts │ └── workers-health-monitor.ts ├── worker.ts └── worker │ ├── app.ts │ └── startup.ts ├── static ├── assets │ ├── Icon.png │ ├── addon.svg │ ├── arrow-left.svg │ ├── cloud.svg │ ├── collaborate-in-jira.svg │ ├── configure.svg │ ├── connected.svg │ ├── edit-icon.png │ ├── error.png │ ├── file-upload.svg │ ├── github-integration.svg │ ├── github-logo-dark-theme.svg │ ├── github-logo.svg │ ├── github-skeleton.svg │ ├── jira-and-github.png │ ├── jira-enterprise-server-connection.svg │ ├── jira-github-connected-dark-theme.svg │ ├── jira-github-connected.svg │ ├── jira-github-connection-success.svg │ ├── jira-github-connection.svg │ ├── jira-logo.svg │ ├── jira-software-logo.png │ ├── preferences.svg │ ├── question.svg │ ├── server.svg │ └── sync.svg ├── css │ ├── error.css │ ├── github-configuration.css │ ├── github-create-branch-options.css │ ├── github-create-branch.css │ ├── github-setup.css │ ├── global.css │ ├── jira-configuration.css │ ├── jira-connected-repos.css │ ├── jira-manual-app-creation.css │ ├── jira-select-app-creation.css │ ├── jira-select-github-cloud-app.css │ ├── jira-select-github-product.css │ ├── jira-select-server.css │ ├── jira-server-url.css │ ├── loading-screen.css │ ├── no-configuration.css │ ├── select-server-header.css │ ├── select-table.css │ └── server-error-message-box.css ├── jira-logo.png ├── js │ ├── github-configuration.js │ ├── github-create-branch-options.js │ ├── github-create-branch.js │ ├── github-setup.js │ ├── jira-api-key-validation.js │ ├── jira-configuration.js │ ├── jira-connected-repos.js │ ├── jira-manual-app-creation.js │ ├── jira-select-card-option.js │ ├── jira-select-github-cloud-app.js │ ├── jira-select-server.js │ ├── jira-server-url.js │ ├── jquery.min.js │ ├── navigation.js │ ├── no-configuration.js │ ├── select-table.js │ └── skeleton.js ├── maintenance.svg └── octicons │ ├── logo-github.svg │ └── mark-github.svg ├── test ├── e2e │ ├── app-installation.e2e.ts │ ├── constants.ts │ ├── create-branch.e2e.ts │ ├── create-project.e2e.ts │ ├── e2e-utils.ts │ ├── env-e2e.ts │ ├── login.e2e.ts │ ├── setup.ts │ ├── teardown.ts │ └── utils │ │ ├── github.ts │ │ ├── jira.ts │ │ └── ngrok.ts ├── fixtures │ ├── api │ │ ├── build-multi.json │ │ ├── build-no-keys.json │ │ ├── build.json │ │ ├── code-scanning-alert-closed-by-user.json │ │ ├── code-scanning-alert-created-js-xss.json │ │ ├── code-scanning-alert-created-pr.json │ │ ├── code-scanning-alert-created.json │ │ ├── code-scanning-alert-fixed.json │ │ ├── code-scanning-alert.json │ │ ├── code-scanning-alerts.json │ │ ├── commit-no-username.json │ │ ├── compare-references.json │ │ ├── graphql │ │ │ ├── branch-associated-pr-has-keys.json │ │ │ ├── branch-commits-have-keys.json │ │ │ ├── branch-empty-nodes.json │ │ │ ├── branch-no-issue-keys.json │ │ │ ├── branch-queries.ts │ │ │ ├── branch-ref-nodes.json │ │ │ ├── commit-empty-nodes.json │ │ │ ├── commit-nodes-mixed.json │ │ │ ├── commit-nodes-no-keys.json │ │ │ ├── commit-nodes.json │ │ │ ├── commit-queries.ts │ │ │ ├── default-branch-null.json │ │ │ ├── default-branch.json │ │ │ ├── dependabot-alerts.json │ │ │ ├── deployment-nodes-mixed.json │ │ │ ├── deployment-nodes-no-keys.json │ │ │ ├── deployment-nodes.json │ │ │ ├── pull-queries.ts │ │ │ ├── pull-request-empty-nodes.json │ │ │ ├── pull-request-no-keys.json │ │ │ ├── pull-request-nodes.json │ │ │ └── repositories.json │ │ ├── pull-request-has-multiple-reviewers-with-multiple-reviews.json │ │ ├── pull-request-list-gql.json │ │ ├── pull-request-list.json │ │ ├── pull-request-multiple-commits-diff.json │ │ ├── pull-request-reviewers-has-user.json │ │ ├── pull-request-reviewers-no-user.json │ │ ├── pull-request-single-commit-diff.json │ │ ├── pull-request.json │ │ ├── secret-scanning-alerts.json │ │ ├── transform-pull-request-list.json │ │ └── user.json │ ├── branch-basic.json │ ├── branch-delete.json │ ├── branch-invalid-ref_type.json │ ├── branch-no-issues.json │ ├── deployment_status-basic.json │ ├── deployment_status_staging.json │ ├── file-paths-too-long.json │ ├── get-repositories.json │ ├── invalid-file-paths.json │ ├── issue-basic.json │ ├── issue-comment-basic.json │ ├── issue-null-body.json │ ├── jira-configuration │ │ ├── failed-installation.json │ │ ├── muliple-successful-and-multiple-failed-installations.json │ │ ├── multiple-failed-installations.json │ │ ├── multiple-subscriptions.json │ │ ├── multiple-successful-connections.json │ │ ├── no-installations.json │ │ ├── no-subscriptions.json │ │ ├── single-installation.json │ │ ├── single-subscription.json │ │ ├── single-successful-and-single-failed-installations.json │ │ └── subscription-with-no-repos.json │ ├── list-repositories.json │ ├── more-than-10-files.json │ ├── pull-request-basic.json │ ├── pull-request-multiple-invalid-issue-key.json │ ├── pull-request-null-repo.json │ ├── pull-request-remove-keys.json │ ├── pull-request-test-changes-with-branch.json │ ├── pull-request-triggered-by-bot.json │ ├── push-basic.json │ ├── push-comment.json │ ├── push-merge-commit.json │ ├── push-mixed.json │ ├── push-multiple.json │ ├── push-no-issuekey-commits.json │ ├── push-no-issues.json │ ├── push-no-username.json │ ├── push-non-merge-commit.json │ ├── push-transition-comment.json │ ├── push-transition.json │ ├── push-with-config-file.json │ ├── push-worklog.json │ ├── repositories.json │ ├── sorted-repos.json │ ├── text │ │ ├── existing-reference-link.rendered.md │ │ ├── existing-reference-link.source.md │ │ ├── find-existing-references.rendered.md │ │ ├── find-existing-references.source.md │ │ ├── issue-keys-with-alphanumeric-values.rendered.md │ │ ├── issue-keys-with-alphanumeric-values.source.md │ │ ├── multiple-links.rendered.md │ │ ├── multiple-links.source.md │ │ ├── previously-referenced.rendered.md │ │ ├── previously-referenced.source.md │ │ ├── valid-and-invalid-issues.rendered.md │ │ └── valid-and-invalid-issues.source.md │ └── workflow-basic.json ├── jira │ └── multiple-jira.test.ts ├── mocks │ └── error-responses.ts ├── setup │ ├── env-test.ts │ ├── matchers │ │ ├── nock.ts │ │ ├── to-be-called-with-delay.ts │ │ └── to-promise.ts │ ├── setup.ts │ └── test-key.pem ├── snapshots │ ├── app.test.ts.snap │ ├── jira │ │ └── client │ │ │ └── jira-client.test.ts.snap │ ├── routes │ │ ├── api │ │ │ └── api-router.test.ts.snap │ │ ├── github │ │ │ └── subscription │ │ │ │ └── github-subscription-delete.test.ts.snap │ │ ├── jira │ │ │ └── atlassian-connect │ │ │ │ └── jira-atlassian-connect-get.test.ts.snap │ │ └── maintenance │ │ │ └── maintenance-router.test.ts.snap │ └── snapshot-resolver.ts └── utils │ ├── cookies.ts │ ├── create-webhook-app.ts │ ├── database-state-creator.ts │ ├── jwt.ts │ ├── models.ts │ └── wait-until.ts ├── tsconfig.json ├── tsconfig.release.json ├── views ├── error.hbs ├── github-configuration.hbs ├── github-create-branch-options.hbs ├── github-create-branch.hbs ├── github-manifest.hbs ├── github-setup.hbs ├── jira-configuration.hbs ├── jira-connected-repos.hbs ├── jira-manual-app-creation.hbs ├── jira-select-app-creation.hbs ├── jira-select-github-product.hbs ├── jira-select-server-app.hbs ├── jira-select-server.hbs ├── jira-server-url.hbs ├── maintenance.hbs ├── no-configuration.hbs ├── partials │ ├── github-setup-form-error.hbs │ ├── github-setup-form.hbs │ ├── jira-and-github-header.hbs │ ├── jira-configuration-empty-connections.hbs │ ├── jira-configuration-error-summary.hbs │ ├── jira-configuration-table.hbs │ ├── loading-screen.hbs │ ├── modal.hbs │ ├── navigation.hbs │ ├── select-app-creation-card.hbs │ ├── select-github-product-card.hbs │ ├── select-server-header.hbs │ ├── select-table.hbs │ └── server-error-message-box.hbs ├── session.hbs └── subscription-deferred-install-approval-form.hbs └── yarn.lock /.adr-dir: -------------------------------------------------------------------------------- 1 | docs/architecture/decisions 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Environment Files 2 | .env 3 | .env.local 4 | .env.local.* 5 | .env.*.local 6 | .env.*.local.* 7 | 8 | # DB Secret File 9 | *.pem 10 | **/*.pem 11 | 12 | # Transpiled files 13 | build/ 14 | 15 | # Node Modules 16 | node_modules/ 17 | 18 | # Coverage 19 | coverage 20 | 21 | # log 22 | log/ 23 | npm-debug.log 24 | 25 | # new relic 26 | newrelic_agent.log 27 | 28 | # IDEs 29 | .idea/ 30 | .vscode/ 31 | .vscodelog/* 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional eslint cache 37 | .eslintcache 38 | 39 | # Misc 40 | .DS_Store 41 | 42 | # Typescript Compilation 43 | src/**/*.js 44 | src/**/*.js.map 45 | test/**/*.js 46 | test/**/*.js.map 47 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.md] 10 | insert_final_newline = false 11 | trim_trailing_whitespace = false 12 | 13 | [*.{js,jsx,json,ts,tsx,yml}] 14 | indent_size = 2 15 | indent_style = tab 16 | tab_width = 2 17 | -------------------------------------------------------------------------------- /.env.development.local.example: -------------------------------------------------------------------------------- 1 | ##### DUPLICATE THIS FILE AND REMOVE THE `-example` AT THE END ##### 2 | 3 | # APP VARIABLES - NEEDED FOR PRODUCTION 4 | # Github App Information 5 | APP_ID= 6 | WEBHOOK_SECRETS= 7 | COOKIE_SESSION_KEY= 8 | GITHUB_CLIENT_ID= 9 | GITHUB_CLIENT_SECRET= 10 | # Path to github private key file (relative to root of this project). If at the root, just specify filename 11 | PRIVATE_KEY_PATH= 12 | 13 | # The URL for the local domain obtained after tunneling (ex. `https://__MYDOMAIN__.public.atlastunnel.com` or `https://XXXX-XXX-XXX-XXX-xX.ngrok.io`) 14 | APP_URL= 15 | 16 | # The Jira Instance URL you created for this app (ex. https://example.atlassian.net) 17 | ATLASSIAN_URL= 18 | 19 | # Uncomment to use proxy for calls to GitHub/GitHub enterprise servers 20 | #EXTERNAL_ONLY_PROXY_HOST="edge-outboundproxy01-stg-apse2.net.atlassian.com" 21 | #EXTERNAL_ONLY_PROXY_PORT=8080 22 | -------------------------------------------------------------------------------- /.env.e2e.local.example: -------------------------------------------------------------------------------- 1 | # APP VARIABLES - NEEDED FOR PRODUCTION 2 | # GitHub App Information 3 | APP_NAME= 4 | APP_ID= 5 | WEBHOOK_SECRETS= 6 | COOKIE_SESSION_KEY= 7 | GITHUB_CLIENT_ID= 8 | GITHUB_CLIENT_SECRET= 9 | PRIVATE_KEY_PATH= 10 | NGROK_AUTHTOKEN= 11 | 12 | # E2E Role Credentials 13 | ATLASSIAN_URL= 14 | JIRA_ADMIN_USERNAME= 15 | JIRA_ADMIN_PASSWORD= 16 | GITHUB_ORG= 17 | GITHUB_USERNAME= 18 | GITHUB_PASSWORD= 19 | GITHUB_URL=https://github.com 20 | GITHUB_2FA_SECRET= 21 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Set your auth token here if using ngrok, you can get it from https://dashboard.ngrok.com/get-started/setup 2 | NGROK_AUTHTOKEN=some-token 3 | 4 | # Unique name for the jira app, used in the Atlassian Connect manifest to 5 | # differentiate this instance from other deployments (staging, dev instances, etc). 6 | # Recommended to use your github username here 7 | APP_KEY=com.github.integration.your-unique-instance-name 8 | 9 | # Your Jira URL 10 | ATLASSIAN_URL=https://your-unique-jira-subdomain.atlassian.net 11 | 12 | # Add your Jira email/API token for automatic app installation 13 | # Create a new API token in Jira [https://id.atlassian.com/manage-profile/security/api-tokens] 14 | JIRA_ADMIN_EMAIL= 15 | JIRA_ADMIN_API_TOKEN= 16 | 17 | # These will be automatically filled when the docker container starts 18 | APP_URL=http://localhost 19 | WEBHOOK_PROXY_URL=http://localhost 20 | 21 | 22 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /**/*.js 2 | prestart.ts 3 | spa/ 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @atlassian/fusion-arc 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Github for Jira Documentation 4 | url: https://support.atlassian.com/jira-cloud-administration/docs/integrate-with-github/ 5 | about: Learn more about how to install, configure and use Github for Jira 6 | - name: Support Requests and Bug Reports 7 | url: https://support.atlassian.com/contact/#/ 8 | about: Need some help? Please submit a support request or bug report via Jira Software Support portal 9 | - name: Feature Requests and Product Suggestions 10 | url: https://jira.atlassian.com/secure/Dashboard.jspa 11 | about: Suggest an idea or solution for the Github for Jira app. When creating tickets, please select the project-"Jira Software Cloud" and the component-"Integration - GitHub - Marketplace". -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **What's in this PR?** 2 | 3 | **Why** 4 | 5 | **Added feature flags** 6 | 7 | **Affected issues** 8 | _Jira Issues_ 9 | 10 | **How has this been tested?** 11 | _Include how to test if applicable_ 12 | 13 | **Whats Next?** 14 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | schedule: 6 | - cron: '0 4 * * 0' 7 | 8 | jobs: 9 | CodeQL-Build: 10 | 11 | strategy: 12 | fail-fast: false 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | # Initializes the CodeQL tools for scanning. 19 | - name: Initialize CodeQL 20 | uses: github/codeql-action/init@v1 21 | - name: Autobuild 22 | uses: github/codeql-action/autobuild@v1 23 | - name: Perform CodeQL Analysis 24 | uses: github/codeql-action/analyze@v1 25 | -------------------------------------------------------------------------------- /.github/workflows/dummy-deployment.yml: -------------------------------------------------------------------------------- 1 | name: "Dummy deployment" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | env: 6 | description: 'The env the dummy deploy points to' 7 | type: choice 8 | options: 9 | - development 10 | - staging 11 | - production 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | environment: 16 | name: ${{ inputs.env }} 17 | steps: 18 | - name: 'Deploy' 19 | id: deploy-dummy 20 | run: | 21 | echo "Deploy to $TARGET_ENV success" 22 | env: 23 | TARGET_ENV: ${{ inputs.env }} 24 | 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment Files 2 | .env 3 | .env.bak.local 4 | .env.local.* 5 | .env.*.local 6 | .env.*.local.* 7 | !.env.example 8 | !.env.local.example 9 | !.env.*.local.example 10 | 11 | # DB Secret File 12 | *.pem 13 | **/*.pem 14 | 15 | # Transpiled files 16 | build/ 17 | 18 | # Node Modules 19 | node_modules/ 20 | 21 | # Coverage 22 | coverage/ 23 | 24 | # log 25 | log/ 26 | *.log 27 | 28 | # new relic 29 | newrelic_agent.log 30 | 31 | # IDEs 32 | .idea/ 33 | .vscode/ 34 | .vscodelog/* 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional eslint cache 40 | .eslintcache 41 | 42 | # Misc 43 | .DS_Store 44 | 45 | # Typescript Compilation 46 | src/**/*.js 47 | src/**/*.js.map 48 | test/**/*.js 49 | test/**/*.js.map 50 | 51 | #Deployments 52 | .nebulae/ 53 | 54 | # e2e tests 55 | test/e2e/test-results/ 56 | 57 | *.drawio.bkp 58 | *.drawio.dtmp 59 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/create-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | DIR=$(dirname "$0") 4 | FILE="${DIR}/../.env" 5 | EXAMPLE="${DIR}/../.env.example" 6 | 7 | if [ ! -f "$FILE" ]; then 8 | echo ".env file not found, using .env.example..." 9 | cp "$EXAMPLE" "$FILE" 10 | echo ".env file created with example" 11 | fi 12 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | . "$(dirname "$0")/create-env.sh" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn run precommit 5 | -------------------------------------------------------------------------------- /.localstack/sqs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Development queues 4 | awslocal sqs create-queue --queue-name backfill 5 | awslocal sqs create-queue --queue-name push 6 | awslocal sqs create-queue --queue-name deployment 7 | awslocal sqs create-queue --queue-name branch 8 | awslocal sqs create-queue --queue-name incominganalyticevents 9 | 10 | # Test queues 11 | awslocal sqs create-queue --queue-name test-sqs-client 12 | awslocal sqs create-queue --queue-name test-backfill 13 | awslocal sqs create-queue --queue-name test-push 14 | awslocal sqs create-queue --queue-name test-deployment 15 | awslocal sqs create-queue --queue-name test-branch 16 | awslocal sqs create-queue --queue-name test-incominganalyticevents 17 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.18.1 -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require('path') 3 | 4 | module.exports = { 5 | "config": path.resolve('./db/config.json'), 6 | "models-path": path.resolve('./src/models'), 7 | "seeders-path": path.resolve('./db/seeders'), 8 | "migrations-path": path.resolve('./db/migrations') 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine3.18 as build 2 | 3 | # adding python for node-gyp 4 | RUN apk add g++ make python3 5 | 6 | # For coredumps 7 | RUN apk add gdb 8 | RUN apk add bash 9 | 10 | # adding to solve vuln 11 | RUN apk add --update --upgrade busybox libretls openssl zlib curl 12 | 13 | COPY . /app 14 | WORKDIR /app 15 | 16 | # Installing packages 17 | RUN cat ./package.json 18 | RUN yarn install --frozen-lockfile 19 | 20 | # If you are going to remove this, please make sure that it doesn't break existing GitHubServerApps: 21 | # 1. create an API endpoint that calls all prod servers and checks for SSL checks in stg 22 | # 2. deploy change without this line to stg 23 | # 3. call the API endpoint again; compare results with the ones from #1 24 | # Details: 25 | # https://github.com/nodejs/node/issues/16336#issuecomment-568845447 26 | # ENV NODE_EXTRA_CA_CERTS=node_modules/node_extra_ca_certs_mozilla_bundle/ca_bundle/ca_intermediate_root_bundle.pem 27 | 28 | CMD ["yarn", "start:no-spa"] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Atlassian Pty Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/start-server-micros.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd "$(dirname "$0")/.." 4 | 5 | case "$MICROS_GROUP" in 6 | "WebServer") 7 | COMMAND="start:main:production" 8 | ;; 9 | "Worker") 10 | COMMAND="start:worker:production" 11 | ;; 12 | *) 13 | echo "Wrong MICROS_GROUP environment parameter: ${MICROS_GROUP}" 14 | exit 1 15 | ;; 16 | esac 17 | 18 | case "$MICROS_ENVTYPE" in 19 | "dev") 20 | export NODE_OPTIONS="--max-old-space-size=250" # since ddev nodes have smaller memory available in general 21 | ;; 22 | esac 23 | 24 | export DATABASE_URL=postgres://$PG_DATABASE_ROLE:$PG_DATABASE_PASSWORD@$PG_DATABASE_BOUNCER:$PG_DATABASE_PORT/$PG_DATABASE_SCHEMA 25 | echo "We are at:" 26 | pwd 27 | npm run "${COMMAND}" 28 | -------------------------------------------------------------------------------- /db/migrations/20180612233338-create-installation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.createTable('Installations', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | jiraHost: { 12 | type: Sequelize.STRING 13 | }, 14 | sharedSecret: { 15 | type: Sequelize.STRING 16 | }, 17 | createdAt: { 18 | allowNull: false, 19 | type: Sequelize.DATE 20 | }, 21 | updatedAt: { 22 | allowNull: false, 23 | type: Sequelize.DATE 24 | } 25 | }) 26 | }, 27 | down: (queryInterface, Sequelize) => { 28 | return queryInterface.dropTable('Installations') 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /db/migrations/20180614222557-create-subscription.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.createTable('Subscriptions', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | gitHubInstallationId: { 12 | type: Sequelize.INTEGER 13 | }, 14 | jiraHost: { 15 | type: Sequelize.STRING 16 | }, 17 | createdAt: { 18 | allowNull: false, 19 | type: Sequelize.DATE 20 | }, 21 | updatedAt: { 22 | allowNull: false, 23 | type: Sequelize.DATE 24 | } 25 | }) 26 | }, 27 | down: (queryInterface, Sequelize) => { 28 | return queryInterface.dropTable('Subscriptions') 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /db/migrations/20180619181057-add-secrets.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.addColumn('Installations', 'secrets', Sequelize.BLOB) 6 | }, 7 | 8 | down: (queryInterface, Sequelize) => { 9 | return queryInterface.removeColumn('Installations', 'secrets') 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /db/migrations/20180619181227-remove-unencrypted-secrets.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.removeColumn('Installations', 'sharedSecret') 6 | }, 7 | 8 | down: (queryInterface, Sequelize) => { 9 | return queryInterface.addColumn('Installations', 'sharedSecret', Sequelize.STRING) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /db/migrations/20180621181228-add-enable-flag-for-installations.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.addColumn('Installations', 'enabled', { 5 | type: Sequelize.BOOLEAN, 6 | allowNull: false, 7 | defaultValue: true 8 | }) 9 | }, 10 | down: (queryInterface, Sequelize) => { 11 | return queryInterface.removeColumn('Installations', 'enabled') 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /db/migrations/20180626204822-add-client-key.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.addColumn('Installations', 'clientKey', { 6 | type: Sequelize.STRING, 7 | allowNull: false 8 | }) 9 | }, 10 | 11 | down: (queryInterface, Sequelize) => { 12 | return queryInterface.removeColumn('Installations', 'clientKey') 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /db/migrations/20180706184633-update-subscriptions-column.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.bulkDelete('Subscriptions', { 6 | [Sequelize.Op.or]: [ 7 | { 8 | jiraHost: null 9 | }, 10 | { 11 | gitHubInstallationId: null 12 | } 13 | ] 14 | }) 15 | 16 | await queryInterface.changeColumn('Subscriptions', 'gitHubInstallationId', { 17 | allowNull: false, 18 | type: Sequelize.INTEGER 19 | }) 20 | 21 | await queryInterface.changeColumn('Subscriptions', 'jiraHost', { 22 | allowNull: false, 23 | type: Sequelize.STRING 24 | }) 25 | }, 26 | 27 | down: async (queryInterface, Sequelize) => { 28 | await queryInterface.changeColumn('Subscriptions', 'gitHubInstallationId', { 29 | type: Sequelize.INTEGER 30 | }) 31 | 32 | await queryInterface.changeColumn('Subscriptions', 'jiraHost', { 33 | type: Sequelize.STRING 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /db/migrations/20180914203755-analytics-views.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface) => { 3 | await queryInterface.sequelize.query('CREATE SCHEMA analytics;') 4 | 5 | await queryInterface.sequelize.query(` 6 | CREATE VIEW analytics.Subscriptions AS ( 7 | SELECT 8 | id, 9 | "gitHubInstallationId" AS github_installation_id, 10 | "jiraHost" AS jira_host, 11 | "createdAt" as created_at, 12 | "updatedAt" as updated_at 13 | FROM 14 | "Subscriptions" 15 | ) 16 | `) 17 | 18 | await queryInterface.sequelize.query(` 19 | CREATE VIEW analytics.Installations AS ( 20 | SELECT 21 | id, 22 | "jiraHost" AS jira_host, 23 | "createdAt" as created_at, 24 | "updatedAt" as updated_at, 25 | enabled 26 | FROM 27 | "Installations" 28 | ) 29 | `) 30 | }, 31 | 32 | down: async (queryInterface) => { 33 | await queryInterface.sequelize.query('DROP SCHEMA analytics CASCADE;') 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /db/migrations/20180917195216-add-sync-columns.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | up: async (queryInterface, Sequelize) => { 4 | await queryInterface.addColumn('Subscriptions', 'selectedRepositories', { 5 | type: Sequelize.ARRAY(Sequelize.INTEGER), 6 | allowNull: true 7 | }) 8 | 9 | await queryInterface.addColumn('Subscriptions', 'repoSyncState', { 10 | type: Sequelize.JSONB, 11 | allowNull: true 12 | }) 13 | 14 | await queryInterface.addColumn('Subscriptions', 'syncStatus', { 15 | type: Sequelize.ENUM('PENDING', 'COMPLETE', 'ACTIVE', 'FAILED'), 16 | allowNull: true 17 | }) 18 | }, 19 | 20 | down: async (queryInterface, Sequelize) => { 21 | await queryInterface.removeColumn('Subscriptions', 'selectedRepositories') 22 | await queryInterface.removeColumn('Subscriptions', 'repoSyncState') 23 | await queryInterface.removeColumn('Subscriptions', 'syncStatus') 24 | await queryInterface.sequelize.query('DROP TYPE "enum_Subscriptions_syncStatus";') 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /db/migrations/20181024000949-add-projects.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | up: (queryInterface, Sequelize) => { 4 | return queryInterface.createTable('Projects', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER 10 | }, 11 | projectKey: { 12 | type: Sequelize.STRING, 13 | allowNull: true 14 | }, 15 | occurrences: { 16 | type: Sequelize.INTEGER, 17 | allowNull: false, 18 | defaultValue: 0 19 | }, 20 | jiraHost: { 21 | type: Sequelize.STRING, 22 | allowNull: false 23 | }, 24 | createdAt: { 25 | allowNull: false, 26 | type: Sequelize.DATE 27 | }, 28 | updatedAt: { 29 | allowNull: false, 30 | type: Sequelize.DATE 31 | } 32 | }) 33 | }, 34 | down: (queryInterface, Sequelize) => { 35 | return queryInterface.dropTable('Projects') 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /db/migrations/20181024214811-more-analytics-views.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: async (queryInterface) => { 5 | await queryInterface.sequelize.query(` 6 | CREATE OR REPLACE VIEW "analytics"."subscriptions" AS SELECT "Subscriptions".id, 7 | "Subscriptions"."gitHubInstallationId" AS github_installation_id, 8 | "Subscriptions"."jiraHost" AS jira_host, 9 | "Subscriptions"."createdAt" AS created_at, 10 | "Subscriptions"."updatedAt" AS updated_at, 11 | "Subscriptions"."repoSyncState" AS repo_sync_state 12 | FROM "Subscriptions"; 13 | `) 14 | 15 | await queryInterface.sequelize.query(` 16 | CREATE VIEW analytics.Projects AS ( 17 | SELECT 18 | id, 19 | "projectKey" AS project_key, 20 | "occurrences", 21 | "jiraHost" as jira_host, 22 | "updatedAt" as updated_at, 23 | "createdAt" as created_at 24 | FROM 25 | "Projects" 26 | )` 27 | ) 28 | }, 29 | 30 | down: async (queryInterface) => { 31 | await queryInterface.sequelize.query('DROP VIEW analytics.projects') 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /db/migrations/20190610222557-update-subscription.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn('Subscriptions', 'jiraClientKey', { 6 | type: Sequelize.STRING, 7 | allowNull: true 8 | }) 9 | }, 10 | 11 | down: async (queryInterface, Sequelize) => { 12 | await queryInterface.removeColumn('Subscriptions', 'jiraClientKey') 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /db/migrations/20190716143013-migrate-clientKey.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.sequelize.query(` 6 | UPDATE "Subscriptions" 7 | SET "jiraClientKey" = ( 8 | SELECT "clientKey" 9 | FROM "Installations" 10 | WHERE "Subscriptions"."jiraHost" = 11 | "Installations"."jiraHost" 12 | LIMIT 1 13 | ) 14 | WHERE EXISTS ( 15 | SELECT "clientKey" 16 | FROM "Installations" 17 | WHERE "Subscriptions"."jiraHost" = "Installations"."jiraHost" 18 | ) 19 | `) 20 | }, 21 | 22 | down: async (queryInterface, Sequelize) => { 23 | await queryInterface.sequelize.query(` 24 | UPDATE "Subscriptions" 25 | SET "jiraClientKey" = NULL 26 | WHERE EXISTS ( 27 | SELECT "clientKey" 28 | FROM "Installations" 29 | WHERE "Subscriptions"."jiraHost" = "Installations"."jiraHost" 30 | ) 31 | `) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /db/migrations/20190807230019-add-indexes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addIndex('Subscriptions', { 6 | fields: ['gitHubInstallationId', 'jiraHost'], 7 | name: 'Subscriptions_gitHubInstallationId_jiraHost_idx' 8 | }) 9 | 10 | await queryInterface.addIndex('Subscriptions', { 11 | fields: ['jiraHost'], 12 | name: 'Subscriptions_jiraHost_idx' 13 | }) 14 | 15 | await queryInterface.addIndex('Subscriptions', { 16 | fields: ['jiraClientKey'], 17 | name: 'Subscriptions_jiraClientKey_idx' 18 | }) 19 | }, 20 | 21 | down: async (queryInterface, Sequelize) => { 22 | await queryInterface.removeIndex('Subscriptions', 'Subscriptions_gitHubInstallationId_jiraHost_idx') 23 | await queryInterface.removeIndex('Subscriptions', 'Subscriptions_jiraHost_idx') 24 | await queryInterface.removeIndex('Subscriptions', 'Subscriptions_jiraClientKey_idx') 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /db/migrations/20190807234327-add-indexes-on-projects.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addIndex('Projects', { 4 | fields: ['jiraHost', 'projectKey'], 5 | name: 'Projects_jiraHost_projectKey_idx' 6 | }) 7 | }, 8 | 9 | down: async (queryInterface, Sequelize) => { 10 | await queryInterface.removeIndex('Projects', 'Projects_jiraHost_projectKey_idx') 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /db/migrations/20190808001912-add-indexes-on-installations.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addIndex('Installations', { 4 | fields: ['jiraHost'], 5 | name: 'Installations_jiraHost_idx' 6 | }) 7 | }, 8 | 9 | down: async (queryInterface, Sequelize) => { 10 | await queryInterface.removeIndex('Installations', 'Installations_jiraHost_idx') 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /db/migrations/20190828145758-add-sync-warning-column-on-subscriptions.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('Subscriptions', 'syncWarning', { 4 | type: Sequelize.STRING, 5 | allowNull: true 6 | }) 7 | }, 8 | 9 | down: async (queryInterface, Sequelize) => { 10 | await queryInterface.removeColumn('Subscriptions', 'syncWarning') 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /db/migrations/20211110173000-add-extra-subscription-columns.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const tableName = "Subscriptions"; 4 | module.exports = { 5 | up: async (queryInterface, Sequelize) => { 6 | await queryInterface.addColumn(tableName, 'rateLimitRemaining', { 7 | type: Sequelize.INTEGER, 8 | allowNull: true 9 | }) 10 | 11 | await queryInterface.addColumn(tableName, 'rateLimitReset', { 12 | type: Sequelize.DATE, 13 | allowNull: true 14 | }) 15 | 16 | await queryInterface.addColumn(tableName, 'numberOfSyncedRepos', { 17 | type: Sequelize.INTEGER, 18 | }) 19 | 20 | }, 21 | 22 | down: async (queryInterface, Sequelize) => { 23 | await queryInterface.removeColumn(tableName, 'rateLimitRemaining') 24 | await queryInterface.removeColumn(tableName, 'rateLimitReset') 25 | await queryInterface.removeColumn(tableName, 'numberOfSyncedRepos') 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /db/migrations/20220420145000-add-repository-cursor.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const tableName = "Subscriptions"; 4 | module.exports = { 5 | up: async (queryInterface, Sequelize) => { 6 | await queryInterface.addColumn(tableName, "repositoryCursor", { 7 | type: Sequelize.STRING, 8 | allowNull: true 9 | }); 10 | await queryInterface.addColumn(tableName, "repositoryStatus", { 11 | type: Sequelize.ENUM("pending", "complete", "failed"), 12 | allowNull: true 13 | }); 14 | await queryInterface.addColumn(tableName, "totalNumberOfRepos", { 15 | type: Sequelize.INTEGER, 16 | allowNull: true 17 | }); 18 | }, 19 | 20 | down: async (queryInterface, Sequelize) => { 21 | await queryInterface.removeColumn(tableName, "repositoryCursor"); 22 | await queryInterface.removeColumn(tableName, "repositoryStatus"); 23 | await queryInterface.removeColumn(tableName, "totalNumberOfRepos"); 24 | await queryInterface.sequelize.query(`DROP TYPE "enum_${tableName}_repositoryStatus";`); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /db/migrations/20220509002323-create-git-hub-server-app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | return queryInterface.createTable('GitHubServerApps', { 6 | id: { 7 | allowNull: false, 8 | autoIncrement: true, 9 | primaryKey: true, 10 | type: Sequelize.INTEGER 11 | }, 12 | uuid: { 13 | allowNull: false, 14 | unique: true, 15 | type: Sequelize.UUID 16 | }, 17 | githubBaseUrl: { 18 | allowNull: false, 19 | type: Sequelize.STRING 20 | }, 21 | githubClientId: { 22 | allowNull: false, 23 | type: Sequelize.STRING 24 | }, 25 | secrets: { 26 | allowNull: false, 27 | type: Sequelize.BLOB 28 | }, 29 | createdAt: { 30 | allowNull: false, 31 | type: Sequelize.DATE 32 | }, 33 | updatedAt: { 34 | allowNull: false, 35 | type: Sequelize.DATE 36 | } 37 | }) 38 | }, 39 | 40 | down: async (queryInterface, Sequelize) => { 41 | return queryInterface.dropTable('GitHubServerApps') 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /db/migrations/20220511143000-add-github-app-id.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const tableName = "Installations"; 4 | module.exports = { 5 | up: async (queryInterface, Sequelize) => { 6 | await queryInterface.addColumn(tableName, "githubAppId", { 7 | type: Sequelize.INTEGER, 8 | allowNull: true 9 | }); 10 | }, 11 | 12 | down: async (queryInterface, Sequelize) => { 13 | await queryInterface.removeColumn(tableName, "githubAppId"); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /db/migrations/20220524110000-update-build-deploy-sync.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.sequelize.query(` 6 | UPDATE "RepoSyncStates" 7 | SET "buildStatus" = 'complete', "deploymentStatus" = 'complete' 8 | `) 9 | }, 10 | 11 | down: async (queryInterface, Sequelize) => { 12 | await queryInterface.sequelize.query(` 13 | UPDATE "RepoSyncStates" 14 | SET "buildStatus = NULL, "deploymentStatus" = NULL 15 | `) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /db/migrations/20220609044825-move-github-app-id-to-subscriptions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.removeColumn("Installations", "githubAppId"); 6 | 7 | await queryInterface.addColumn("Subscriptions", "gitHubAppId", { 8 | type: Sequelize.INTEGER, 9 | allowNull: true 10 | }); 11 | }, 12 | 13 | down: async (queryInterface, Sequelize) => { 14 | await queryInterface.addColumn("Installations", "gitHubAppId", { 15 | type: Sequelize.INTEGER, 16 | allowNull: true 17 | }); 18 | 19 | await queryInterface.removeColumn("Subscriptions", "gitHubAppId"); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /db/migrations/20220614055633-readd-github-app-id.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const tableName = "Installations"; 4 | module.exports = { 5 | up: async (queryInterface, Sequelize) => { 6 | await queryInterface.addColumn(tableName, "githubAppId", { 7 | type: Sequelize.INTEGER, 8 | allowNull: true 9 | }); 10 | }, 11 | 12 | down: async (queryInterface, Sequelize) => { 13 | await queryInterface.removeColumn(tableName, "githubAppId"); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /db/migrations/20220630040916-add-app-id.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | up: (queryInterface, Sequelize) => { 5 | return queryInterface.addColumn('GitHubServerApps', 'appId', { 6 | type: Sequelize.INTEGER, 7 | allowNull: false 8 | }) 9 | }, 10 | 11 | down: (queryInterface, Sequelize) => { 12 | return queryInterface.removeColumn('GitHubServerApps', 'appId') 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /db/migrations/20220705052312-add-cryptor-sharesecret-column-to-installation-table.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const tableName = "Installations"; 4 | 5 | module.exports = { 6 | up: async (queryInterface, Sequelize) => { 7 | await queryInterface.addColumn(tableName, "encryptedSharedSecret", { 8 | type: Sequelize.TEXT, 9 | allowNull: true 10 | }); 11 | }, 12 | 13 | down: async (queryInterface) => { 14 | await queryInterface.removeColumn(tableName, "encryptedSharedSecret"); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /db/migrations/20220803110900-add-config-columns.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const tableNames = ["RepoSyncStates"]; 4 | module.exports = { 5 | up: async (queryInterface, Sequelize) => { 6 | await Promise.all([ 7 | tableNames.map(tableName => queryInterface.addColumn(tableName, "config", { 8 | type: Sequelize.JSON, 9 | allowNull: true 10 | })) 11 | ]); 12 | }, 13 | 14 | down: async (queryInterface, Sequelize) => { 15 | await Promise.all([ 16 | tableNames.map(tableName => queryInterface.removeColumn(tableName, "config")) 17 | ]); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /db/migrations/20220808010636-cleanup-gsha.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tableName = "GitHubServerApps"; 4 | 5 | module.exports = { 6 | up: async (queryInterface) => { 7 | // Staging database is out of sync - workaround so we can deploy to staging without it failing 8 | try { 9 | await queryInterface.removeColumn(tableName, "githubBaseUrl"); 10 | await queryInterface.removeColumn(tableName, "githubClientId"); 11 | } catch (err) {} 12 | }, 13 | 14 | down: async (queryInterface, Sequelize) => { 15 | await queryInterface.addColumn(tableName, "githubBaseUrl", { 16 | type: Sequelize.String, 17 | allowNull: true 18 | }); 19 | await queryInterface.addColumn(tableName, "githubClientId", { 20 | type: Sequelize.String, 21 | allowNull: true 22 | }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /db/migrations/20220829153300-github-app-id-default-value.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.removeColumn("Installations", "githubAppId"); 6 | 7 | await queryInterface.changeColumn("Subscriptions", "gitHubAppId", { 8 | type: Sequelize.INTEGER, 9 | allowNull: true, 10 | defaultValue: null 11 | }); 12 | }, 13 | 14 | down: async (queryInterface, Sequelize) => { 15 | await queryInterface.addColumn("Installations", "githubAppId", { 16 | type: Sequelize.INTEGER, 17 | allowNull: true 18 | }); 19 | 20 | await queryInterface.changeColumn("Subscriptions", "gitHubAppId", { 21 | type: Sequelize.INTEGER, 22 | allowNull: true 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /db/migrations/20221117154600-add-new-client-key-columns.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn("Installations", "plainClientKey", { type: Sequelize.STRING, allowNull: true }); 6 | await queryInterface.addColumn("Subscriptions", "plainClientKey", { type: Sequelize.STRING, allowNull: true }); 7 | }, 8 | 9 | down: async (queryInterface) => { 10 | await queryInterface.removeColumn("Installations", "plainClientKey"); 11 | await queryInterface.removeColumn("Subscriptions", "plainClientKey"); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /db/migrations/20230202110100-add-repo-sync-state-fk.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: async (queryInterface) => { 5 | //refer to doc https://sequelize.org/v5/class/lib/query-interface.js~queryinterface#instance-method-addConstraint 6 | await queryInterface.addConstraint("RepoSyncStates", { 7 | type: "foreign key", 8 | fields: ["subscriptionId"], 9 | name: "reposyncstates_subscriptions_id_fk", 10 | references: { 11 | table: "Subscriptions", 12 | field: "id" 13 | }, 14 | onUpdate: "cascade", 15 | onDelete: "cascade" 16 | }); 17 | }, 18 | 19 | down: async (queryInterface) => { 20 | await queryInterface.removeConstraint("RepoSyncStates", "reposyncstates_subscriptions_id_fk"); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /db/migrations/20230220142300-add-subs-bkfill-date-col.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn("Subscriptions", "backfillSince", { type: Sequelize.DATE, allowNull: true }); 6 | }, 7 | 8 | down: async (queryInterface) => { 9 | await queryInterface.removeColumn("Subscriptions", "backfillSince"); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /db/migrations/20230310104700-add-reposync-failedcode-col.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn("RepoSyncStates", "failedCode", { type: Sequelize.STRING , allowNull: true }); 6 | }, 7 | 8 | down: async (queryInterface) => { 9 | await queryInterface.removeColumn("RepoSyncStates", "failedCode"); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /db/migrations/20230316051227-add-commit-from.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn("RepoSyncStates", "commitFrom", { type: Sequelize.DATE, allowNull: true }); 6 | }, 7 | 8 | down: async (queryInterface, Sequelize) => { 9 | await queryInterface.removeColumn("RepoSyncStates", "commitFrom"); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /db/migrations/20230320085600-add-commit-from-all-entities.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn("RepoSyncStates", "branchFrom", { type: Sequelize.DATE, allowNull: true }); 6 | await queryInterface.addColumn("RepoSyncStates", "pullFrom", { type: Sequelize.DATE, allowNull: true }); 7 | await queryInterface.addColumn("RepoSyncStates", "buildFrom", { type: Sequelize.DATE, allowNull: true }); 8 | await queryInterface.addColumn("RepoSyncStates", "deploymentFrom", { type: Sequelize.DATE, allowNull: true }); 9 | }, 10 | 11 | down: async (queryInterface, Sequelize) => { 12 | await queryInterface.removeColumn("RepoSyncStates", "branchFrom"); 13 | await queryInterface.removeColumn("RepoSyncStates", "pullFrom"); 14 | await queryInterface.removeColumn("RepoSyncStates", "buildFrom"); 15 | await queryInterface.removeColumn("RepoSyncStates", "deploymentFrom"); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /db/migrations/20230503000639-github-server-app-add-api-key-columns.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | /** 6 | * Add altering commands here. 7 | * 8 | * Example: 9 | * await queryInterface.createTable("users", { id: Sequelize.INTEGER }); 10 | */ 11 | await queryInterface.addColumn("GitHubServerApps", "apiKeyHeaderName", { 12 | type: Sequelize.STRING(1024), 13 | allowNull: true 14 | }); 15 | 16 | await queryInterface.addColumn("GitHubServerApps", "encryptedApiKeyValue", { 17 | type: Sequelize.TEXT, 18 | allowNull: true 19 | }); 20 | }, 21 | 22 | down: async (queryInterface) => { 23 | /** 24 | * Add reverting commands here. 25 | * 26 | * Example: 27 | * await queryInterface.dropTable("users"); 28 | */ 29 | await queryInterface.removeColumn("GitHubServerApps", "apiKeyHeaderName"); 30 | await queryInterface.removeColumn("GitHubServerApps", "encryptedApiKeyValue"); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /db/migrations/20230711040027-add-avatar-to-subscription.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const tableName = "Subscriptions"; 4 | 5 | module.exports = { 6 | up: async (queryInterface, Sequelize) => { 7 | await queryInterface.addColumn(tableName, "avatarUrl", { 8 | type: Sequelize.STRING, 9 | allowNull: true 10 | }); 11 | }, 12 | 13 | down: async (queryInterface, Sequelize) => { 14 | await queryInterface.removeColumn(tableName, "avatarUrl"); 15 | } 16 | }; -------------------------------------------------------------------------------- /db/migrations/20230717232437-add-dependabot-alert-cols.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn("RepoSyncStates", "dependabotAlertFrom", { type: Sequelize.DATE, allowNull: true }); 6 | await queryInterface.addColumn("RepoSyncStates", "dependabotAlertStatus", { type: Sequelize.ENUM("pending", "complete", "failed"), allowNull: true }); 7 | await queryInterface.addColumn("RepoSyncStates", "dependabotAlertCursor", { type: Sequelize.STRING, allowNull: true }); 8 | }, 9 | 10 | down: async (queryInterface, Sequelize) => { 11 | await queryInterface.removeColumn("RepoSyncStates", "dependabotAlertFrom"); 12 | await queryInterface.removeColumn("RepoSyncStates", "dependabotAlertStatus"); 13 | await queryInterface.removeColumn("RepoSyncStates", "dependabotAlertCursor"); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /db/migrations/20230731063755-add-security-permission-col.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn("Subscriptions", "isSecurityPermissionsAccepted", { 6 | type: Sequelize.BOOLEAN, 7 | allowNull: false, 8 | defaultValue: false 9 | }); 10 | }, 11 | 12 | down: async (queryInterface, Sequelize) => { 13 | await queryInterface.removeColumn("Subscriptions", "isSecurityPermissionsAccepted"); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /db/migrations/20230808062435-add-secret-scanning-alert-cols.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn("RepoSyncStates", "secretScanningAlertFrom", { type: Sequelize.DATE, allowNull: true }); 6 | await queryInterface.addColumn("RepoSyncStates", "secretScanningAlertStatus", { type: Sequelize.ENUM("pending", "complete", "failed"), allowNull: true }); 7 | await queryInterface.addColumn("RepoSyncStates", "secretScanningAlertCursor", { type: Sequelize.STRING, allowNull: true }); 8 | }, 9 | 10 | down: async (queryInterface, Sequelize) => { 11 | await queryInterface.removeColumn("RepoSyncStates", "secretScanningAlertFrom"); 12 | await queryInterface.removeColumn("RepoSyncStates", "secretScanningAlertStatus"); 13 | await queryInterface.removeColumn("RepoSyncStates", "secretScanningAlertCursor"); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /db/migrations/20230823013417-add-code-scanning-alert-cols.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | up: async (queryInterface, Sequelize) => { 5 | await queryInterface.addColumn("RepoSyncStates", "codeScanningAlertFrom", { type: Sequelize.DATE, allowNull: true }); 6 | await queryInterface.addColumn("RepoSyncStates", "codeScanningAlertStatus", { type: Sequelize.ENUM("pending", "complete", "failed"), allowNull: true }); 7 | await queryInterface.addColumn("RepoSyncStates", "codeScanningAlertCursor", { type: Sequelize.STRING, allowNull: true }); 8 | }, 9 | 10 | down: async (queryInterface, Sequelize) => { 11 | await queryInterface.removeColumn("RepoSyncStates", "codeScanningAlertFrom"); 12 | await queryInterface.removeColumn("RepoSyncStates", "codeScanningAlertStatus"); 13 | await queryInterface.removeColumn("RepoSyncStates", "codeScanningAlertCursor"); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /docs/DevInfo-v0.10-ImplementationGuidance-062218.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/DevInfo-v0.10-ImplementationGuidance-062218.pdf -------------------------------------------------------------------------------- /docs/DevInfoAPI_ExampleUseCases.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/DevInfoAPI_ExampleUseCases.pdf -------------------------------------------------------------------------------- /docs/JiraDevelopmentInformationAPI-VendorImplementationGuidance.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/JiraDevelopmentInformationAPI-VendorImplementationGuidance.pdf -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | ## Simple Network Diagram 4 | 5 | Below is a point-in-time network diagram of the Jira Application Deployment attempting to capture the most 6 | important sections of how and where network resources are and what the connections are. 7 | 8 | ![Simple Jira Network Architecture Diagram](./images/simple-jira-architecture.png) 9 | -------------------------------------------------------------------------------- /docs/images/architecture-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/architecture-overview.png -------------------------------------------------------------------------------- /docs/images/associating-builds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/associating-builds.png -------------------------------------------------------------------------------- /docs/images/associating-deployments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/associating-deployments.png -------------------------------------------------------------------------------- /docs/images/author-icons-in-jira-for-non-matching-atlassian-emails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/author-icons-in-jira-for-non-matching-atlassian-emails.png -------------------------------------------------------------------------------- /docs/images/builds-data-jira-dev-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/builds-data-jira-dev-panel.png -------------------------------------------------------------------------------- /docs/images/code-in-jira-missing-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/code-in-jira-missing-user.png -------------------------------------------------------------------------------- /docs/images/connect-gh-org-to-jira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/connect-gh-org-to-jira.png -------------------------------------------------------------------------------- /docs/images/correctly-mapped-deployment-environments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/correctly-mapped-deployment-environments.png -------------------------------------------------------------------------------- /docs/images/data-model.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/data-model.jpg -------------------------------------------------------------------------------- /docs/images/deployments-in-jira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/deployments-in-jira.png -------------------------------------------------------------------------------- /docs/images/devinfo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/devinfo.png -------------------------------------------------------------------------------- /docs/images/edit-github-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/edit-github-settings.png -------------------------------------------------------------------------------- /docs/images/get-started.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/get-started.png -------------------------------------------------------------------------------- /docs/images/github-ip-allowlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/github-ip-allowlist.png -------------------------------------------------------------------------------- /docs/images/install-app-atlassian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/install-app-atlassian.png -------------------------------------------------------------------------------- /docs/images/install-app-in-github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/install-app-in-github.png -------------------------------------------------------------------------------- /docs/images/issue-board-view-missing-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/issue-board-view-missing-user.png -------------------------------------------------------------------------------- /docs/images/jira-issue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/jira-issue.png -------------------------------------------------------------------------------- /docs/images/network-traffic-diagram--manual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/network-traffic-diagram--manual.png -------------------------------------------------------------------------------- /docs/images/network-traffic-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/network-traffic-diagram.png -------------------------------------------------------------------------------- /docs/images/public-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/public-profile.png -------------------------------------------------------------------------------- /docs/images/read-and-write-permissions-issues-and-prs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/read-and-write-permissions-issues-and-prs.png -------------------------------------------------------------------------------- /docs/images/restart-backfill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/restart-backfill.png -------------------------------------------------------------------------------- /docs/images/select-backfill-date.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/select-backfill-date.png -------------------------------------------------------------------------------- /docs/images/simple-jira-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/simple-jira-architecture.png -------------------------------------------------------------------------------- /docs/images/unmapped-deployment-environments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/unmapped-deployment-environments.png -------------------------------------------------------------------------------- /docs/images/untick-private-email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/images/untick-private-email.png -------------------------------------------------------------------------------- /docs/undefined-environment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/docs/undefined-environment.png -------------------------------------------------------------------------------- /etc/app-install/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest as build 2 | 3 | RUN apk --no-cache add curl jq 4 | 5 | COPY ./etc/app-install/app-install.sh ./app/app-install.sh 6 | WORKDIR /app 7 | 8 | CMD ["sh", "app-install.sh"] 9 | -------------------------------------------------------------------------------- /etc/cryptor-mock/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /src 4 | 5 | RUN npm i express body-parser 6 | COPY cryptor-mock.js /src 7 | 8 | EXPOSE 26272 9 | CMD node /src/cryptor-mock.js 10 | -------------------------------------------------------------------------------- /etc/spa-build/spa-build.sh: -------------------------------------------------------------------------------- 1 | set -e #exit on failed commands 2 | 3 | cd spa 4 | 5 | # Doing yarn install to avoid any build errors 6 | yarn 7 | 8 | yarn build 9 | 10 | echo "All done!!" 11 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | const { devices } = require("@playwright/test"); 2 | 3 | module.exports = { 4 | testDir: `${__dirname}/test/e2e`, 5 | testMatch: /.*\.e2e\.ts/, 6 | outputDir: `${__dirname}/test/e2e/test-results/tests`, 7 | use: { 8 | trace: "retain-on-failure", 9 | video: "retain-on-failure", 10 | screenshot: "only-on-failure" 11 | }, 12 | timeout: 60000, 13 | globalSetup: `${__dirname}/test/e2e/setup.ts`, 14 | globalTeardown: `${__dirname}/test/e2e/teardown.ts`, 15 | // Fail the build on CI if you accidentally left test.only in the source code. 16 | forbidOnly: !!process.env.CI, 17 | // Opt out of parallel tests - this is because of the app installation test 18 | workers: 1, 19 | retries: process.env.CI ? 2 : 0, 20 | projects: [ 21 | { 22 | name: "chromium", 23 | use: { 24 | ...devices["Desktop Chrome"]/*, 25 | storageState: `${__dirname}/test/e2e/test-results/states/default.json`*/ 26 | } 27 | } 28 | /*{ 29 | name: "firefox", 30 | use: { ...devices["Desktop Firefox"] } 31 | }, 32 | { 33 | name: "webkit", 34 | use: { ...devices["Desktop Safari"] } 35 | }*/ 36 | ] 37 | }; 38 | -------------------------------------------------------------------------------- /project-descriptor.yml: -------------------------------------------------------------------------------- 1 | # Used to start Nebulae sandbox. 2 | # First, build docker image then update version here if needed. 3 | # To start sandbox: 4 | # - `DOCKER_IMAGE_NAME=docker.atl-paas.net/atlassian/github-for-jira DOCKER_IMAGE_TAG= start-nebatlas nebulae start --print-build-output` 5 | # To stop sandbox: 6 | # -`atlas nebulae stop` 7 | service: 8 | # The name of your service 9 | name: github-for-jira 10 | 11 | descriptor: 12 | # Location of your service descriptor 13 | path: github-for-jira.sd.yml 14 | build: 15 | commands: 16 | # The steps to build your Docker image. It might be a raw `docker build` 17 | # like below, or it might be a script or several scripts if your build 18 | # process is more involved. 19 | - docker build -t atlassian/github-for-jira:0.0.0 . 20 | -------------------------------------------------------------------------------- /spa/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react-hooks/recommended", 7 | ], 8 | root: true, 9 | parser: "@typescript-eslint/parser", 10 | parserOptions: { ecmaVersion: "latest", sourceType: "module" }, 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "react-refresh/only-export-components": "warn", 14 | "semi": [ 2, "always" ], 15 | "@typescript-eslint/no-empty-function": "off", 16 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 17 | "quotes": [ 18 | "error", 19 | "double", 20 | { 21 | "avoidEscape": true, 22 | "allowTemplateLiterals": true 23 | } 24 | ], 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /spa/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /spa/.yarnrc: -------------------------------------------------------------------------------- 1 | --ignore-engines true 2 | -------------------------------------------------------------------------------- /spa/README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | This is the new frontend version of the app in React JS. 4 | 5 | # For Development 6 | 7 | This SPA is already run when you start the GitHub for Jira app. The app should be running at `http://localhost:5173`. 8 | For local instances, a proxy server has been created to run this locally, which is running on `https://NODE_TUNNEL_URL/spa`. 9 | 10 | # For Production 11 | 12 | This SPA is build when you run the build for the GitHub for Jira app. 13 | Running the `yarn build` should create `dist` folder which is served by the GitHub for Jira app under the url `/spa`. 14 | Check `router.ts#59` -------------------------------------------------------------------------------- /spa/config-overrides.js: -------------------------------------------------------------------------------- 1 | //https://stackoverflow.com/questions/70591567/module-not-found-error-cant-resolve-fs-in-react 2 | module.exports = function override(config, env) { 3 | console.log("React app rewired works!") 4 | config.resolve.fallback = { 5 | "@atlassiansox/analytics-web-client": false 6 | }; 7 | return config; 8 | }; 9 | -------------------------------------------------------------------------------- /spa/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testEnvironment": "jsdom", 3 | "transform": { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | "setupFilesAfterEnv": [ 7 | "./setup.ts" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /spa/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GitHub for Jira 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /spa/setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; -------------------------------------------------------------------------------- /spa/src/api/apps/index.ts: -------------------------------------------------------------------------------- 1 | import { GetGitHubAppsUrlResponse, JiraCloudIDResponse } from "rest-interfaces"; 2 | import { axiosRest } from "../axiosInstance"; 3 | 4 | export default { 5 | getAppNewInstallationUrl: () => axiosRest.get("/rest/app/cloud/installation/new"), 6 | getJiraCloudId: () => axiosRest.get("/rest/app/cloud/jira/cloudid"), 7 | }; 8 | 9 | -------------------------------------------------------------------------------- /spa/src/api/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { GetRedirectUrlResponse, ExchangeTokenResponse } from "rest-interfaces"; 2 | import { axiosRestWithNoJwt } from "../axiosInstance"; 3 | 4 | export default { 5 | generateOAuthUrl: () => axiosRestWithNoJwt.get("/rest/app/cloud/oauth/redirectUrl"), 6 | exchangeToken: (code: string, state: string) => axiosRestWithNoJwt.post("/rest/app/cloud/oauth/exchangeToken", { code, state }), 7 | }; 8 | -------------------------------------------------------------------------------- /spa/src/api/deferral/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeferralParsedRequest, 3 | DeferredInstallationUrlParams, 4 | GetDeferredInstallationUrl 5 | } from "rest-interfaces"; 6 | import { axiosRest, axiosRestWithNoJwtButWithGitHubToken } from "../axiosInstance"; 7 | 8 | export default { 9 | getDeferredInstallationUrl: (params: DeferredInstallationUrlParams) => 10 | axiosRest.get("/rest/app/cloud/deferred/installation-url", { params }), 11 | connectDeferredOrg: (requestId: string) => axiosRestWithNoJwtButWithGitHubToken.post(`/rest/app/cloud/deferred/connect/${requestId}`), 12 | parseDeferredRequestId: (requestId: string) => axiosRestWithNoJwtButWithGitHubToken.get(`/rest/app/cloud/deferred/parse/${requestId}`) 13 | }; 14 | 15 | -------------------------------------------------------------------------------- /spa/src/api/github/index.ts: -------------------------------------------------------------------------------- 1 | import { UsersGetAuthenticatedResponse } from "rest-interfaces"; 2 | import { axiosGitHub } from "../axiosInstance"; 3 | 4 | export default { 5 | getUserDetails: () => axiosGitHub.get("https://api.github.com/user"), 6 | }; 7 | -------------------------------------------------------------------------------- /spa/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import Token from "./token"; 2 | import Auth from "./auth"; 3 | import App from "./apps"; 4 | import Orgs from "./orgs"; 5 | import GitHub from "./github"; 6 | import Subscription from "./subscriptions"; 7 | import Deferral from "./deferral"; 8 | 9 | const ApiRequest = { 10 | token: Token, 11 | auth: Auth, 12 | gitHub: GitHub, 13 | app: App, 14 | orgs: Orgs, 15 | deferral: Deferral, 16 | subscriptions: Subscription, 17 | }; 18 | 19 | export default ApiRequest; 20 | -------------------------------------------------------------------------------- /spa/src/api/orgs/index.ts: -------------------------------------------------------------------------------- 1 | import { OrganizationsResponse } from "rest-interfaces"; 2 | import { axiosRestWithGitHubToken } from "../axiosInstance"; 3 | 4 | export default { 5 | getOrganizations: () => axiosRestWithGitHubToken.get("/rest/app/cloud/org"), 6 | connectOrganization: (orgId: number) => axiosRestWithGitHubToken.post("/rest/app/cloud/org", { installationId: orgId }), 7 | }; 8 | -------------------------------------------------------------------------------- /spa/src/api/subscriptions/index.ts: -------------------------------------------------------------------------------- 1 | import { axiosRest } from "../axiosInstance"; 2 | import { RestSyncReqBody } from "~/src/rest-interfaces"; 3 | 4 | export default { 5 | getSubscriptions: () => axiosRest.get("/rest/subscriptions"), 6 | deleteGHEServer: (serverUrl: string) => 7 | axiosRest.delete(`/rest/ghes-servers/${serverUrl}`), 8 | deleteGHEApp: (uuid: string) => 9 | axiosRest.delete(`/rest/app/${uuid}`), 10 | deleteSubscription: (subscriptionId: number) => 11 | axiosRest.delete(`/rest/app/cloud/subscriptions/${subscriptionId}`), 12 | syncSubscriptions: (subscriptionId: number, reqBody: RestSyncReqBody) => 13 | axiosRest.post(`/rest/app/cloud/subscriptions/${subscriptionId}/sync`, reqBody), 14 | }; 15 | -------------------------------------------------------------------------------- /spa/src/api/token/index.ts: -------------------------------------------------------------------------------- 1 | import { clearGitHubToken, setGitHubToken, hasGitHubToken } from "../axiosInstance"; 2 | 3 | export default { 4 | hasGitHubToken, 5 | clearGitHubToken, 6 | setGitHubToken, 7 | }; 8 | -------------------------------------------------------------------------------- /spa/src/common/Scrollbars.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | import SimpleBar from "simplebar-react"; 3 | import "simplebar-react/dist/simplebar.min.css"; 4 | 5 | // Source: https://github.com/Grsmto/simplebar/blob/master/packages/simplebar/README.md#options 6 | const Scrollbars = ({ 7 | style, 8 | children 9 | }: { 10 | style: React.CSSProperties | undefined, 11 | children: React.JSX.Element 12 | }) => 13 | 18 | {children} 19 | ; 20 | 21 | export default Scrollbars; 22 | -------------------------------------------------------------------------------- /spa/src/components/Error/test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render, screen } from "@testing-library/react"; 3 | import Error from "./index"; 4 | 5 | test("Basic test for errors", async () => { 6 | render(); 7 | 8 | expect(screen.queryByText("BOOM! Warming the boiled egg in the microwave!!")).toBeInTheDocument(); 9 | expect(screen.queryByLabelText("warning")).toBeInTheDocument(); 10 | }); 11 | 12 | test("Basic test for warnings", async () => { 13 | render(Warning! Do not warm the boiled egg in the microwave!} />); 14 | 15 | expect(screen.queryByText("Warning! Do not warm the boiled egg in the microwave!")).toBeInTheDocument(); 16 | expect(screen.queryByLabelText("warning")).toBeInTheDocument(); 17 | }); 18 | -------------------------------------------------------------------------------- /spa/src/components/GithubConnectedHeader/index.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | import { token, useThemeObserver } from "@atlaskit/tokens"; 3 | import { css } from "@emotion/react"; 4 | 5 | const headerWrapperStyle = css` 6 | text-align: center; 7 | `; 8 | const logoContainerStyle = css` 9 | display: inline-flex; 10 | align-items: center; 11 | `; 12 | const logoImgStyle = css` 13 | height: 96px; 14 | `; 15 | const titleStyle = css` 16 | margin: ${token("space.400")} ${token("space.0")} ${token("space.300")}; 17 | `; 18 | 19 | const GithubConnectedHeader = () => { 20 | const { colorMode } = useThemeObserver(); 21 | 22 | return ( 23 |
24 |
25 | 34 |
35 |

GitHub is now connected

36 |
37 | ); 38 | }; 39 | 40 | export default GithubConnectedHeader; 41 | -------------------------------------------------------------------------------- /spa/src/feature-flags.ts: -------------------------------------------------------------------------------- 1 | const featureFlags = () => FRONTEND_FEATURE_FLAGS ? 2 | JSON.parse(JSON.stringify(FRONTEND_FEATURE_FLAGS)) : null; 3 | export const enableBackfillStatusPage = featureFlags()?.ENABLE_5KU_BACKFILL_PAGE; 4 | -------------------------------------------------------------------------------- /spa/src/global.d.ts: -------------------------------------------------------------------------------- 1 | //Need this line to make it a "module" file 2 | //https://stackoverflow.com/a/42257742 3 | export {}; 4 | 5 | declare global { 6 | const AP: AtlassianPlugin; 7 | const SPA_APP_ENV: "" | "local" | "dev" | "staging" | "prod"; 8 | const SENTRY_SPA_DSN: string | undefined; 9 | const FRONTEND_FEATURE_FLAGS: Record; 10 | } 11 | 12 | interface AtlassianPlugin { 13 | getLocation: (...args) => void; 14 | context: { 15 | getToken: (...args) => void; 16 | } 17 | navigator: { 18 | go: (...args) => void; 19 | reload: () => void; 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /spa/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "@atlaskit/css-reset"; 4 | import App from "./app"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /spa/src/pages/ConfigSteps/SkeletonForLoading/index.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | import Step from "../../../components/Step"; 3 | import Skeleton from "@atlaskit/skeleton"; 4 | import { css } from "@emotion/react"; 5 | 6 | const contentStyle = css` 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | margin: 0 auto; 11 | `; 12 | 13 | const SkeletonForLoading = () => <> 14 | } 21 | > 22 | 28 | 29 |
30 | 36 |
37 | ; 38 | 39 | export default SkeletonForLoading; 40 | -------------------------------------------------------------------------------- /spa/src/pages/Connections/SkeletonForLoading/index.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | import Step from "../../../components/Step"; 3 | import Skeleton from "@atlaskit/skeleton"; 4 | import { css } from "@emotion/react"; 5 | 6 | const contentStyle = css` 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | margin: 0 auto; 11 | `; 12 | 13 | const SkeletonForLoading = () => <> 14 | } 21 | > 22 | 28 | 29 |
30 | 36 |
37 | ; 38 | 39 | export default SkeletonForLoading; 40 | -------------------------------------------------------------------------------- /spa/src/pages/DeferredInstallation/ErrorState/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation } from "react-router-dom"; 2 | import { ErrorObjType } from "../../../utils/modifyError"; 3 | import ErrorUI from "../../../components/Error"; 4 | import Step from "../../../components/Step"; 5 | import { Wrapper } from "../../../common/Wrapper"; 6 | import SyncHeader from "../../../components/SyncHeader"; 7 | import analyticsClient from "../../../analytics"; 8 | 9 | const ErrorState = () => { 10 | const location = useLocation(); 11 | const { error, requestId }: { error: ErrorObjType; requestId: string } = location.state; 12 | analyticsClient.sendScreenEvent({ name: "DeferredInstallationErrorScreen"}, { type: "cloud" }, requestId); 13 | 14 | return (<> 15 | 16 | 17 | 18 | 19 |
20 | Please inform the person who sent you the link that the link
21 | has expired and send a new link. 22 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | export default ErrorState; 29 | -------------------------------------------------------------------------------- /spa/src/pages/DeferredInstallation/ForbiddenState/test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render, screen } from "@testing-library/react"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import ForbiddenState from "./index"; 5 | 6 | jest.mock("react-router-dom", () => ({ 7 | ...(jest.requireActual("react-router-dom")), 8 | useLocation: () => ({ 9 | "state": { 10 | "requestId": { 11 | "requestId": "request-id" 12 | } 13 | } 14 | }), 15 | })); 16 | 17 | test("Forbidden State screen", async () => { 18 | render( 19 | 20 | 21 | 22 | ); 23 | 24 | expect(screen.getByText("Connect Github to Jira")).toBeTruthy(); 25 | expect(screen.getByText("Can’t connect this organization because you don’t have owner permissions")).toBeTruthy(); 26 | expect(screen.getByText("The GitHub account you’ve used doesn’t have owner permissions to connect to the GitHub organization.")).toBeTruthy(); 27 | expect(screen.getByText("Let the person who sent you the request know to find an owner for that organization.")).toBeTruthy(); 28 | }); 29 | -------------------------------------------------------------------------------- /spa/src/rest-interfaces: -------------------------------------------------------------------------------- 1 | ../../src/rest-interfaces -------------------------------------------------------------------------------- /spa/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2022.Error"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "noEmit": true, 12 | "jsx": "react-jsx", 13 | "jsxImportSource": "@emotion/react", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "allowSyntheticDefaultImports": true, 19 | "paths": { 20 | "~/*": ["./*"], 21 | "rest-interfaces": ["./src/rest-interfaces"] 22 | } 23 | }, 24 | "include": ["src"] 25 | } 26 | -------------------------------------------------------------------------------- /src/backfill/branch-processor.ts: -------------------------------------------------------------------------------- 1 | import { JobState, StepProcessor, StepResult } from "./backfill.types"; 2 | 3 | export class BranchProcessor implements StepProcessor { 4 | process(jobState: JobState): StepResult { 5 | return { 6 | success: true, 7 | jobState: jobState 8 | }; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/backfill/commit-processor.ts: -------------------------------------------------------------------------------- 1 | import { JobState, StepProcessor, StepResult } from "./backfill.types"; 2 | 3 | export class CommitProcessor implements StepProcessor { 4 | process(jobState: JobState): StepResult { 5 | return { 6 | success: true, 7 | jobState: jobState 8 | }; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/backfill/pull-request-processor.ts: -------------------------------------------------------------------------------- 1 | import { JobState, StepProcessor, StepResult } from "./backfill.types"; 2 | 3 | export class PullRequestProcessor implements StepProcessor { 4 | process(jobState: JobState): StepResult { 5 | return { 6 | success: true, 7 | jobState: jobState 8 | }; 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/config/cidr-validator.test.ts: -------------------------------------------------------------------------------- 1 | import { isIp4InCidrs } from "./cidr-validator"; 2 | 3 | describe("cidr-validator", () => { 4 | test.each([ 5 | ["192.168.1.5", ["10.10.0.0/16", "192.168.1.1/24"], true], 6 | ["10.10.1.5", ["10.10.0.0/16", "192.168.1.1/24"], true], 7 | ["122.168.1.5", ["10.10.0.0/16", "192.168.1.1/24"], false], 8 | ["some-hostname", ["10.10.0.0/16", "192.168.1.1/24"], false] 9 | ])(".isIp4InCidrs(%p, %p) === %p", (ip, cidrs, expected) => { 10 | expect(isIp4InCidrs(ip, cidrs)).toBe(expected); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/config/dynamodb.ts: -------------------------------------------------------------------------------- 1 | import AWS from "aws-sdk"; 2 | import { envVars } from "config/env"; 3 | import { isNodeProd } from "utils/is-node-env"; 4 | 5 | export const dynamodb = new AWS.DynamoDB({ 6 | apiVersion: "2012-11-05", 7 | region: envVars.DYNAMO_DEPLOYMENT_HISTORY_CACHE_TABLE_REGION, 8 | endpoint: isNodeProd() ? undefined : "http://localhost:4566" 9 | }); 10 | 11 | -------------------------------------------------------------------------------- /src/config/jira-test-site-check.test.ts: -------------------------------------------------------------------------------- 1 | import { isTestJiraHost } from "./jira-test-site-check"; 2 | 3 | describe.each([ 4 | [undefined, false], 5 | ["https://site-1.some-test.atlassian.net", true], 6 | ["https://site-2.some-test.atlassian.net", true], 7 | ["https://site-3.some-test.atlassian.net", true], 8 | ["https://real-site-1.non-test.atlassian.net", false] 9 | ])("Checking whether it is jira test site", (site, shouldBeTest) => { 10 | it(`should successfully check ${site ?? "undefined"} to be is-jira-test-site?: ${shouldBeTest}`, () => { 11 | expect(isTestJiraHost(site)).toBe(shouldBeTest); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/config/jira-test-site-check.ts: -------------------------------------------------------------------------------- 1 | import { envVars } from "config/env"; 2 | const testSites = (envVars.JIRA_TEST_SITES || "").split(",").filter(s => !!s).map(s => s.trim()); 3 | export const isTestJiraHost = (jiraHost: string | undefined) => { 4 | if (!jiraHost) return false; 5 | return testSites.includes(jiraHost); 6 | }; 7 | -------------------------------------------------------------------------------- /src/config/redis-info.ts: -------------------------------------------------------------------------------- 1 | import IORedis from "ioredis"; 2 | import { isNodeProd } from "utils/is-node-env"; 3 | import { envVars } from "config/env"; 4 | 5 | export const getRedisInfo = (connectionName: string): IORedis.RedisOptions => ({ 6 | port: Number(envVars.REDISX_CACHE_PORT) || 6379, 7 | host: envVars.REDISX_CACHE_HOST || "127.0.0.1", 8 | db: 0, 9 | reconnectOnError(err) { 10 | const targetError = "READONLY"; 11 | 12 | if (err.message.includes(targetError)) { 13 | return 1; 14 | } 15 | return false; 16 | }, 17 | // TODO find out if we still need these options 18 | // https://github.com/OptimalBits/bull/issues/1873#issuecomment-950873766 19 | maxRetriesPerRequest: null, 20 | enableReadyCheck: false, 21 | 22 | tls: isNodeProd() || envVars.REDISX_CACHE_TLS_ENABLED ? { checkServerIdentity: () => undefined } : undefined, 23 | connectionName 24 | }); 25 | -------------------------------------------------------------------------------- /src/config/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | import { envVars } from "./env"; 3 | 4 | const { SENTRY_DSN, MICROS_ENV, MICROS_SERVICE_VERSION } = envVars; 5 | 6 | export const initializeSentry = (): void => { 7 | Sentry.init({ 8 | dsn: SENTRY_DSN, 9 | environment: MICROS_ENV, 10 | release: MICROS_SERVICE_VERSION 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/github/client/auth-token.ts: -------------------------------------------------------------------------------- 1 | export const NINE_MINUTES_MSEC = 9 * 60 * 1000; 2 | export const ONE_MINUTE = 60 * 1000; 3 | 4 | export class AuthToken { 5 | readonly token: string; 6 | readonly expirationDate: Date; 7 | 8 | constructor(token: string, expirationDate: Date) { 9 | this.token = token; 10 | this.expirationDate = expirationDate; 11 | } 12 | 13 | isAboutToExpire(): boolean { 14 | return Date.now() + ONE_MINUTE > this.expirationDate.getTime(); 15 | } 16 | 17 | millisUntilAboutToExpire() { 18 | return Math.max(this.expirationDate.getTime() - ONE_MINUTE - Date.now(), 0); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/github/client/github-client-constants.ts: -------------------------------------------------------------------------------- 1 | export const GITHUB_CLOUD_HOSTNAME = "github.com"; 2 | export const GITHUB_CLOUD_BASEURL = "https://github.com"; 3 | export const GITHUB_CLOUD_API_BASEURL = "https://api.github.com"; 4 | export const GITHUB_ACCEPT_HEADER = "application/vnd.github.v3+json"; 5 | -------------------------------------------------------------------------------- /src/github/client/installation-id.test.ts: -------------------------------------------------------------------------------- 1 | import { InstallationId } from "./installation-id"; 2 | 3 | describe("InstallationId", () => { 4 | 5 | it("serializes correctly", async () => { 6 | const expectedString = "https://api.github.com###4711###12345678"; 7 | const installationId = new InstallationId("https://api.github.com", 4711, 12345678); 8 | expect(installationId.toString()).toBe(expectedString); 9 | }); 10 | 11 | it("deserializes correctly", async () => { 12 | const deserializedInstallationId = InstallationId.fromString("https://api.github.com###4711###12345678"); 13 | expect(deserializedInstallationId.githubBaseUrl).toBe("https://api.github.com"); 14 | expect(deserializedInstallationId.appId).toBe(4711); 15 | expect(deserializedInstallationId.installationId).toBe(12345678); 16 | }); 17 | 18 | }); 19 | -------------------------------------------------------------------------------- /src/jira/extract-installation-from-jira-callback.ts: -------------------------------------------------------------------------------- 1 | import { Installation } from "models/installation"; 2 | import { NextFunction, Request, Response } from "express"; 3 | 4 | /** 5 | * Express middleware for connect app events 6 | * 7 | * Retrieves installation using clientKey and adds it to the res.locals 8 | * 9 | * @param req Request 10 | * @param res Response 11 | * @param next Next function 12 | */ 13 | export const extractInstallationFromJiraCallback = async (req: Request, res: Response, next: NextFunction) => { 14 | if (!req.body?.clientKey) { 15 | res.status(401); 16 | return; 17 | } 18 | 19 | const installation = await Installation.getForClientKey(req.body.clientKey); 20 | if (!installation) { 21 | res.status(404); 22 | return; 23 | } 24 | 25 | const { jiraHost, clientKey } = installation; 26 | 27 | req.addLogFields({ 28 | jiraHost, 29 | jiraClientKey: `${clientKey.substr(0, 5)}***}` 30 | }); 31 | res.locals.installation = installation; 32 | next(); 33 | }; 34 | -------------------------------------------------------------------------------- /src/jira/util/id.test.ts: -------------------------------------------------------------------------------- 1 | import { getJiraId } from "./id"; 2 | 3 | describe("Jira ID", () => { 4 | it("should work", () => { 5 | expect(getJiraId("AP-3-large_push")).toEqual("AP-3-large_push"); 6 | expect(getJiraId("AP-3-large_push/foobar")).toEqual( 7 | "~41502d332d6c617267655f707573682f666f6f626172" 8 | ); 9 | expect(getJiraId("feature-something-cool")).toEqual( 10 | "feature-something-cool" 11 | ); 12 | expect(getJiraId("feature/something-cool")).toEqual( 13 | "~666561747572652f736f6d657468696e672d636f6f6c" 14 | ); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/jira/util/id.ts: -------------------------------------------------------------------------------- 1 | const validJiraId = /^[a-zA-Z0-9~.\-_]+$/; 2 | export const getJiraId = (name: string) => validJiraId.test(name) ? name : `~${Buffer.from(name).toString("hex")}`; 3 | 4 | -------------------------------------------------------------------------------- /src/jira/util/query-atlassian-connect-public-key.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const CONNECT_INSTALL_KEYS_CDN_URL = 4 | "https://connect-install-keys.atlassian.com"; 5 | const CONNECT_INSTALL_KEYS_CDN_URL_STAGING = 6 | "https://cs-migrations--cdn.us-west-1.staging.public.atl-paas.net"; 7 | 8 | 9 | /** 10 | * Queries the public key for the specified keyId 11 | */ 12 | export const queryAtlassianConnectPublicKey = async (keyId: string, isStagingTenant: boolean): Promise => { 13 | 14 | const keyServerUrl = !isStagingTenant 15 | ? CONNECT_INSTALL_KEYS_CDN_URL 16 | : CONNECT_INSTALL_KEYS_CDN_URL_STAGING; 17 | 18 | const result = await axios.get(`${keyServerUrl}/${keyId}`, { 19 | timeout: 10000 20 | }); 21 | 22 | if (result.status !== 200) { 23 | throw new Error(`Unable to get public key for keyId ${keyId}`); 24 | } 25 | 26 | return result.data; 27 | }; 28 | -------------------------------------------------------------------------------- /src/middleware/cookiesession-middleware.ts: -------------------------------------------------------------------------------- 1 | // setup route middlewares 2 | import cookieSession from "cookie-session"; 3 | import { envVars } from "config/env"; 4 | import { createHashWithSharedSecret } from "utils/encryption"; 5 | 6 | const THIRTY_DAYS_MSEC = 30 * 24 * 60 * 60 * 1000; 7 | 8 | // TODO: replace with encryption + Cryptor 9 | export const cookieSessionMiddleware = cookieSession({ 10 | keys: [envVars.COOKIE_SESSION_KEY, createHashWithSharedSecret(envVars.STORAGE_SECRET), envVars.GITHUB_CLIENT_SECRET], 11 | maxAge: THIRTY_DAYS_MSEC, 12 | signed: true, 13 | sameSite: "none", 14 | secure: true, 15 | httpOnly: true 16 | }); 17 | -------------------------------------------------------------------------------- /src/middleware/csrf-middleware.ts: -------------------------------------------------------------------------------- 1 | // setup route middlewares 2 | import csrf from "csurf"; 3 | import { isNodeTest } from "utils/is-node-env"; 4 | 5 | export const csrfMiddleware = csrf( 6 | isNodeTest() 7 | ? { ignoreMethods: ["GET", "HEAD", "OPTIONS", "POST", "PUT"] } 8 | : undefined 9 | ); 10 | -------------------------------------------------------------------------------- /src/models/sync-state.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "installationId": 12345678, 3 | "jiraHost": "tcbyrd.atlassian.net", 4 | "repos": { 5 | "12345678": { 6 | "name": "testrepo", 7 | "full_name": "tcbyrd/testrepo", 8 | "lastPullCursor": "=sdflkjwe0ifj092309cvniki2309u", 9 | "lastCommitCursor": "=sd9u23knxcovikjw039udlkj32029", 10 | "pullStatus": "complete", 11 | "commitStatus": "pending" 12 | }, 13 | "23456789": { 14 | "name": "tcbyrd.github.io", 15 | "full_name": "tcbyrd/tcbyrd.github.io", 16 | "lastPullCursor": "=sdflkjwe0ifj092310cvniki2309u", 17 | "lastCommitCursor": "=sd9u23knxcovikj4539udlkj32029", 18 | "pullStatus": "pending", 19 | "commitStatus": "pending" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/rest/helper/index.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from "express"; 2 | import { ParamsDictionary } from "express-serve-static-core"; 3 | import AsyncWrapper from "express-async-handler"; 4 | 5 | //too hard, don't want to type this 6 | /* eslint-disable @typescript-eslint/no-explicit-any */ 7 | export const errorWrapper = (name:string, handler: (...args: Parameters>) => Promise): RequestHandler => { 8 | const wrapper = AsyncWrapper(handler); 9 | Object.defineProperty(wrapper, "name", { get: () => name }); 10 | return wrapper; 11 | }; 12 | -------------------------------------------------------------------------------- /src/rest/middleware/jwt/github-token.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { errorWrapper } from "../../helper"; 3 | import { InvalidTokenError } from "config/errors"; 4 | 5 | export const GitHubTokenHandler = errorWrapper("GitHubTokenHandler", (req: Request, res: Response, next: NextFunction) => { 6 | const token = req.headers["github-auth"]; 7 | 8 | if (!token) { 9 | throw new InvalidTokenError("Github token invalid"); 10 | } 11 | 12 | res.locals.githubToken = token; 13 | next(); 14 | return Promise.resolve(); 15 | }); 16 | -------------------------------------------------------------------------------- /src/rest/routes/deferred/deferred-analytics-proxy.ts: -------------------------------------------------------------------------------- 1 | import { InvalidArgumentError } from "config/errors"; 2 | import { extractSubscriptionDeferredInstallPayload } from "services/subscription-deferred-install-service"; 3 | import { AnalyticsProxyHandler } from "~/src/rest/routes/analytics-proxy"; 4 | import { RequestHandler } from "express"; 5 | 6 | /** 7 | * This handler simply gets the `jiraHost` from the `requestId` 8 | * and re-uses the existing `AnalyticsProxyHandler` 9 | */ 10 | const DeferredAnalyticsProxy = (async (req, res, next) => { 11 | const requestId = req.params.requestId; 12 | if (!requestId) { 13 | req.log.warn("Missing requestId in query"); 14 | throw new InvalidArgumentError("Missing requestId in query"); 15 | } 16 | 17 | const { jiraHost } = await extractSubscriptionDeferredInstallPayload(requestId); 18 | res.locals.jiraHost = jiraHost; 19 | 20 | AnalyticsProxyHandler(req, res, next); 21 | }) as RequestHandler; 22 | 23 | export default DeferredAnalyticsProxy; 24 | -------------------------------------------------------------------------------- /src/rest/routes/deferred/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { DeferredCheckOwnershipAndConnectRoute } from "./deferred-check-ownership-and-connect"; 3 | import { JwtHandler } from "../../middleware/jwt/jwt-handler"; 4 | import { GitHubTokenHandler } from "~/src/rest/middleware/jwt/github-token"; 5 | 6 | import { DeferredInstallationUrlRoute } from "./deferred-installation-url"; 7 | import { DeferredRequestParseRoute } from "./deferred-request-parse"; 8 | import DeferredAnalyticsProxy from "~/src/rest/routes/deferred/deferred-analytics-proxy"; 9 | export const DeferredRouter = Router({ mergeParams: true }); 10 | 11 | DeferredRouter.post("/analytics-proxy/:requestId", DeferredAnalyticsProxy); 12 | 13 | DeferredRouter.get("/installation-url", JwtHandler, DeferredInstallationUrlRoute); 14 | 15 | DeferredRouter.post("/connect/:requestId", GitHubTokenHandler, DeferredCheckOwnershipAndConnectRoute); 16 | 17 | DeferredRouter.get("/parse/:requestId", GitHubTokenHandler, DeferredRequestParseRoute); 18 | -------------------------------------------------------------------------------- /src/rest/routes/enterprise/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { deleteEnterpriseServerHandler } from "./delete-ghe-server"; 3 | export { deleteEnterpriseAppHandler } from "./delete-ghe-app"; 4 | export const gheServerRouter = Router({ mergeParams: true }); 5 | 6 | gheServerRouter.delete("/", deleteEnterpriseServerHandler); 7 | -------------------------------------------------------------------------------- /src/rest/routes/github-apps/index.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from "express"; 2 | import { errorWrapper } from "../../helper"; 3 | import { createAppClient } from "~/src/util/get-github-client-config"; 4 | import { GetGitHubAppsUrlResponse } from "rest-interfaces"; 5 | import { BaseLocals } from ".."; 6 | 7 | export const GitHubAppsRoute = Router({ mergeParams: true }); 8 | 9 | GitHubAppsRoute.get("/new", errorWrapper("GetGitHubAppsUrl", async function GetGitHubAppsUrl(req: Request, res: Response) { 10 | const { jiraHost } = res.locals; 11 | const log = req.log.child({ jiraHost }); 12 | const gitHubAppClient = await createAppClient(log, jiraHost, undefined, { trigger: "github-apps-url-get" }); 13 | const { data } = await gitHubAppClient.getApp(); 14 | const appInstallationUrl = `${data.html_url}/installations/new?state=spa`; 15 | res.status(200).json({ 16 | appInstallationUrl 17 | }); 18 | })); 19 | -------------------------------------------------------------------------------- /src/rest/routes/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Installation } from "~/src/models/installation"; 2 | 3 | export interface BaseLocals extends Record { 4 | gitHubAppConfig: { 5 | gitHubAppId: number | undefined; 6 | appId: string; 7 | uuid: string | undefined; 8 | hostname: string; 9 | clientId: string; 10 | }; 11 | installation: Installation; 12 | jiraHost: string; 13 | githubToken: string; 14 | gitHubAppId: number; 15 | accountId?: string; 16 | } -------------------------------------------------------------------------------- /src/rest/routes/jira/index.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from "express"; 2 | import { JiraCloudIDResponse } from "rest-interfaces"; 3 | import { JiraClient } from "models/jira-client"; 4 | import { errorWrapper } from "../../helper"; 5 | import { Installation } from "~/src/models/installation"; 6 | 7 | export const JiraCloudIDRouter = Router({ mergeParams: true }); 8 | 9 | JiraCloudIDRouter.get("/", errorWrapper("JiraCloudIDGet", async function JiraCloudIDGet(req: Request, res: Response) { 10 | 11 | const installation = res.locals["installation"] as Installation; 12 | 13 | const jiraClient = await JiraClient.getNewClient(installation, req.log); 14 | const { cloudId } = await jiraClient.getCloudId(); 15 | res.status(200).json({ cloudId }); 16 | 17 | })); 18 | 19 | -------------------------------------------------------------------------------- /src/routes/api/api-hash-post.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { createHashWithSharedSecret } from "~/src/util/encryption"; 3 | 4 | export const ApiHashPost = async (req: Request, res: Response): Promise => { 5 | 6 | const { data } = req.body; 7 | 8 | if (!data) { 9 | res.status(400) 10 | .json({ 11 | message: "Please provide a value to be hashed." 12 | }); 13 | return; 14 | } 15 | 16 | const hashedValue = createHashWithSharedSecret(data); 17 | 18 | res.json({ 19 | originalValue: data, 20 | hashedValue 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/api/api-ping-get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import axios from "axios"; 3 | 4 | /** 5 | * Makes a call to the URL passed into the "url" field of the body JSON. 6 | */ 7 | export const ApiPingGet = async (req: Request, res: Response): Promise => { 8 | 9 | const { data } = req.body; 10 | 11 | if (!data || !data.url) { 12 | res.status(400) 13 | .json({ 14 | message: "Please provide a JSON object with the field 'url'." 15 | }); 16 | return; 17 | } 18 | 19 | try { 20 | const pingResponse = await axios.get(data.url); 21 | res.json({ 22 | url: data.url, 23 | method: "GET", 24 | statusCode: pingResponse.status, 25 | statusText: pingResponse.statusText 26 | }); 27 | } catch (err: unknown) { 28 | res.json({ 29 | url: data.url, 30 | method: "GET", 31 | error: err 32 | }); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/routes/api/audit-log/audit-log-api-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { ApiAuditLogGetBySubscriptionId } from "./audit-log-get-by-sub-id"; 3 | import { param, query } from "express-validator"; 4 | import { returnOnValidationError } from "../api-utils"; 5 | 6 | export const AuditLogApiRouter = Router({ mergeParams: true }); 7 | 8 | AuditLogApiRouter.get("/subscription/:subscriptionId", 9 | param("subscriptionId").isInt(), 10 | query("entityType").isString(), 11 | query("entityId").isString(), 12 | query("issueKey").isString(), 13 | returnOnValidationError, 14 | ApiAuditLogGetBySubscriptionId 15 | ); 16 | -------------------------------------------------------------------------------- /src/routes/api/audit-log/audit-log-get-by-sub-id.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { Subscription } from "models/subscription"; 3 | import { getAuditLog, AuditInfoPK } from "services/audit-log-service"; 4 | 5 | export const ApiAuditLogGetBySubscriptionId = async (req: Request, res: Response): Promise => { 6 | 7 | const { subscriptionId } = req.params; 8 | const { issueKey, entityType, entityId } = req.query; 9 | 10 | const subscription = await Subscription.findByPk(subscriptionId); 11 | 12 | if (subscription === null) { 13 | throw new Error("Cannot find subscription by id " + subscriptionId); 14 | } 15 | 16 | const auditInfo: AuditInfoPK = { 17 | subscriptionId: Number(subscriptionId), 18 | issueKey: String(issueKey), 19 | entityType: String(entityType), 20 | entityId: String(entityId) 21 | }; 22 | 23 | const result = await getAuditLog(auditInfo, req.log); 24 | 25 | res.status(200).json(result); 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /src/routes/api/client-key/client-key-regex.test.ts: -------------------------------------------------------------------------------- 1 | import { extractClientKey } from "./client-key-regex"; 2 | 3 | const sampleUuid = "yyyyyyyy-0569-4f33-8413-cccccccc"; 4 | const sampleJiraSite = "sample-site.atlassian.net"; 5 | const getXml = (key: string) => { 6 | return ` 7 | 8 | ${key} 9 | JIRA 10 | 11 | ...SIb3DQEBAQUAA4GNADCBiQK... 12 | 13 | Atlassian JIRA at url 14 | 15 | `; 16 | }; 17 | 18 | const possibleJiraXmlInfo = [ 19 | [getXml(`jira:${sampleUuid}`), `jira:${sampleUuid}`], 20 | [getXml(`${sampleUuid}`), sampleUuid], 21 | [getXml(`${sampleJiraSite}`), sampleJiraSite], 22 | ["random-string", undefined] 23 | ]; 24 | 25 | describe("Extract client key from xml", ()=>{ 26 | it.each(possibleJiraXmlInfo)("should extract client key correctly", (xml, key)=>{ 27 | expect(extractClientKey(xml as string)).toBe(key); 28 | }); 29 | }); 30 | 31 | -------------------------------------------------------------------------------- /src/routes/api/client-key/client-key-regex.ts: -------------------------------------------------------------------------------- 1 | import { xml2json } from "xml2json-light"; 2 | export const extractClientKey = (text: string): string | undefined => { 3 | try { 4 | const json = xml2json(text, { object: true }); 5 | return json?.consumer?.key; 6 | } catch (e: unknown) { 7 | return undefined; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/routes/api/configuration/api-configuration-get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getLogger } from "config/logger"; 3 | import { getConfiguredAppProperties } from "utils/app-properties-utils"; 4 | import { Subscription } from "models/subscription"; 5 | 6 | /** 7 | * Makes a request to Jira to get jiraHosts is-configured app property 8 | */ 9 | export const ApiConfigurationGet = async (req: Request, res: Response): Promise => { 10 | 11 | const logger = getLogger("api-configured-get"); 12 | const installationId = req.params.installationId as unknown as number; 13 | 14 | if (!installationId) { 15 | req.log.warn("no installationId"); 16 | res.sendStatus(400); 17 | return; 18 | } 19 | 20 | const subscription = await Subscription.findOneForGitHubInstallationId(installationId, undefined); 21 | if (!subscription) { 22 | req.log.warn("no subscription"); 23 | res.sendStatus(404); 24 | return; 25 | } 26 | 27 | const { jiraHost } = subscription; 28 | const response = await getConfiguredAppProperties(jiraHost, logger); 29 | const configStatus = response?.data; 30 | res.status(200); 31 | res.send({ configStatus }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/routes/api/configuration/api-configuration-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { ApiConfigurationGet } from "routes/api/configuration/api-configuration-get"; 3 | import { ApiConfigurationPost } from "routes/api/configuration/api-configuration-post"; 4 | 5 | export const ApiConfigurationRouter = Router({ mergeParams: true }); 6 | 7 | ApiConfigurationRouter.get("/:installationId", ApiConfigurationGet); 8 | ApiConfigurationRouter.post("/", ApiConfigurationPost); 9 | -------------------------------------------------------------------------------- /src/routes/api/data-cleanup/data-cleanup-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { RepoSyncStateCleanUpOrphanDataPost } from "./cleanup-reposyncstates"; 3 | 4 | export const DataCleanupRouter = Router(); 5 | DataCleanupRouter.delete("/repo-sync-states", RepoSyncStateCleanUpOrphanDataPost); 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/routes/api/db-migrations/db-migration-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { DBMigrationUp } from "./db-migration-up"; 3 | import { DBMigrationDown } from "./db-migration-down"; 4 | 5 | export const DBMigrationsRouter = Router(); 6 | DBMigrationsRouter.post("/up", DBMigrationUp); 7 | DBMigrationsRouter.post("/down", DBMigrationDown); 8 | 9 | -------------------------------------------------------------------------------- /src/routes/api/ghes-app-verification/ghes-app-verification-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { GHESVerifyGetApps } from "./ghes-app-verify-get-apps"; 3 | 4 | export const GHESVerificationRouter = Router({ mergeParams: true }); 5 | GHESVerificationRouter.post("/verify-get-apps", GHESVerifyGetApps); 6 | 7 | -------------------------------------------------------------------------------- /src/routes/api/installation/api-installation-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { param } from "express-validator"; 3 | import { returnOnValidationError } from "../api-utils"; 4 | import { ApiInstallationDelete } from "./api-installation-delete"; 5 | import { ApiInstallationSyncstateGet } from "./api-installation-syncstate-get"; 6 | import { ApiInstallationSyncPost } from "./api-installation-sync-post"; 7 | import { ApiInstallationGet } from "./api-installation-get"; 8 | 9 | export const ApiInstallationRouter = Router({ mergeParams: true }); 10 | const subRouter = Router({ mergeParams: true }); 11 | ApiInstallationRouter.use(`(/githubapp/:gitHubAppId(\\d+))?`, subRouter); 12 | 13 | subRouter.post( 14 | "/sync", 15 | ApiInstallationSyncPost 16 | ); 17 | 18 | subRouter.get( 19 | "/", 20 | ApiInstallationGet 21 | ); 22 | 23 | subRouter.get( 24 | "/:jiraHost/syncstate", 25 | param("jiraHost").isString(), 26 | returnOnValidationError, 27 | ApiInstallationSyncstateGet 28 | ); 29 | 30 | subRouter.delete( 31 | "/:jiraHost", 32 | param("jiraHost").isString(), 33 | returnOnValidationError, 34 | ApiInstallationDelete 35 | ); 36 | -------------------------------------------------------------------------------- /src/routes/api/jira/api-jira-get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { WhereOptions } from "sequelize"; 3 | import { Installation } from "models/installation"; 4 | import { serializeJiraInstallation } from "../api-utils"; 5 | 6 | export const ApiJiraGet = async (req: Request, res: Response): Promise => { 7 | const where: WhereOptions = req.params.clientKeyOrJiraHost.startsWith("http") 8 | ? { jiraHost: req.params.clientKeyOrJiraHost } 9 | : { clientKey: req.params.clientKeyOrJiraHost }; 10 | const jiraInstallations = await Installation.findAll({ where }); 11 | if (!jiraInstallations.length) { 12 | res.sendStatus(404); 13 | return; 14 | } 15 | res.json(jiraInstallations.map((jiraInstallation) => 16 | serializeJiraInstallation(jiraInstallation, req.log) 17 | )); 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/api/jira/api-jira-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { oneOf, param } from "express-validator"; 3 | import { returnOnValidationError } from "../api-utils"; 4 | import { ApiJiraGet } from "./api-jira-get"; 5 | import { ApiJiraUninstallPost } from "./api-jira-uninstall-post"; 6 | import { ApiJiraVerifyPost } from "./api-jira-verify-post"; 7 | 8 | export const ApiJiraRouter = Router(); 9 | 10 | ApiJiraRouter.post( 11 | "/:installationId/verify", 12 | param("installationId").isInt(), 13 | returnOnValidationError, 14 | ApiJiraVerifyPost 15 | ); 16 | 17 | ApiJiraRouter.post( 18 | "/:clientKey/uninstall", 19 | param("clientKey").isHexadecimal(), 20 | returnOnValidationError, 21 | ApiJiraUninstallPost 22 | ); 23 | 24 | ApiJiraRouter.get( 25 | "/:clientKeyOrJiraHost", 26 | [ 27 | oneOf([ 28 | param("clientKeyOrJiraHost").isURL(), 29 | param("clientKeyOrJiraHost").isHexadecimal() 30 | ]), 31 | returnOnValidationError 32 | ], 33 | ApiJiraGet 34 | ); 35 | -------------------------------------------------------------------------------- /src/routes/api/jira/api-jira-verify-post.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { Installation } from "models/installation"; 3 | import { verifyJiraInstallation } from "~/src/jira/verify-installation"; 4 | 5 | export const ApiJiraVerifyPost = async (req: Request, res: Response): Promise => { 6 | const { installationId } = req.params; 7 | try { 8 | const installation = await Installation.findByPk(Number(installationId)); 9 | if (!installation) { 10 | req.log.error({ installationId }, "Installation doesn't exist"); 11 | res.status(500).send("Installation doesn't exist"); 12 | return; 13 | } 14 | const isValid = await verifyJiraInstallation(installation, req.log)(); 15 | res.json({ 16 | message: isValid ? "Verification successful" : "Verification failed", 17 | installation: { 18 | enabled: isValid, 19 | id: installation.id, 20 | jiraHost: installation.jiraHost 21 | } 22 | }); 23 | } catch (err: unknown) { 24 | req.log.error({ installationId, err }, "Error getting installation"); 25 | res.status(500).json(err); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/routes/github/branch/github-branch-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { GithubBranchGet } from "routes/github/branch/github-branch-get"; 3 | 4 | export const GithubBranchRouter = Router(); 5 | 6 | GithubBranchRouter.get("/owner/:owner/repo/:repo/:ref", GithubBranchGet); 7 | -------------------------------------------------------------------------------- /src/routes/github/configuration/github-configuration-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { GithubConfigurationGet } from "./github-configuration-get"; 3 | import { GithubConfigurationPost } from "./github-configuration-post"; 4 | import { GithubConfigurationAppInstallsGet } from "routes/github/configuration/github-configuration-app-installs-get"; 5 | 6 | export const GithubConfigurationRouter = Router(); 7 | GithubConfigurationRouter.route("/") 8 | .get(GithubConfigurationGet) 9 | .post(GithubConfigurationPost); 10 | 11 | GithubConfigurationRouter.route("/app-installations") 12 | .get(GithubConfigurationAppInstallsGet); 13 | -------------------------------------------------------------------------------- /src/routes/github/create-branch/github-create-branch-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { GithubCreateBranchGet } from "routes/github/create-branch/github-create-branch-get"; 3 | import { GithubCreateBranchPost } from "routes/github/create-branch/github-create-branch-post"; 4 | import { GithubBranchesGet } from "~/src/routes/github/create-branch/github-branches-get"; 5 | import { GithubRemoveSession } from "~/src/routes/github/create-branch/github-remove-session"; 6 | 7 | export const GithubCreateBranchRouter = Router(); 8 | 9 | GithubCreateBranchRouter.route("/") 10 | .get(GithubCreateBranchGet) 11 | .post(GithubCreateBranchPost); 12 | 13 | // TODO - move to /github/branch directory 14 | GithubCreateBranchRouter.get("/owners/:owner/repos/:repo/branches", GithubBranchesGet); 15 | 16 | GithubCreateBranchRouter.get("/change-github-login", GithubRemoveSession); 17 | -------------------------------------------------------------------------------- /src/routes/github/create-branch/github-remove-session.test.ts: -------------------------------------------------------------------------------- 1 | import { GithubRemoveSession } from "~/src/routes/github/create-branch/github-remove-session"; 2 | 3 | describe("GitHub Remove Session", () => { 4 | 5 | let req, res; 6 | beforeEach(async () => { 7 | 8 | req = { 9 | session: { 10 | githubToken: "abc-token" 11 | } 12 | }; 13 | 14 | res = { 15 | sendStatus: jest.fn(), 16 | send: jest.fn(), 17 | locals: { 18 | gitHubAppConfig: {} 19 | } 20 | }; 21 | }); 22 | 23 | it.each(["gitHubAppConfig"])("Should 401 when missing required fields", (attribute) => { 24 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 25 | delete res.locals[attribute]; 26 | GithubRemoveSession(req, res); 27 | expect(res.sendStatus).toHaveBeenCalledWith(401); 28 | }); 29 | 30 | it("Should remove githubToken from session", () => { 31 | GithubRemoveSession(req, res); 32 | expect(req.session.githubToken).toBeUndefined(); 33 | }); 34 | 35 | }); -------------------------------------------------------------------------------- /src/routes/github/create-branch/github-remove-session.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | 4 | export const GithubRemoveSession = (req: Request, res: Response) => { 5 | const { gitHubAppConfig } = res.locals; 6 | 7 | if (!gitHubAppConfig) { 8 | res.sendStatus(401); 9 | return; 10 | } 11 | 12 | req.session.githubToken = undefined; 13 | req.session.githubRefreshToken = undefined; 14 | res.send({ baseUrl: gitHubAppConfig.hostname }); 15 | 16 | }; -------------------------------------------------------------------------------- /src/routes/github/manifest/github-manifest-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { GithubManifestCompleteGet } from "~/src/routes/github/manifest/github-manifest-complete-get"; 3 | import { GithubManifestGet } from "routes/github/manifest/github-manifest-get"; 4 | 5 | export const GithubManifestRouter = Router(); 6 | 7 | GithubManifestRouter.route("/complete/:uuid") 8 | .get(GithubManifestCompleteGet); 9 | 10 | GithubManifestRouter.route("/:uuid") 11 | .get(GithubManifestGet); 12 | -------------------------------------------------------------------------------- /src/routes/github/repository/github-repository-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { GitHubRepositoryGet } from "routes/github/repository/github-repository-get"; 3 | 4 | export const GithubRepositoryRouter = Router(); 5 | 6 | GithubRepositoryRouter.route("/") 7 | .get(GitHubRepositoryGet); 8 | -------------------------------------------------------------------------------- /src/routes/github/setup/github-setup-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { GithubSetupGet } from "./github-setup-get"; 3 | import { GithubSetupPost } from "./github-setup-post"; 4 | import { query } from "express-validator"; 5 | import { returnOnValidationError } from "routes/api/api-utils"; 6 | 7 | export const GithubSetupRouter = Router(); 8 | 9 | GithubSetupRouter.route("/") 10 | .get(query("installation_id").isInt(), returnOnValidationError, GithubSetupGet) 11 | .post(GithubSetupPost); 12 | 13 | -------------------------------------------------------------------------------- /src/routes/github/subscription/github-subscription-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { GithubSubscriptionDelete } from "./github-subscription-delete"; 3 | import { param } from "express-validator"; 4 | import { returnOnValidationError } from "../../api/api-utils"; 5 | 6 | export const GithubSubscriptionRouter = Router(); 7 | 8 | GithubSubscriptionRouter.route("/:installationId") 9 | .all(param("installationId").isInt(), returnOnValidationError) 10 | .delete(GithubSubscriptionDelete); 11 | -------------------------------------------------------------------------------- /src/routes/github/webhook/webhook-context.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | import Logger from "bunyan"; 3 | import { GitHubAppConfig } from "~/src/sqs/sqs.types"; 4 | 5 | type WebhookContextConstructorParam = { 6 | id: string; 7 | name: string; 8 | payload: E; 9 | log: Logger; 10 | action?: string; 11 | gitHubAppConfig: GitHubAppConfig; 12 | } 13 | 14 | export class WebhookContext { 15 | id: string; 16 | name: string; 17 | payload: E; 18 | log: Logger; 19 | action?: string; 20 | sentry?: Sentry.Hub; 21 | timedout?: number; 22 | webhookReceived?: number; 23 | 24 | gitHubAppConfig: GitHubAppConfig; 25 | 26 | constructor({ id, name, payload, log, action, gitHubAppConfig }: WebhookContextConstructorParam) { 27 | this.id = id; 28 | this.name = name; 29 | this.payload = payload; 30 | this.log = log; 31 | this.action = action; 32 | this.gitHubAppConfig = gitHubAppConfig; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/routes/healthcheck/healthcheck-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { DeepcheckGet } from "./deepcheck-get"; 3 | import { HealthcheckGetPost } from "./healthcheck-get-post"; 4 | 5 | export const HealthcheckRouter = Router(); 6 | 7 | /** 8 | * /deepcheck endpoint to checks to see that all our connections are OK 9 | * 10 | * It's a race between the setTimeout and our ping + authenticate. 11 | */ 12 | HealthcheckRouter.get("/deepcheck", DeepcheckGet); 13 | 14 | /** 15 | * healthcheck endpoint to check that the app started properly 16 | */ 17 | HealthcheckRouter.get("/healthcheck", HealthcheckGetPost); 18 | // To troubleshoot connectivity between GHEs and the app 19 | HealthcheckRouter.post("/healthcheck/:uuid", HealthcheckGetPost); 20 | -------------------------------------------------------------------------------- /src/routes/jira/configuration/jira-configuration-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { csrfMiddleware } from "middleware/csrf-middleware"; 3 | import { JiraGet } from "routes/jira/jira-get"; 4 | import { JiraDelete } from "routes/jira/jira-delete"; 5 | import { jiraSymmetricJwtMiddleware } from "~/src/middleware/jira-symmetric-jwt-middleware"; 6 | import { jiraAdminPermissionsMiddleware } from "middleware/jira-admin-permission-middleware"; 7 | 8 | export const JiraConfigurationRouter = Router(); 9 | 10 | JiraConfigurationRouter.route("/") 11 | .get(csrfMiddleware, jiraSymmetricJwtMiddleware, jiraAdminPermissionsMiddleware, JiraGet) 12 | .delete(jiraSymmetricJwtMiddleware, jiraAdminPermissionsMiddleware, JiraDelete); 13 | -------------------------------------------------------------------------------- /src/routes/jira/connect/jira-connect-get.test.ts: -------------------------------------------------------------------------------- 1 | import { JiraConnectGet } from "routes/jira/connect/jira-connect-get"; 2 | 3 | describe("GET /jira/connect", () => { 4 | const mockRequest = (): any => ({ 5 | log: { 6 | info: jest.fn(), 7 | warn: jest.fn(), 8 | error: jest.fn(), 9 | debug: jest.fn() 10 | } 11 | }); 12 | 13 | const mockResponse = (): any => ({ 14 | locals: { 15 | jiraHost 16 | }, 17 | render: jest.fn().mockReturnValue({}), 18 | status: jest.fn().mockReturnValue({}), 19 | send: jest.fn().mockReturnValue({}) 20 | }); 21 | 22 | it("Get Jira Configuration", async () => { 23 | const response = mockResponse(); 24 | await JiraConnectGet(mockRequest(), response, jest.fn()); 25 | expect(response.render.mock.calls[0][0]).toBe("jira-select-github-product.hbs"); 26 | }); 27 | }); -------------------------------------------------------------------------------- /src/routes/jira/connect/jira-connect-get.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { sendAnalytics } from "utils/analytics-client"; 3 | import { AnalyticsEventTypes, AnalyticsScreenEventsEnum } from "interfaces/common"; 4 | import { errorStringFromUnknown } from "~/src/util/error-string-from-unknown"; 5 | 6 | export const JiraConnectGet = async ( 7 | req: Request, 8 | res: Response, 9 | next: NextFunction 10 | ): Promise => { 11 | try { 12 | req.log.info("Received Jira Connect page request"); 13 | 14 | await sendAnalytics(res.locals.jiraHost, AnalyticsEventTypes.ScreenEvent, { 15 | name: AnalyticsScreenEventsEnum.SelectGitHubProductEventName 16 | }, { 17 | jiraHost: res.locals.jiraHost 18 | }); 19 | 20 | res.render("jira-select-github-product.hbs"); 21 | 22 | req.log.info("Jira Connect page rendered successfully."); 23 | } catch (error: unknown) { 24 | next(new Error(`Failed to render Jira Connect page: ${errorStringFromUnknown(error)}`)); return; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/routes/jira/connect/jira-connect-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { csrfMiddleware } from "middleware/csrf-middleware"; 3 | import { JiraConnectGet } from "./jira-connect-get"; 4 | import { JiraConnectEnterpriseRouter } from "./enterprise/jira-connect-enterprise-router"; 5 | import { jiraSymmetricJwtMiddleware } from "~/src/middleware/jira-symmetric-jwt-middleware"; 6 | import { jiraAdminPermissionsMiddleware } from "middleware/jira-admin-permission-middleware"; 7 | 8 | export const JiraConnectRouter = Router(); 9 | 10 | JiraConnectRouter.route("/") 11 | .get(csrfMiddleware, jiraSymmetricJwtMiddleware, jiraAdminPermissionsMiddleware, JiraConnectGet); 12 | 13 | JiraConnectRouter.use("/enterprise", JiraConnectEnterpriseRouter); 14 | -------------------------------------------------------------------------------- /src/routes/jira/events/jira-events-install-post.ts: -------------------------------------------------------------------------------- 1 | import { Installation } from "models/installation"; 2 | import { Request, Response } from "express"; 3 | import { statsd } from "config/statsd"; 4 | import { metricHttpRequest } from "config/metric-names"; 5 | 6 | /** 7 | * Handle the install webhook from Jira 8 | */ 9 | export const JiraEventsInstallPost = async (req: Request, res: Response): Promise => { 10 | 11 | const { baseUrl: host, clientKey, sharedSecret } = req.body; 12 | req.log.info({ jiraHost: host, clientKey }, "Received installation payload"); 13 | 14 | await Installation.install({ 15 | host, 16 | clientKey, 17 | sharedSecret 18 | }); 19 | 20 | req.log.info({ jiraHost: host, clientKey }, "Installed installation"); 21 | 22 | statsd.increment(metricHttpRequest.install, {}, { jiraHost: host }); 23 | 24 | res.sendStatus(204); 25 | }; 26 | -------------------------------------------------------------------------------- /src/routes/jira/events/jira-events-uninstall-post.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from "models/subscription"; 2 | import { Request, Response } from "express"; 3 | import { statsd } from "config/statsd"; 4 | import { metricHttpRequest } from "config/metric-names"; 5 | import { JiraClient } from "models/jira-client"; 6 | 7 | /** 8 | * Handle the uninstall webhook from Jira 9 | */ 10 | export const JiraEventsUninstallPost = async (req: Request, res: Response): Promise => { 11 | const { installation } = res.locals; 12 | const subscriptions = await Subscription.getAllForHost(installation.jiraHost); 13 | 14 | if (subscriptions) { 15 | await Promise.all(subscriptions.map((sub) => sub.uninstall())); 16 | } 17 | 18 | statsd.increment(metricHttpRequest.uninstall, {}, { jiraHost: installation.jiraHost }); 19 | 20 | const jiraClient = await JiraClient.getNewClient(installation, req.log); 21 | 22 | try { 23 | await jiraClient.appPropertiesDelete(); 24 | } catch (err: unknown) { 25 | req.log.warn({ err }, "Cannot delete properties"); 26 | } 27 | await installation.uninstall(); 28 | 29 | req.log.info("App uninstalled on Jira."); 30 | res.sendStatus(204); 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/jira/security/workspaces/containers/jira-security-workspaces-containers-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | JiraSecurityWorkspacesContainersPost 4 | } from "~/src/routes/jira/security/workspaces/containers/jira-security-workspaces-containers-post"; 5 | import { JiraSecurityWorkspacesContainersSearchGet } from "./jira-security-workspaces-containers-search-get"; 6 | 7 | export const JiraSecurityWorkspacesContainersRouter = Router(); 8 | 9 | JiraSecurityWorkspacesContainersRouter.route("/containers") 10 | .post(JiraSecurityWorkspacesContainersPost); 11 | 12 | JiraSecurityWorkspacesContainersRouter.route("/containers/search") 13 | .get(JiraSecurityWorkspacesContainersSearchGet); 14 | -------------------------------------------------------------------------------- /src/routes/jira/security/workspaces/containers/jira-security-workspaces-containers.types.ts: -------------------------------------------------------------------------------- 1 | export interface SecurityContainer { 2 | id: string, 3 | name: string, 4 | url: string, 5 | avatarUrl: string, 6 | lastUpdatedDate: Date 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/jira/security/workspaces/jira-security-workspaces-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { JiraSecurityWorkspacesContainersRouter } from "~/src/routes/jira/security/workspaces/containers/jira-security-workspaces-containers-router"; 3 | import { JiraSecurityWorkspacesPost } from "~/src/routes/jira/security/workspaces/jira-security-workspaces-post"; 4 | 5 | export const JiraSecurityWorkspacesRouter = Router(); 6 | 7 | JiraSecurityWorkspacesRouter.use( 8 | "/workspaces", 9 | JiraSecurityWorkspacesContainersRouter 10 | ); 11 | 12 | JiraSecurityWorkspacesRouter.route("/workspaces").post( 13 | JiraSecurityWorkspacesPost 14 | ); 15 | -------------------------------------------------------------------------------- /src/routes/jira/workspaces/jira-workspaces-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { JiraWorkspacesGet } from "routes/jira/workspaces/jira-workspaces-get"; 3 | import { 4 | JiraWorkspacesRepositoriesRouter 5 | } from "routes/jira/workspaces/repositories/jira-workspaces-repositories-router"; 6 | 7 | export const JiraWorkspacesRouter = Router(); 8 | 9 | JiraWorkspacesRouter.route("/search") 10 | .get(JiraWorkspacesGet); 11 | 12 | JiraWorkspacesRouter.use("/repositories", JiraWorkspacesRepositoriesRouter); 13 | -------------------------------------------------------------------------------- /src/routes/jira/workspaces/repositories/jira-workspaces-repositories-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | JiraWorkspacesRepositoriesAssociate 4 | } from "routes/jira/workspaces/repositories/jira-workspaces-repositories-associate"; 5 | import { JiraWorkspacesRepositoriesGet } from "routes/jira/workspaces/repositories/jira-workspaces-repositories-get"; 6 | 7 | export const JiraWorkspacesRepositoriesRouter = Router(); 8 | 9 | JiraWorkspacesRepositoriesRouter.route("/search") 10 | .get(JiraWorkspacesRepositoriesGet); 11 | 12 | JiraWorkspacesRepositoriesRouter.route("/associate") 13 | .post(JiraWorkspacesRepositoriesAssociate); 14 | -------------------------------------------------------------------------------- /src/routes/maintenance/maintenance-get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | export const MaintenanceGet = (_: Request, res: Response) => { 4 | // Best HTTP status code for maintenance mode: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#5xx_server_errors 5 | res.status(503).render("maintenance.hbs", { 6 | title: "GitHub for Jira - Under Maintenance", 7 | APP_URL: process.env.APP_URL, 8 | nonce: res.locals.nonce 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/routes/maintenance/maintenance-router.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, Router } from "express"; 2 | import { booleanFlag, BooleanFlags } from "config/feature-flags"; 3 | import { MaintenanceGet } from "./maintenance-get"; 4 | 5 | export const MaintenanceRouter = Router(); 6 | const ignoredPaths = [ 7 | "/jira/atlassian-connect.json", 8 | "/jira/events/installed", 9 | "/jira/events/uninstalled" 10 | ]; 11 | 12 | const maintenanceMiddleware = async (req: Request, res: Response, next: NextFunction) => { 13 | if (!ignoredPaths.includes(req.path) && await booleanFlag(BooleanFlags.MAINTENANCE_MODE, res.locals.jiraHost)) { 14 | MaintenanceGet(req, res); return; 15 | } 16 | next(); 17 | }; 18 | MaintenanceRouter.use(maintenanceMiddleware); 19 | 20 | MaintenanceRouter.get("/maintenance", MaintenanceGet); 21 | -------------------------------------------------------------------------------- /src/routes/microscope/microscope-dlq-router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | deleteMessage, deleteMessages, 4 | microscopeDlqHealthcheck, queryQueueAttributes, queryQueueMessages, queryQueues, requeueMessage, requeueMessages 5 | } from "routes/microscope/operations/microscope-dlq-operations"; 6 | 7 | export const MicroscopeDlqRouter = Router(); 8 | 9 | MicroscopeDlqRouter.get("/healthcheck", microscopeDlqHealthcheck); 10 | MicroscopeDlqRouter.get("/queues", queryQueues); 11 | MicroscopeDlqRouter.post("/attributes", queryQueueAttributes); 12 | MicroscopeDlqRouter.get("/queue/:queueName/messages", queryQueueMessages); 13 | MicroscopeDlqRouter.post("/queue/:queueName/message", requeueMessage); 14 | MicroscopeDlqRouter.delete("/queue/:queueName/message", deleteMessage); 15 | MicroscopeDlqRouter.post("/queue/:queueName/requeue", requeueMessages); 16 | MicroscopeDlqRouter.delete("/queue/:queueName/messages", deleteMessages); 17 | -------------------------------------------------------------------------------- /src/routes/public/public-router.ts: -------------------------------------------------------------------------------- 1 | import { Router, static as Static } from "express"; 2 | import path from "path"; 3 | 4 | export const PublicRouter = Router(); 5 | 6 | const rootPath = process.cwd(); 7 | PublicRouter.use("/", Static(path.join(rootPath, "static"))); 8 | PublicRouter.use("/css-reset", Static(path.join(rootPath, "node_modules/@atlaskit/css-reset/dist"))); 9 | PublicRouter.use("/primer", Static(path.join(rootPath, "node_modules/primer/build"))); 10 | PublicRouter.use("/atlassian-ui-kit", Static(path.join(rootPath, "node_modules/@atlaskit/reduced-ui-pack/dist"))); 11 | PublicRouter.use("/aui", Static(path.join(rootPath, "node_modules/@atlassian/aui/dist/aui"))); 12 | -------------------------------------------------------------------------------- /src/routes/version/version-get.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { envVars } from "config/env"; 3 | 4 | export const VersionGet = (_: Request, res: Response) => { 5 | res.send({ 6 | branch: envVars.GIT_BRANCH_NAME, 7 | branchUrl: `${envVars.GITHUB_REPO_URL}/tree/${envVars.GIT_BRANCH_NAME ?? "undefined"}`, 8 | commit: envVars.GIT_COMMIT_SHA, 9 | commitDate: envVars.GIT_COMMIT_DATE, 10 | commitUrl: `${envVars.GITHUB_REPO_URL}/commit/${envVars.GIT_COMMIT_SHA ?? "undefined"}`, 11 | deploymentDate: envVars.DEPLOYMENT_DATE 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/services/github/user.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from "@octokit/rest"; 2 | import { GitHubInstallationClient } from "../../github/client/github-installation-client"; 3 | import Logger from "bunyan"; 4 | 5 | // TODO: Remove this method on featureFlag cleanup 6 | export const getGithubUser = async (gitHubInstallationClient: GitHubInstallationClient, username: string, logger: Logger): Promise => { 7 | if (!username) { 8 | return undefined; 9 | } 10 | 11 | try { 12 | const response = await gitHubInstallationClient.getUserByUsername(username); 13 | return response.data; 14 | } catch (err: unknown) { 15 | logger.warn({ err, username }, "Cannot retrieve user from Github REST API"); 16 | } 17 | return undefined; 18 | }; 19 | -------------------------------------------------------------------------------- /src/spa-proxy.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import httpProxy from "http-proxy"; 3 | import { isNodeDev } from "utils/is-node-env"; 4 | 5 | const SPA_PATH = "/spa"; 6 | 7 | const proxy = httpProxy.createProxyServer({ 8 | target: { 9 | host: "127.0.0.1", 10 | port: 3000, 11 | path: SPA_PATH 12 | }, 13 | ws: false 14 | }); 15 | 16 | export const proxyLocalUIForDev = (app: Express) => { 17 | if (isNodeDev()) { 18 | app.use(SPA_PATH, (req, res) => { proxy.web(req, res); }); 19 | } 20 | }; 21 | 22 | -------------------------------------------------------------------------------- /src/transforms/transform-repository.ts: -------------------------------------------------------------------------------- 1 | import { transformRepositoryId } from "./transform-repository-id"; 2 | import { BulkSubmitRepositoryInfo } from "interfaces/jira"; 3 | 4 | interface Repository { 5 | id: number; 6 | full_name: string; 7 | html_url: string; 8 | } 9 | 10 | 11 | /** 12 | * @param repository 13 | * @param gitHubBaseUrl - can be undefined for Cloud 14 | */ 15 | export const transformRepositoryDevInfoBulk = (repository: Repository, gitHubBaseUrl: string | undefined): BulkSubmitRepositoryInfo => { 16 | return { 17 | id: transformRepositoryId(repository.id, gitHubBaseUrl), 18 | name: repository.full_name, 19 | url: repository.html_url, 20 | updateSequenceId: Date.now() 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/transforms/util/pull-request-link-generator.ts: -------------------------------------------------------------------------------- 1 | import { jiraIssueKeyParser } from "utils/jira-utils"; 2 | 3 | /** 4 | * Generates a create pull request link for GH in the format [URL]/compare/[name]?title=[...keys]-[name] 5 | * This format allows the use of title query param to set the PR name 6 | * Max length: 2000 7 | */ 8 | export const generateCreatePullRequestUrl = (baseUrl: string, branchName: string, issueKeys: string[] = []) => { 9 | const branchKeys = jiraIssueKeyParser(branchName); 10 | 11 | let keys = ""; 12 | if (!branchKeys.length) { 13 | keys = issueKeys?.length ? issueKeys[0] : ""; 14 | } 15 | 16 | const title = encodeURIComponent(keys != "" ? keys + "-" + branchName : branchName); 17 | const branch = encodeURIComponent(branchName); 18 | const url = `${baseUrl}/compare/${branch}?title=${title}&quick_pull=1`; 19 | 20 | // Jira API has a 2000 character limit for createPullRequestUrl field. 21 | if (url.length > 2000) { 22 | return `${baseUrl}/compare/${branch}`; 23 | } 24 | 25 | return url; 26 | }; 27 | -------------------------------------------------------------------------------- /src/util/api-key-validator.ts: -------------------------------------------------------------------------------- 1 | import { canBeUsedAsApiKeyHeader } from "utils/http-headers"; 2 | 3 | export const validateApiKeyInputsAndReturnErrorIfAny = (apiKeyHeaderName: string | undefined, apiKeyValue: string | undefined) => { 4 | if (apiKeyHeaderName) { 5 | let error: string | undefined = undefined; 6 | if (!apiKeyValue) { 7 | error = "apiKeyHeaderName was provided but apiKeyValue was empty"; 8 | } 9 | if (!canBeUsedAsApiKeyHeader(apiKeyHeaderName)) { 10 | error = "Provided apiKeyHeaderName cannot be used as API key header"; 11 | } 12 | if (apiKeyHeaderName.length > 1024) { 13 | error = "apiKeyHeaderName max length is 1024"; 14 | } 15 | if (apiKeyValue && apiKeyValue.length > 8096) { 16 | error = "apiKeyValue max length is 8096"; 17 | } 18 | return error; 19 | } 20 | if (apiKeyValue && !apiKeyHeaderName) { 21 | return "cannot use apiKeyValue without apiKeyHeaderName"; 22 | } 23 | return undefined; 24 | }; 25 | -------------------------------------------------------------------------------- /src/util/create-url-with-query-string.test.ts: -------------------------------------------------------------------------------- 1 | import { createUrlWithQueryString } from "./create-url-with-query-string"; 2 | 3 | describe("check-and-add-query-string", () => { 4 | it(`Should return URL with all the query parameters`, async () => { 5 | const request = { 6 | query: { 7 | son: "goku", 8 | uzumaki: "naruto", 9 | ketchum: "ash" 10 | } 11 | } as any; 12 | const URL = "http://myrandomSite.com"; 13 | const resultUrl = createUrlWithQueryString(request, URL); 14 | 15 | expect(resultUrl).toBe(`${URL}?son=goku&uzumaki=naruto&ketchum=ash`); 16 | }); 17 | 18 | it(`Should return just URL for empty query`, async () => { 19 | const request = { query: {} } as any; 20 | const URL = "http://myrandomSite.com"; 21 | const resultUrl = createUrlWithQueryString(request, URL); 22 | 23 | expect(resultUrl).toBe(`${URL}`); 24 | }); 25 | 26 | it(`Should return just URL for null query`, async () => { 27 | const request = {} as any; 28 | const URL = "http://myrandomSite.com"; 29 | const resultUrl = createUrlWithQueryString(request, URL); 30 | 31 | expect(resultUrl).toBe(`${URL}`); 32 | }); 33 | }); 34 | 35 | -------------------------------------------------------------------------------- /src/util/create-url-with-query-string.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | 3 | /** 4 | * This method creates a URL with queryParameters that are available within the request 5 | * It will add all the available query parameters as queryString within the URL 6 | * 7 | * @param req 8 | * @param URL 9 | */ 10 | export const createUrlWithQueryString = (req: Request, URL: string): string => { 11 | let queryString = ""; 12 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 13 | const keys = req.query ? Object.keys(req.query) : []; 14 | const queryStrings = keys.reduce((_, current, index, array) => { 15 | if (req.query[current]) { 16 | queryString += index === 0 ? "?" : ""; 17 | queryString += current + "="; 18 | queryString += String(req.query[current]); 19 | queryString += index !== array.length - 1 ? "&" : ""; 20 | } 21 | return queryString; 22 | }, ""); 23 | 24 | return URL + queryStrings; 25 | }; 26 | -------------------------------------------------------------------------------- /src/util/encryption-client.test.ts: -------------------------------------------------------------------------------- 1 | import { EncryptionClient, EncryptionSecretKeyEnum } from "utils/encryption-client"; 2 | 3 | describe("encryption-client", () => { 4 | 5 | it("should hit the docker mock implentation and success", async () => { 6 | const encrypted = await EncryptionClient.encrypt(EncryptionSecretKeyEnum.GITHUB_SERVER_APP, "some-text"); 7 | expect(encrypted).toBe("encrypted:some-text"); 8 | const decrypted = await EncryptionClient.decrypt("encrypted:some-text"); 9 | expect(decrypted).toBe("some-text"); 10 | }); 11 | 12 | }); 13 | -------------------------------------------------------------------------------- /src/util/encryption.ts: -------------------------------------------------------------------------------- 1 | import { createHmac, createHash } from "crypto"; 2 | import { envVars } from "../config/env"; 3 | 4 | export const createHashWithSharedSecret = (data?: string): string => { 5 | if (!data) { 6 | return ""; 7 | } 8 | const cleanedData = removeNonAlphaNumericCharacters(data); 9 | return createHmac("sha256", envVars.GLOBAL_HASH_SECRET) 10 | .update(cleanedData) 11 | .digest("hex"); 12 | }; 13 | 14 | export const createHashWithoutSharedSecret = (data: string | null | undefined) => { 15 | if (!data) { 16 | return ""; 17 | } 18 | return createHash("sha256").update(data).digest("hex"); 19 | 20 | }; 21 | 22 | const removeNonAlphaNumericCharacters = (str: string): string => { 23 | return str.replace(/[^\p{L}\p{N}]+/ug, ""); // Tests all unicode characters and only keeps Letters and Numbers 24 | }; 25 | -------------------------------------------------------------------------------- /src/util/env-utils.ts: -------------------------------------------------------------------------------- 1 | import { envVars } from "config/env"; 2 | 3 | // Check to see if all required environment variables are set 4 | export const envCheck = (...requiredEnvVars: string[]) => { 5 | const missingVars = requiredEnvVars.filter(key => envVars[key] === undefined); 6 | if (missingVars.length) { 7 | throw new Error(`Missing required Environment Variables: ${missingVars.join(", ")}`); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/util/error-string-from-unknown.ts: -------------------------------------------------------------------------------- 1 | export const errorStringFromUnknown = (e : unknown) : string => { 2 | return e instanceof Error ? e.toString() : "unkown"; 3 | }; 4 | -------------------------------------------------------------------------------- /src/util/get-cloud-or-server.ts: -------------------------------------------------------------------------------- 1 | import { GITHUB_CLOUD_API_BASEURL } from "~/src/github/client/github-client-constants"; 2 | 3 | export const getCloudOrServerFromGitHubAppId = (gitHubAppId: number | undefined) => gitHubAppId ? "server" : "cloud"; 4 | export const getCloudOrServerFromHost = (host: string) => GITHUB_CLOUD_API_BASEURL.includes(host) ? "cloud" : "server"; 5 | -------------------------------------------------------------------------------- /src/util/github-sync-helper.ts: -------------------------------------------------------------------------------- 1 | import { Subscription, SyncStatus } from "models/subscription"; 2 | import { TaskType, SyncType } from "~/src/sync/sync.types"; 3 | 4 | 5 | const MILLISECONDS_IN_ONE_DAY = 24 * 60 * 60 * 1000; 6 | export const getStartTimeInDaysAgo = (commitsFromDate: Date | undefined) => { 7 | if (commitsFromDate === undefined) return undefined; 8 | return Math.floor((Date.now() - commitsFromDate.getTime()) / MILLISECONDS_IN_ONE_DAY); 9 | }; 10 | 11 | type SyncTypeAndTargetTasks = { 12 | syncType: SyncType, 13 | targetTasks: TaskType[] | undefined, 14 | }; 15 | 16 | export const determineSyncTypeAndTargetTasks = (syncTypeFromReq: string, subscription: Subscription): SyncTypeAndTargetTasks => { 17 | if (syncTypeFromReq === "full") { 18 | return { syncType: "full", targetTasks: undefined }; 19 | } 20 | 21 | if (subscription.syncStatus === SyncStatus.FAILED) { 22 | return { syncType: "full", targetTasks: undefined }; 23 | } 24 | 25 | return { syncType: "partial", targetTasks: ["pull", "branch", "commit", "build", "deployment", "dependabotAlert", "secretScanningAlert", "codeScanningAlert"] }; 26 | }; -------------------------------------------------------------------------------- /src/util/handlebars/handlebar-partials.ts: -------------------------------------------------------------------------------- 1 | import hbs from "hbs"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | 5 | export const registerHandlebarsPartials = (partialPath: string) => { 6 | fs.readdirSync(partialPath) 7 | .filter(file => file.endsWith(".hbs")) 8 | .forEach(file => { 9 | hbs.registerPartial( 10 | file.replace(/\.hbs$/, ""), // removes extension of file 11 | fs.readFileSync(path.resolve(partialPath, file), { 12 | encoding: "utf8" 13 | }) 14 | ); 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/util/healthcheck-stopper.test.ts: -------------------------------------------------------------------------------- 1 | import { isHealthcheckStopped, stopHealthcheck } from "utils/healthcheck-stopper"; 2 | 3 | describe("healthcheck-stopper", () => { 4 | it("allows healthchecks by default", () => { 5 | expect(isHealthcheckStopped()).toBeFalsy(); 6 | }); 7 | 8 | it("prohibits healthchecks after stop is called", () => { 9 | stopHealthcheck(); 10 | expect(isHealthcheckStopped()).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/util/healthcheck-stopper.ts: -------------------------------------------------------------------------------- 1 | // Temp utility functions while we are investigating why worker node.js processes die. 2 | 3 | let stopped = false; 4 | export const stopHealthcheck = () => { 5 | stopped = true; 6 | }; 7 | 8 | export const isHealthcheckStopped = () => { 9 | return stopped; 10 | }; 11 | -------------------------------------------------------------------------------- /src/util/is-connected.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from "models/subscription"; 2 | 3 | export const isConnected = async (jiraHost: string): Promise => { 4 | const subscriptions = await Subscription.getAllForHost(jiraHost); 5 | 6 | return subscriptions.length > 0; 7 | }; 8 | -------------------------------------------------------------------------------- /src/util/is-node-env.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentEnum } from "interfaces/common"; 2 | 3 | export const getNodeEnv: () => EnvironmentEnum = () => EnvironmentEnum[process.env.NODE_ENV || ""] as EnvironmentEnum | undefined || EnvironmentEnum.development; 4 | export const isNodeEnv = (env: EnvironmentEnum) => getNodeEnv() === env; 5 | export const isNodeProd = () => isNodeEnv(EnvironmentEnum.production); 6 | export const isNodeDev = () => isNodeEnv(EnvironmentEnum.development); 7 | export const isNodeTest = () => isNodeEnv(EnvironmentEnum.test); 8 | -------------------------------------------------------------------------------- /src/util/jira-issue-check-redis-util.ts: -------------------------------------------------------------------------------- 1 | import IORedis from "ioredis"; 2 | import { getRedisInfo } from "config/redis-info"; 3 | import { numberFlag, NumberFlags } from "config/feature-flags"; 4 | 5 | //five seconds 6 | const REDIS_CLEANUP_TIMEOUT = 5 * 1000; 7 | 8 | const redis = new IORedis(getRedisInfo("JiraIssueStatusStorage")); 9 | 10 | export const saveIssueStatusToRedis = async ( 11 | jiraHost: string, 12 | issueKey: string, 13 | status: "exist" | "not_exist" 14 | ) => { 15 | const key = getKey(jiraHost, issueKey); 16 | const timeout = await numberFlag(NumberFlags.SKIP_PROCESS_QUEUE_IF_ISSUE_NOT_FOUND_TIMEOUT, REDIS_CLEANUP_TIMEOUT, jiraHost); 17 | await redis.set(key, status, "px", timeout); 18 | }; 19 | 20 | export const getIssueStatusFromRedis = async ( 21 | jiraHost: string, 22 | issueKey: string 23 | ): Promise<"exist" | "not_exist" | null> => { 24 | const key = getKey(jiraHost, issueKey); 25 | const status = await redis.get(key); 26 | return status as "exist" | "not_exist" | null; 27 | }; 28 | 29 | const getKey = (jiraHost: string, issueKey: string) => { 30 | return `jiraHost_${jiraHost}_issueKey_${issueKey}`; 31 | }; 32 | -------------------------------------------------------------------------------- /src/util/log-sampled.test.ts: -------------------------------------------------------------------------------- 1 | import Logger from "bunyan"; 2 | import { logInfoSampled } from "utils/log-sampled"; 3 | describe("log-sampled", () => { 4 | it("logSampledInfo should log during the very first call", () => { 5 | const logger = { 6 | info: jest.fn() 7 | } as unknown as Logger; 8 | logInfoSampled(logger, "foo", "hello", 10); 9 | expect(logger.info).toBeCalledWith({ sampled: true }, "hello (sampled)"); 10 | }); 11 | 12 | it("logSampledInfo should log sampled messages", () => { 13 | const logger = { 14 | info: jest.fn() 15 | } as unknown as Logger; 16 | for (let callNo = 0; callNo < 100; callNo++) { 17 | logInfoSampled(logger, "foo", "hello", 10); 18 | } 19 | expect(logger.info).toBeCalledTimes(10); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/util/log-sampled.ts: -------------------------------------------------------------------------------- 1 | import Logger from "bunyan"; 2 | 3 | const counters: Record = {}; 4 | export const logInfoSampled = (logger: Logger, key: string, msg: string, sampledRate: number) => { 5 | let cnt = counters[key] || 0; 6 | if (cnt === 0) { 7 | logger.info({ sampled: true }, msg + " (sampled)"); 8 | } 9 | cnt ++; 10 | if (cnt >= sampledRate) { 11 | cnt = 0; 12 | } 13 | counters[key] = cnt; 14 | }; 15 | -------------------------------------------------------------------------------- /src/util/match-route-with-pattern.ts: -------------------------------------------------------------------------------- 1 | import matchstick from "matchstick"; 2 | import { isEmpty } from "lodash"; 3 | 4 | /** 5 | * This method checks if the `route` matches the `pattern` 6 | * It ignores all the query strings in both `route` and `pattern` 7 | * 8 | * Source: https://github.com/edj-boston/matchstick 9 | * 10 | */ 11 | export const matchRouteWithPattern = (pattern: string, route: string): boolean => { 12 | if (isEmpty(pattern) || isEmpty(route)) { 13 | return false; 14 | } 15 | pattern = pattern.replace(/ac\./g, ""); // Remove all the `ac.` 16 | pattern = pattern.split("?")[0]; // Removing the query parameters 17 | route = route.split("?")[0]; // Removing the query parameters 18 | 19 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call 20 | return matchstick(pattern, "template").match(route); 21 | }; 22 | -------------------------------------------------------------------------------- /src/util/not-empty.ts: -------------------------------------------------------------------------------- 1 | const notEmpty = ( 2 | value: TValue | null | undefined 3 | ): value is TValue => { 4 | return value !== null && value !== undefined; 5 | }; 6 | 7 | export default notEmpty; 8 | -------------------------------------------------------------------------------- /src/util/paginate-response.ts: -------------------------------------------------------------------------------- 1 | import { Workspace } from "../routes/jira/workspaces/jira-workspaces-get"; 2 | 3 | export const paginatedResponse = (page: number, limit: number, payload: Workspace[]) => { 4 | const startIndex = (page - 1) * limit; 5 | const endIndex = page * limit; 6 | return payload.slice(startIndex, endIndex); 7 | }; 8 | -------------------------------------------------------------------------------- /src/util/regex.ts: -------------------------------------------------------------------------------- 1 | export const UUID_REGEX = "[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}"; -------------------------------------------------------------------------------- /src/util/validate-url.ts: -------------------------------------------------------------------------------- 1 | interface UrlValidationResult { 2 | isValidUrl: boolean, 3 | reason?: string 4 | } 5 | 6 | // See https://stash.atlassian.com/projects/EDGE/repos/edge-proxies/browse/sceptre/data/squid_outbound-proxy.conf.jinja2#43 7 | const ALLOWED_PORTS = [80, 8080, 443, 6017, 8443, 8444, 7990, 8090, 8085, 8060, 8900, 9900]; 8 | 9 | export const validateUrl = (url: string): UrlValidationResult => { 10 | try { 11 | const { protocol, port } = new URL(url); 12 | if (port && !ALLOWED_PORTS.includes(parseInt(port))) { 13 | return { 14 | isValidUrl: false, 15 | reason: "only the following ports are allowed: " + ALLOWED_PORTS.join(", ") 16 | }; 17 | } 18 | if (!(/^https?:$/.test(protocol))) { 19 | return { 20 | isValidUrl: false, 21 | reason: "unsupported protocol, only HTTP and HTTPS are allowed" 22 | }; 23 | } 24 | if (url.includes("?")) { 25 | return { 26 | isValidUrl: false, 27 | reason: "query parameters are not allowed" 28 | }; 29 | } 30 | } catch (err: unknown) { 31 | return { 32 | isValidUrl: false 33 | }; 34 | } 35 | return { 36 | isValidUrl: true 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/util/validations.ts: -------------------------------------------------------------------------------- 1 | // TODO: what is this regex actually checking? that it matches an alphanumeric 2 | // with dashes and underscore string that's at least length of 1 up to 62? 3 | const domainRegexp = /^https:\/\/\w(?:[\w-]{0,61}\w)?\.(atlassian\.net|jira\.com)$/; 4 | 5 | export const validJiraDomain = (url: string): boolean => domainRegexp.test(url); 6 | 7 | -------------------------------------------------------------------------------- /src/util/webhook-timeout.ts: -------------------------------------------------------------------------------- 1 | import { WebhookContext } from "../routes/github/webhook/webhook-context"; 2 | 3 | const DEFAULT_TIMEOUT = Number(process.env.REQUEST_TIMEOUT_MS) || 25000; 4 | 5 | export const webhookTimeout = (callback: (context: WebhookContext) => Promise, timeout = DEFAULT_TIMEOUT) => 6 | async (context: WebhookContext): Promise => { 7 | const timestamp = Date.now(); 8 | const id = setTimeout(() => context.timedout = Date.now() - timestamp, timeout); 9 | try { 10 | await callback(context); 11 | } finally { 12 | clearTimeout(id); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/worker/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from "express"; 2 | import { HealthcheckRouter } from "routes/healthcheck/healthcheck-router"; 3 | 4 | export const createWorkerServerApp = (): Express => { 5 | const app = express(); 6 | // We are always behind a proxy, but we want the source IP 7 | app.set("trust proxy", true); 8 | app.use(HealthcheckRouter); 9 | return app; 10 | }; 11 | -------------------------------------------------------------------------------- /src/worker/startup.ts: -------------------------------------------------------------------------------- 1 | import { sqsQueues } from "../sqs/queues"; 2 | import { getLogger } from "config/logger"; 3 | import { statsd } from "config/statsd"; 4 | import { metricLag } from "config/metric-names"; 5 | import createLag from "event-loop-lag"; 6 | 7 | const logger = getLogger("worker"); 8 | 9 | let running = false; 10 | 11 | export const start = () => { 12 | if (running) { 13 | logger.debug("Worker instance already running, skipping."); 14 | return; 15 | } 16 | const lag = createLag(1000); 17 | setInterval(() => { 18 | statsd.histogram(metricLag.lagHist, lag(), { }, { }); 19 | }, 1000); 20 | 21 | logger.info("Micros Lifecycle: Starting queue processing"); 22 | sqsQueues.start(); 23 | 24 | running = true; 25 | }; 26 | 27 | export const stop = async () => { 28 | if (!running) { 29 | logger.debug("Worker instance not running, skipping."); 30 | return; 31 | } 32 | logger.info("Micros Lifecycle: Stopping queue processing"); 33 | 34 | await sqsQueues.stop(); 35 | 36 | running = false; 37 | }; 38 | -------------------------------------------------------------------------------- /static/assets/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/static/assets/Icon.png -------------------------------------------------------------------------------- /static/assets/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/assets/edit-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/static/assets/edit-icon.png -------------------------------------------------------------------------------- /static/assets/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/static/assets/error.png -------------------------------------------------------------------------------- /static/assets/jira-and-github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/static/assets/jira-and-github.png -------------------------------------------------------------------------------- /static/assets/jira-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /static/assets/jira-software-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/static/assets/jira-software-logo.png -------------------------------------------------------------------------------- /static/assets/question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /static/css/error.css: -------------------------------------------------------------------------------- 1 | .error-container { 2 | align-items: center; 3 | display: flex; 4 | flex-direction: column; 5 | height: 100%; 6 | justify-content: center; 7 | margin: auto; 8 | max-width: 570px; 9 | } 10 | 11 | .error-errorImg { 12 | margin-bottom: 2em; 13 | width: 11.5em; 14 | } 15 | 16 | .error-container > .error-header { 17 | font-size: 1.3rem; 18 | } 19 | 20 | .error-message { 21 | margin: 1em auto; 22 | text-align: center; 23 | } 24 | 25 | a { 26 | color: #0052cc; 27 | } 28 | 29 | a:hover { 30 | text-decoration: none; 31 | } 32 | -------------------------------------------------------------------------------- /static/css/jira-select-github-cloud-app.css: -------------------------------------------------------------------------------- 1 | .jiraSelectGitHubCloudApp { 2 | text-align: center; 3 | height: 100%; 4 | } 5 | 6 | .jiraSelectGitHubCloudApp__content { 7 | margin: 10% auto 0; 8 | } -------------------------------------------------------------------------------- /static/css/jira-select-server.css: -------------------------------------------------------------------------------- 1 | .jiraSelectServer { 2 | text-align: center; 3 | height: 100%; 4 | } 5 | 6 | .jiraSelectServer__content { 7 | margin: 10% auto 0; 8 | } -------------------------------------------------------------------------------- /static/css/jira-server-url.css: -------------------------------------------------------------------------------- 1 | .jiraServerUrl__content { 2 | margin: 4em auto 0; 3 | max-width: 560px; 4 | } 5 | .jiraServerUrl__content__title { 6 | text-align: center; 7 | margin-bottom: 2em; 8 | } 9 | 10 | .jiraServerUrl__options__subTitle { 11 | font-size: 1.2rem; 12 | margin-bottom: 0.5em; 13 | display: inline-flex; 14 | } 15 | 16 | .jiraServerUrl__prompt { 17 | color: #344563; 18 | font-size: 1.01rem; 19 | } 20 | 21 | .jiraServerUrl__section { 22 | margin-top: 1.2em; 23 | } 24 | 25 | .jiraServerUrl h3 { 26 | display: block; 27 | } 28 | 29 | .jiraServerUrl h3 small{ 30 | vertical-align: bottom; 31 | margin-top: 0px; 32 | } 33 | 34 | .jiraServerUrl__actionBtn { 35 | margin-top: 1.25em; 36 | float: right; 37 | } 38 | 39 | #jiraServerUrl__form input { 40 | height: 40px; 41 | } 42 | 43 | #gheServerBtnSpinner { 44 | display: none; 45 | color: #fff; 46 | } 47 | 48 | .jiraServerUrl__validationError { 49 | display: none; 50 | } 51 | -------------------------------------------------------------------------------- /static/css/loading-screen.css: -------------------------------------------------------------------------------- 1 | .loadingScreen___container { 2 | align-items: center; 3 | display: flex; 4 | flex-direction: column; 5 | height: 100%; 6 | justify-content: center; 7 | margin: auto; 8 | max-width: 648px; 9 | } 10 | 11 | .loadingScreen___header { 12 | font-size: 1.5rem; 13 | } 14 | 15 | .loadingScreen___imgDivider { 16 | font-size: 2.8rem; 17 | color: #6B778C; 18 | } 19 | 20 | .loadingScreen___imgContainer { 21 | margin: 4.6em 0 4.1em; 22 | display: flex; 23 | justify-content: space-evenly; 24 | align-items: center; 25 | min-width: 375px; 26 | } 27 | 28 | .loadingScreen___jiraImg { 29 | height: 80px; 30 | } 31 | 32 | .loadingScreen___GitHubImg { 33 | height: 60px; 34 | } 35 | 36 | .loadingScreen___message { 37 | text-align: center; 38 | max-width: 364px; 39 | color: #344563; 40 | font-size: 1rem; 41 | } 42 | 43 | /** 44 | CSS property for `session___body-session.hbs` 45 | */ 46 | .session___body { 47 | background: #fff; 48 | } 49 | -------------------------------------------------------------------------------- /static/css/no-configuration.css: -------------------------------------------------------------------------------- 1 | .noConfiguration__container { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 100%; 6 | } 7 | 8 | .noConfiguration__content { 9 | display: flex; 10 | } 11 | 12 | .noConfiguration__content p { 13 | max-width: 420px; 14 | font-size: 14px; 15 | color: #253858; 16 | margin: 16px 0; 17 | } 18 | 19 | .noConfiguration__content img { 20 | margin-left: 60px; 21 | } -------------------------------------------------------------------------------- /static/css/select-server-header.css: -------------------------------------------------------------------------------- 1 | .selectServer__title { 2 | margin-bottom: 2.5em; 3 | } 4 | 5 | .selectServer__headerImg { 6 | height: 2.5em; 7 | } 8 | 9 | .selectServer__header { 10 | font-size: 1.5rem; 11 | font-weight: 500; 12 | margin: 1em 0; 13 | } 14 | 15 | .selectServer__subHeader { 16 | font-size: 0.9rem; 17 | } -------------------------------------------------------------------------------- /static/css/select-table.css: -------------------------------------------------------------------------------- 1 | .selectTable__content { 2 | background-color: #F4F5F7; 3 | padding: 1.5em; 4 | width: 656px; 5 | margin: 0 auto; 6 | border-radius: 10px; 7 | } 8 | 9 | .selectTable__contentHead { 10 | display: flex; 11 | align-items: center; 12 | justify-content: space-between; 13 | padding: 0.5em; 14 | margin-bottom: 1.2em; 15 | } 16 | 17 | .selectTable__contentHeadTitle { 18 | display: flex; 19 | align-items: center; 20 | } 21 | 22 | .selectTable__addNew { 23 | color: #42526E; 24 | } 25 | 26 | .selectTable__contentImg { 27 | margin-right: 0.8em; 28 | height: 26px; 29 | } 30 | 31 | .selectTable__contentTitle { 32 | font-weight: 600; 33 | font-size: 1.1rem; 34 | } 35 | 36 | .selectTable__list { 37 | background-color: #fff; 38 | border-radius: 3px; 39 | box-shadow: 0 1px 1px rgba(9, 30, 66, 0.25), 0 0 1px rgba(9, 30, 66, 0.31); 40 | padding: 0.5em 0; 41 | } 42 | 43 | .selectTable__itemContainer { 44 | display: flex; 45 | padding: 0.5em 1.5em; 46 | align-items: center; 47 | justify-content: space-between; 48 | } -------------------------------------------------------------------------------- /static/css/server-error-message-box.css: -------------------------------------------------------------------------------- 1 | .errorMessageBox__container { 2 | display: flex; 3 | padding: 1em; 4 | margin: 1em 0 1.75em; 5 | border-radius: 3px; 6 | background-color: #FFEBE6; 7 | } 8 | 9 | .aui-iconfont-error { 10 | color: #BF2600; 11 | margin-top: 4px; 12 | } 13 | 14 | .errorMessageBox__content { 15 | margin-left: 1em; 16 | color: #42526E; 17 | } 18 | 19 | .errorMessageBox__title { 20 | font-weight: 600; 21 | } 22 | 23 | .errorMessageBox__message { 24 | margin: 0.5em 0; 25 | } -------------------------------------------------------------------------------- /static/jira-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/github-for-jira/bdfa61232a1df71e237ea7834844e538ee8e65b9/static/jira-logo.png -------------------------------------------------------------------------------- /static/js/jira-select-github-cloud-app.js: -------------------------------------------------------------------------------- 1 | const params = new URLSearchParams(window.location.search.substring(1)); 2 | const jiraHost = params.get("xdm_e"); 3 | 4 | function openChildWindow(url) { 5 | const child = window.open(url); 6 | const interval = setInterval(function () { 7 | if (child.closed) { 8 | clearInterval(interval); 9 | AP.navigator.reload(); 10 | } 11 | }, 1000); 12 | 13 | return child; 14 | } 15 | 16 | window.addEventListener("message", event => { 17 | if (event.origin === window.location.origin && event.data.moduleKey) { 18 | AP.navigator.go( 19 | 'addonmodule', 20 | { 21 | moduleKey: event.data.moduleKey 22 | } 23 | ); 24 | } 25 | }, false); 26 | 27 | $('.select-server').click(function (event) { 28 | event.preventDefault(); 29 | const uuid = $(this).data("identifier"); 30 | window.AP.context.getToken(function(token) { 31 | const child = openChildWindow(`/session/github/${uuid}/configuration?ghRedirect=to`); 32 | child.window.jwt = token; 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /static/js/jira-select-server.js: -------------------------------------------------------------------------------- 1 | $('.select-server').click(function (event) { 2 | event.preventDefault(); 3 | 4 | AP.navigator.go("addonmodule", { 5 | moduleKey: "github-list-server-apps-page", 6 | customData: { 7 | connectConfigUuid: $(event.target).data("identifier"), 8 | serverUrl: $(event.target).data("identifier") // TODO: remove when the descriptor is propagated everywhere, ~ in 1 month 9 | } 10 | }) 11 | }); 12 | -------------------------------------------------------------------------------- /static/js/navigation.js: -------------------------------------------------------------------------------- 1 | /* globals $, AP */ 2 | $(".go-back").click(function (event) { 3 | event.preventDefault(); 4 | 5 | if (AP && AP.history) { 6 | AP.history.back(); 7 | } else { 8 | history.back(); 9 | } 10 | }); 11 | 12 | $(".go-main-admin").click(function (event) { 13 | event.preventDefault(); 14 | 15 | AP.navigator.go( 16 | 'addonmodule', 17 | { 18 | moduleKey: "gh-addon-admin", 19 | } 20 | ); 21 | }); 22 | 23 | 24 | -------------------------------------------------------------------------------- /static/js/no-configuration.js: -------------------------------------------------------------------------------- 1 | function openChildWindow(url) { 2 | const child = window.open(url); 3 | const interval = setInterval(function () { 4 | if (child.closed) { 5 | clearInterval(interval); 6 | location.reload(); 7 | } 8 | }, 500); 9 | return child; 10 | } 11 | 12 | $("#noConfiguration__ConnectToGH").on("click", () => { 13 | const configurationUrl = $("#configurationUrl").val(); 14 | openChildWindow(configurationUrl); 15 | }); 16 | -------------------------------------------------------------------------------- /static/js/select-table.js: -------------------------------------------------------------------------------- 1 | $(".selectTable__addNew").click(function (event) { 2 | event.preventDefault(); 3 | const customData = $(event.target).data("qs-for-path"); 4 | 5 | AP.navigator.go("addonmodule", { 6 | moduleKey: $(event.target).data("path"), 7 | customData: customData || null 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /static/js/skeleton.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | 3 | setTimeout(function() { 4 | $('.skeleton-container').remove(); 5 | $('.skeleton-input').remove(); 6 | $('.skeleton').removeClass('skeleton'); 7 | $(".loaded").attr('style', 'display: block !important'); 8 | }, 500); 9 | }); 10 | -------------------------------------------------------------------------------- /static/octicons/mark-github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/e2e/app-installation.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { jiraAppInstall, jiraAppUninstall } from "test/e2e/utils/jira"; 3 | import { testData } from "test/e2e/constants"; 4 | import { githubAppInstall, githubAppUninstall } from "test/e2e/utils/github"; 5 | 6 | test.describe("App Installation", () => { 7 | test.describe("jira", () => { 8 | test.use({ 9 | storageState: testData.jira.roles.admin.state 10 | }); 11 | 12 | test("jiraAppUninstall", async ({ page }) => { 13 | expect(await jiraAppUninstall(page)).toBeTruthy(); 14 | }); 15 | 16 | test("jiraAppInstall", async ({ page }) => { 17 | expect(await jiraAppInstall(page)).toBeTruthy(); 18 | }); 19 | }); 20 | 21 | // Skipping because github isn't ready yet 22 | test.describe.skip("github", () => { 23 | test("githubAppInstall", async ({ page }) => { 24 | expect(await githubAppInstall(page)).toBeTruthy(); 25 | }); 26 | 27 | test("githubAppUninstall", async ({ page }) => { 28 | expect((await githubAppUninstall(page))).toBeTruthy(); 29 | }); 30 | }); 31 | }); 32 | 33 | -------------------------------------------------------------------------------- /test/e2e/create-project.e2e.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { jiraCreateProject, jiraRemoveProject } from "test/e2e/utils/jira"; 3 | import { createProjectId, testData } from "test/e2e/constants"; 4 | 5 | test.describe("Create project", () => { 6 | test.use({ 7 | storageState: testData.jira.roles.admin.state 8 | }); 9 | 10 | test("Can create project", async ({ page }) => { 11 | const projectId = createProjectId(); 12 | await jiraCreateProject(page, projectId); 13 | const response = await page.goto(testData.jira.urls.project(projectId)); 14 | expect(response).toBeTruthy(); 15 | expect(response?.status()).toBe(200); 16 | await jiraRemoveProject(page, projectId); 17 | }); 18 | }); 19 | 20 | -------------------------------------------------------------------------------- /test/e2e/e2e-utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { STATE_PATH, TestDataRole } from "test/e2e/constants"; 3 | 4 | export const clearState = () => { 5 | fs.existsSync(STATE_PATH) && fs.rmdirSync(STATE_PATH, { recursive: true }); 6 | }; 7 | 8 | export const stateExists = (role: TestDataRole): boolean => { 9 | if (role.state) { 10 | return fs.existsSync(role.state); 11 | } 12 | return true; 13 | }; 14 | -------------------------------------------------------------------------------- /test/e2e/env-e2e.ts: -------------------------------------------------------------------------------- 1 | import { envVars, EnvVars } from "config/env"; 2 | import { envCheck } from "utils/env-utils"; 3 | 4 | envCheck( 5 | "APP_NAME", 6 | "ATLASSIAN_URL", 7 | "JIRA_ADMIN_USERNAME", 8 | "JIRA_ADMIN_PASSWORD", 9 | "GITHUB_USERNAME", 10 | "GITHUB_PASSWORD", 11 | "GITHUB_URL" 12 | ); 13 | 14 | export interface E2EEnvVars extends EnvVars { 15 | APP_NAME: string; 16 | ATLASSIAN_URL: string; 17 | JIRA_ADMIN_USERNAME: string; 18 | JIRA_ADMIN_PASSWORD: string; 19 | GITHUB_USERNAME: string; 20 | GITHUB_PASSWORD: string; 21 | GITHUB_URL: string; 22 | GITHUB_ORG?: string; 23 | GITHUB_2FA_SECRET?: string; 24 | } 25 | 26 | export const e2eEnvVars = envVars as E2EEnvVars; 27 | -------------------------------------------------------------------------------- /test/e2e/login.e2e.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { jiraLogin } from "test/e2e/utils/jira"; 3 | import { githubLogin } from "test/e2e/utils/github"; 4 | import { testData } from "test/e2e/constants"; 5 | 6 | test.describe("Login", () => { 7 | for (const useState of [false, true]) { 8 | test.describe(useState ? "with state" : "without state", () => { 9 | test.describe("Jira", () => { 10 | if (useState) { 11 | test.use({ 12 | storageState: testData.jira.roles.admin.state 13 | }); 14 | } 15 | test("jiraLogin", async ({ page }) => { 16 | expect(await jiraLogin(page, "admin")).toBeTruthy(); 17 | }); 18 | }); 19 | 20 | // Skipping because github isn't ready yet 21 | test.describe.skip("Github", () => { 22 | if (useState) { 23 | test.use({ 24 | storageState: testData.github.roles.admin.state 25 | }); 26 | } 27 | test("githubLogin", async ({ page }) => { 28 | expect(await githubLogin(page, "admin")).toBeTruthy(); 29 | }); 30 | }); 31 | }); 32 | } 33 | }); 34 | 35 | -------------------------------------------------------------------------------- /test/e2e/setup.ts: -------------------------------------------------------------------------------- 1 | import { chromium } from "@playwright/test"; 2 | import { jiraAppInstall, jiraCreateProject, jiraLogin } from "test/e2e/utils/jira"; 3 | import { clearState, stateExists } from "test/e2e/e2e-utils"; 4 | import { testData } from "test/e2e/constants"; 5 | import { ngrokBypass } from "test/e2e/utils/ngrok"; 6 | 7 | export default async function setup() { 8 | // Remove old state before starting 9 | clearState(); 10 | 11 | const browser = await chromium.launch(); 12 | const page = await browser.newPage(); 13 | 14 | // login and save state before tests 15 | await ngrokBypass(page); 16 | await jiraLogin(page, "admin", true); 17 | 18 | // Create global project 19 | await jiraAppInstall(page); 20 | await jiraCreateProject(page, testData.projectId()); 21 | 22 | // Close the browser 23 | await browser.close(); 24 | 25 | // Check to make sure state exists before continuing 26 | if (!stateExists(testData.jira.roles.admin) || !stateExists(testData.github.roles.admin)) { 27 | throw new Error("Missing state"); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/e2e/teardown.ts: -------------------------------------------------------------------------------- 1 | import { chromium } from "@playwright/test"; 2 | import { jiraAppUninstall, jiraRemoveProject } from "test/e2e/utils/jira"; 3 | import { testData } from "test/e2e/constants"; 4 | 5 | export default async function teardown() { 6 | const browser = await chromium.launch(); 7 | const page = await browser.newPage({ storageState: testData.jira.roles.admin.state }); 8 | await jiraRemoveProject(page, testData.projectId()); 9 | // Uninstall the app 10 | await jiraAppUninstall(page); 11 | // Close the browser 12 | await browser.close(); 13 | } 14 | -------------------------------------------------------------------------------- /test/e2e/utils/ngrok.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | import { testData } from "test/e2e/constants"; 3 | 4 | export const ngrokBypass = async (page: Page): Promise => { 5 | await page.goto(`${testData.appUrl}/version`); 6 | const button = await page.waitForSelector("#ngrok button", { timeout: 5000 }).catch(() => undefined); 7 | if (button) { 8 | await page.click("#ngrok button"); 9 | await page.waitForLoadState(); 10 | await page.context().storageState({ path: testData.state }); 11 | } 12 | 13 | return page; 14 | }; 15 | -------------------------------------------------------------------------------- /test/fixtures/api/graphql/branch-empty-nodes.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "refs": { 5 | "edges": [] 6 | } 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /test/fixtures/api/graphql/branch-queries.ts: -------------------------------------------------------------------------------- 1 | import { getBranchesQueryWithChangedFiles } from "~/src/github/client/github-queries"; 2 | 3 | export const branchesNoLastCursor = (variables?: Record) => ({ 4 | query: getBranchesQueryWithChangedFiles, 5 | variables: { 6 | owner: "integrations", 7 | repo: "test-repo-name", 8 | per_page: 20, 9 | ...variables 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /test/fixtures/api/graphql/commit-empty-nodes.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "ref": { 5 | "target": { 6 | "history": { 7 | "edges": [] 8 | } 9 | } 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/api/graphql/commit-nodes-no-keys.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "defaultBranchRef": { 5 | "target": { 6 | "history": { 7 | "edges": [ 8 | { 9 | "cursor": "Y3Vyc29yOnYyOpK5MjAxsdlkwOC0yM1QxNzozODowNS0wNDowMM4MjT7J 99", 10 | "node": { 11 | "author": { 12 | "email": "test-author-email@example.com", 13 | "name": "test-author-name" 14 | }, 15 | "authoredDate": "test-authored-date", 16 | "message": "test-commit-message", 17 | "oid": "test-oid", 18 | "url": "https://github.com/test-login/test-repo/commit/test-sha" 19 | } 20 | } 21 | ] 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/api/graphql/commit-nodes.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "defaultBranchRef": { 5 | "target": { 6 | "history": { 7 | "edges": [ 8 | { 9 | "cursor": "Y3Vyc29yOnYyOpK5MjAxsdlkwOC0yM1QxNzozODowNS0wNDowMM4MjT7J 99", 10 | "node": { 11 | "author": { 12 | "email": "test-author-email@example.com", 13 | "name": "test-author-name" 14 | }, 15 | "authoredDate": "test-authored-date", 16 | "message": "[TES-17] test-commit-message", 17 | "oid": "test-oid", 18 | "url": "https://github.com/test-login/test-repo/commit/test-sha" 19 | } 20 | } 21 | ] 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/api/graphql/commit-queries.ts: -------------------------------------------------------------------------------- 1 | import { getCommitsQueryWithChangedFiles } from "~/src/github/client/github-queries"; 2 | 3 | export const commitsNoLastCursor = (variables) => ({ 4 | query: getCommitsQueryWithChangedFiles, 5 | variables 6 | }); 7 | 8 | export const commitsWithLastCursor = { 9 | query: getCommitsQueryWithChangedFiles, 10 | variables: { 11 | owner: "integrations", 12 | repo: "test-repo-name", 13 | per_page: 20, 14 | cursor: "Y3Vyc29yOnYyOpK5MjAxsdlkwOC0yM1QxNzozODowNS0wNDowMM4MjT7J 99", 15 | default_ref: "master" 16 | } 17 | }; 18 | 19 | const defaultBranchQuery = `query ($owner: String!, $repo: String!) { 20 | repository(owner: $owner, name: $repo) { 21 | defaultBranchRef { 22 | name 23 | } 24 | } 25 | }`; 26 | 27 | export const getDefaultBranch = { 28 | query: defaultBranchQuery, 29 | variables: { 30 | owner: "integrations", 31 | repo: "test-repo-name" 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /test/fixtures/api/graphql/default-branch-null.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | } 5 | } 6 | } -------------------------------------------------------------------------------- /test/fixtures/api/graphql/default-branch.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "defaultBranchRef": { 5 | "name": "master" 6 | } 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /test/fixtures/api/graphql/deployment-nodes-no-keys.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "defaultBranchRef": { 5 | "target": { 6 | "history": { 7 | "edges": [ 8 | { 9 | "cursor": "Y3Vyc29yOnYyOpK5MjAxsdlkwOC0yM1QxNzozODowNS0wNDowMM4MjT7J 99", 10 | "node": { 11 | "author": { 12 | "email": "test-author-email@example.com", 13 | "name": "test-author-name" 14 | }, 15 | "authoredDate": "test-authored-date", 16 | "message": "test-commit-message", 17 | "oid": "test-oid", 18 | "url": "https://github.com/test-login/test-repo/commit/test-sha" 19 | } 20 | } 21 | ] 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/api/graphql/pull-request-empty-nodes.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequests": { 5 | "edges": [] 6 | } 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /test/fixtures/api/graphql/repositories.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "viewer": { 4 | "repositories": { 5 | "totalCount": 2, 6 | "pageInfo": { 7 | "endCursor": "Y3Vyc29yOnYyOpK5MjAyMS0wOS0wNVQyMDozMTo1NSswMDowMM4wrst8", 8 | "hasNextPage": false 9 | }, 10 | "edges": [ 11 | { 12 | "node": { 13 | "id": 123456789, 14 | "name": "SampleRepo1", 15 | "full_name": "user1/SampleRepo1", 16 | "owner": { 17 | "login": "user1" 18 | }, 19 | "html_url": "https://github.com/user1/SampleRepo1", 20 | "updated_at": "2022-10-10T20:31:55Z" 21 | } 22 | }, 23 | { 24 | "node": { 25 | "id": 987654321, 26 | "name": "SampleRepo2", 27 | "full_name": "user2/SampleRepo2", 28 | "owner": { 29 | "login": "user2" 30 | }, 31 | "html_url": "https://github.com/user2/SampleRepo2", 32 | "updated_at": "2022-10-10T19:30:45Z" 33 | } 34 | } 35 | ] 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/fixtures/api/pull-request-reviewers-no-user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id":448221072, 4 | "node_id":"MDE3OlB1bGxSZXF1ZXN0UmV2aWV3NDQ4MjIxMDcy", 5 | "user":null, 6 | "body":"", 7 | "state":"APPROVED", 8 | "html_url":"https://github.com/bgvozdev/testing/pull/8605#pullrequestreview-448221072", 9 | "pull_request_url":"https://api.github.com/repos/bgvozdev/testing/pulls/8605", 10 | "author_association":"NONE", 11 | "_links": { 12 | "html": { 13 | "href":"https://github.com/bgvozdev/testing/pull/8605#pullrequestreview-448221072" 14 | }, 15 | "pull_request":{ 16 | "href":"https://api.github.com/repos/bgvozdev/testing/pulls/8605" 17 | } 18 | }, 19 | "submitted_at":"2020-07-14T15:31:49Z", 20 | "commit_id":"c5d81ac7d2a3b18a82a83f8bc04212345f082cf811" 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /test/fixtures/api/secret-scanning-alerts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "number": 12, 4 | "created_at": "2023-08-04T04:33:44Z", 5 | "updated_at": "2023-08-04T04:33:44Z", 6 | "url": "https://api.github.com/repos/test-owner/sample-repo/secret-scanning/alerts/12", 7 | "html_url": "https://github.com/test-owner/sample-repo/security/secret-scanning/12", 8 | "locations_url": "https://api.github.com/repos/test-owner/sample-repo/secret-scanning/alerts/12/locations", 9 | "state": "open", 10 | "secret_type": "github_personal_access_token", 11 | "secret_type_display_name": "GitHub Personal Access Token", 12 | "secret": "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", 13 | "resolution": null, 14 | "resolved_by": null, 15 | "resolved_at": null, 16 | "resolution_comment": null, 17 | "push_protection_bypassed": false, 18 | "push_protection_bypassed_by": null, 19 | "push_protection_bypassed_at": null 20 | } 21 | ] -------------------------------------------------------------------------------- /test/fixtures/api/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "login": "bkeepers", 3 | "name": "Bee Keepers", 4 | "id": 173, 5 | "node_id": "MDQ6VXNlcjE3Mw==", 6 | "avatar_url": "https://avatars0.githubusercontent.com/u/173?v=4", 7 | "gravatar_id": "", 8 | "url": "https://api.github.com/users/bkeepers", 9 | "html_url": "https://github.com/bkeepers", 10 | "followers_url": "https://api.github.com/users/bkeepers/followers", 11 | "following_url": "https://api.github.com/users/bkeepers/following{/other_user}", 12 | "gists_url": "https://api.github.com/users/bkeepers/gists{/gist_id}", 13 | "starred_url": "https://api.github.com/users/bkeepers/starred{/owner}{/repo}", 14 | "subscriptions_url": "https://api.github.com/users/bkeepers/subscriptions", 15 | "organizations_url": "https://api.github.com/users/bkeepers/orgs", 16 | "repos_url": "https://api.github.com/users/bkeepers/repos", 17 | "events_url": "https://api.github.com/users/bkeepers/events{/privacy}", 18 | "received_events_url": "https://api.github.com/users/bkeepers/received_events" 19 | } 20 | -------------------------------------------------------------------------------- /test/fixtures/branch-basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create", 3 | "payload": { 4 | "ref": "TES-123-test-ref", 5 | "ref_type": "branch", 6 | "master_branch": "master", 7 | "repository": { 8 | "id": "test-repo-id", 9 | "name": "test-repo-name", 10 | "full_name": "example/test-repo-name", 11 | "html_url": "test-repo-url", 12 | "owner": { 13 | "login": "test-repo-owner" 14 | } 15 | }, 16 | "sender": { 17 | "type": "User", 18 | "login": "TestUser" 19 | }, 20 | "installation": { 21 | "id": 1234 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/branch-delete.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delete", 3 | "payload": { 4 | "ref": "TES-123-test-ref", 5 | "ref_type": "branch", 6 | "master_branch": "master", 7 | "repository": { 8 | "id": "test-repo-id", 9 | "name": "test-repo-name", 10 | "full_name": "example/test-repo-name", 11 | "html_url": "test-repo-url", 12 | "owner": { 13 | "login": "test-repo-owner" 14 | } 15 | }, 16 | "sender": { 17 | "type": "User", 18 | "login": "TestUser" 19 | }, 20 | "installation": { 21 | "id": 1234 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/branch-invalid-ref_type.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create", 3 | "payload": { 4 | "ref": "tes-123", 5 | "ref_type": "not-branch", 6 | "master_branch": "master", 7 | "repository": { 8 | "id": "test-repo-id", 9 | "name": "test-repo-name", 10 | "full_name": "example/test-repo-name", 11 | "html_url": "test-repo-url", 12 | "owner": { 13 | "login": "test-repo-owner" 14 | } 15 | }, 16 | "sender": { 17 | "type": "User", 18 | "login": "TestUser" 19 | }, 20 | "installation": { 21 | "id": 1234 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/branch-no-issues.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create", 3 | "payload": { 4 | "ref": "no-issue-keys", 5 | "ref_type": "branch", 6 | "master_branch": "master", 7 | "repository": { 8 | "id": "test-repo-id", 9 | "name": "test-repo-name", 10 | "full_name": "example/test-repo-name", 11 | "html_url": "test-repo-url", 12 | "owner": { 13 | "login": "test-repo-owner" 14 | } 15 | }, 16 | "sender": { 17 | "type": "User", 18 | "login": "TestUser" 19 | }, 20 | "installation": { 21 | "id": 1234 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/get-repositories.json: -------------------------------------------------------------------------------- 1 | { 2 | "viewer": { 3 | "repositories": { 4 | "totalCount": 2, 5 | "pageInfo": { 6 | "endCursor": null, 7 | "hasNextPage": false 8 | }, 9 | "edges": [ 10 | { 11 | "node": { 12 | "id": 1296269, 13 | "name": "Hello-World", 14 | "full_name": "octocat/Hello-World", 15 | "owner": { 16 | "login": "octocat" 17 | }, 18 | "html_url": "https://github.com/octocat/Hello-World", 19 | "updated_at": "2011-01-26T19:14:43Z" 20 | } 21 | }, 22 | { 23 | "node": { 24 | "id": 1234567, 25 | "name": "Hello-World-2", 26 | "full_name": "octocat/Hello-World-2", 27 | "owner": { 28 | "login": "octocat" 29 | }, 30 | "html_url": "https://github.com/octocat/Hello-World", 31 | "updated_at": "2011-01-26T19:14:43Z" 32 | } 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/fixtures/issue-basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issues", 3 | "payload": { 4 | "action": "opened", 5 | "issue": { 6 | "id": "test-issue-id", 7 | "number": 123456789, 8 | "body": "Test example issue with linked Jira issue: [TEST-123]" 9 | }, 10 | "repository": { 11 | "name": "test-repo-name", 12 | "html_url": "test-repo-url", 13 | "owner": { 14 | "login": "test-repo-owner" 15 | } 16 | }, 17 | "sender": { 18 | "type": "User", 19 | "login": "TestUser" 20 | }, 21 | "installation": { 22 | "id": 1234 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/issue-comment-basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issue_comment", 3 | "payload": { 4 | "action": "created", 5 | "issue": { 6 | "number": "TEST-123" 7 | }, 8 | "comment": { 9 | "body": "Test example comment with linked Jira issue: [TEST-123]", 10 | "id": "5678", 11 | "html_url": "some-comment-url" 12 | }, 13 | "repository": { 14 | "name": "test-repo-name", 15 | "html_url": "test-repo-url", 16 | "owner": { 17 | "login": "test-repo-owner" 18 | } 19 | }, 20 | "sender": { 21 | "type": "User", 22 | "login": "TestUser" 23 | }, 24 | "installation": { 25 | "id": 1234 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/fixtures/issue-null-body.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issues", 3 | "payload": { 4 | "action": "opened", 5 | "issue": { 6 | "id": "test-issue-id", 7 | "number": 123456789, 8 | "body": null 9 | }, 10 | "repository": { 11 | "name": "test-repo-name", 12 | "html_url": "test-repo-url", 13 | "owner": { 14 | "login": "test-repo-owner" 15 | } 16 | }, 17 | "sender": { 18 | "type": "User", 19 | "login": "TestUser" 20 | }, 21 | "installation": { 22 | "id": 1234 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/jira-configuration/failed-installation.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": 404, 3 | "headers": {}, 4 | "request": {}, 5 | "documentation_url": "https://docs.github.com/rest/reference/apps#get-an-installation-for-the-authenticated-app" 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/jira-configuration/multiple-failed-installations.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "error": { 4 | "status": 404, 5 | "headers": {}, 6 | "request": {}, 7 | "documentation_url": "https://docs.github.com/rest/reference/apps#get-an-installation-for-the-authenticated-app" 8 | }, 9 | "id": 12345678, 10 | "deleted": true 11 | }, 12 | { 13 | "error": { 14 | "status": 404, 15 | "headers": {}, 16 | "request": {}, 17 | "documentation_url": "https://docs.github.com/rest/reference/apps#get-an-installation-for-the-authenticated-app" 18 | }, 19 | "id": 23456789, 20 | "deleted": true 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /test/fixtures/jira-configuration/no-installations.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /test/fixtures/jira-configuration/no-subscriptions.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /test/fixtures/pull-request-null-repo.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "closed", 5 | "repository": null, 6 | "pull_request": { 7 | "id": "test-pull-request-id", 8 | "number": 1, 9 | "state": "open", 10 | "title": "[TEST-123] Test pull request.", 11 | "comments": "test-pull-request-comment-count", 12 | "html_url": "test-pull-request-url", 13 | "head": { 14 | "repo": null, 15 | "ref": "test-pull-request-head-ref", 16 | "sha": "test-pull-request-sha" 17 | }, 18 | "base": { 19 | "repo": { 20 | "html_url": "test-pull-request-base-url" 21 | }, 22 | "ref": "test-pull-request-base-ref" 23 | }, 24 | "user": { 25 | "login": "test-pull-request-user-login" 26 | }, 27 | "updated_at": "test-pull-request-update-time" 28 | }, 29 | "sender": { 30 | "type": "User", 31 | "login": "TestUser" 32 | }, 33 | "installation": { 34 | "id": 1234 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/fixtures/push-basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "commits": [ 5 | { 6 | "id": "test-commit-id", 7 | "hash": "test-commit-hash", 8 | "author": { 9 | "name": "test-commit-name", 10 | "email": "test-email@example.com", 11 | "username": "test-commit-author-username" 12 | }, 13 | "message": "[TEST-123] Test commit.", 14 | "added": [ 15 | "test-added" 16 | ], 17 | "modified": [ 18 | "test-modified" 19 | ], 20 | "removed": [ 21 | "test-removal" 22 | ] 23 | } 24 | ], 25 | "repository": { 26 | "id": "test-repo-id", 27 | "name": "test-repo-name", 28 | "full_name": "example/test-repo-name", 29 | "html_url": "test-repo-url", 30 | "owner": { 31 | "login": "test-repo-owner" 32 | } 33 | }, 34 | "sender": { 35 | "type": "User", 36 | "login": "TestUser" 37 | }, 38 | "installation": { 39 | "id": 1234 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/fixtures/push-comment.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "action": "created", 5 | "commits": [ 6 | { 7 | "id": "test-commit-id", 8 | "hash": "test-commit-hash", 9 | "author": { 10 | "username": "test-commit-author-username" 11 | }, 12 | "message": "TEST-123 #comment This is a comment", 13 | "added": [ 14 | "test-added" 15 | ], 16 | "modified": [ 17 | "test-modified" 18 | ], 19 | "removed": [ 20 | "test-removal" 21 | ] 22 | } 23 | ], 24 | "repository": { 25 | "id": "test-repo-id", 26 | "name": "test-repo-name", 27 | "full_name": "example/test-repo-name", 28 | "html_url": "test-repo-url", 29 | "owner": { 30 | "login": "test-repo-owner" 31 | } 32 | }, 33 | "sender": { 34 | "type": "User", 35 | "login": "TestUser" 36 | }, 37 | "installation": { 38 | "id": 1234 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/fixtures/push-multiple.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "commits": [ 5 | { 6 | "id": "test-commit-id", 7 | "hash": "test-commit-hash", 8 | "author": { 9 | "username": "test-commit-author-username" 10 | }, 11 | "message": "TEST-123 TEST-246 #comment This is a comment", 12 | "added": [ 13 | "test-added" 14 | ], 15 | "modified": [ 16 | "test-modified" 17 | ], 18 | "removed": [ 19 | "test-removal" 20 | ] 21 | } 22 | ], 23 | "repository": { 24 | "id": "test-repo-id", 25 | "name": "test-repo-name", 26 | "full_name": "example/test-repo-name", 27 | "html_url": "test-repo-url", 28 | "owner": { 29 | "login": "test-repo-owner" 30 | } 31 | }, 32 | "sender": { 33 | "type": "User", 34 | "login": "TestUser" 35 | }, 36 | "installation": { 37 | "id": 1234 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/fixtures/push-no-issuekey-commits.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "action": "created", 5 | "commits": [ 6 | { 7 | "id": "test-commit-id", 8 | "hash": "test-commit-hash", 9 | "author": { 10 | "username": "test-commit-author-username" 11 | }, 12 | "message": "Test commit.", 13 | "added": [ 14 | "test-added" 15 | ], 16 | "modified": [ 17 | "test-modified" 18 | ], 19 | "removed": [ 20 | "test-removal" 21 | ] 22 | } 23 | ], 24 | "repository": { 25 | "id": "test-repo-id", 26 | "name": "test-repo-name", 27 | "full_name": "example/test-repo-name", 28 | "html_url": "test-repo-url", 29 | "owner": { 30 | "login": "test-repo-owner" 31 | } 32 | }, 33 | "sender": { 34 | "type": "User", 35 | "login": "TestUser" 36 | }, 37 | "installation": { 38 | "id": 1234 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/fixtures/push-no-issues.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "action": "created", 5 | "commits": [ 6 | { 7 | "id": "test-commit-id", 8 | "hash": "test-commit-hash", 9 | "author": { 10 | "username": "test-commit-author-username" 11 | }, 12 | "message": "Example commit #comment This is a comment", 13 | "added": [ 14 | "test-added" 15 | ], 16 | "modified": [ 17 | "test-modified" 18 | ], 19 | "removed": [ 20 | "test-removal" 21 | ] 22 | } 23 | ], 24 | "repository": { 25 | "id": "test-repo-id", 26 | "name": "test-repo-name", 27 | "full_name": "example/test-repo-name", 28 | "html_url": "test-repo-url", 29 | "owner": { 30 | "login": "test-repo-owner" 31 | } 32 | }, 33 | "sender": { 34 | "type": "User", 35 | "login": "TestUser" 36 | }, 37 | "installation": { 38 | "id": 1234 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/fixtures/push-no-username.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "action": "created", 5 | "commits": [ 6 | { 7 | "id": "commit-no-username", 8 | "hash": "test-commit-hash", 9 | "author": { 10 | "email": "test-email@example.com", 11 | "name": "test-commit-name" 12 | }, 13 | "message": "[TEST-123] Test commit.", 14 | "added": [ 15 | "test-added" 16 | ], 17 | "modified": [ 18 | "test-modified" 19 | ], 20 | "removed": [ 21 | "test-removal" 22 | ] 23 | } 24 | ], 25 | "repository": { 26 | "id": "test-repo-id", 27 | "name": "test-repo-name", 28 | "full_name": "example/test-repo-name", 29 | "html_url": "test-repo-url", 30 | "owner": { 31 | "login": "test-repo-owner" 32 | } 33 | }, 34 | "sender": { 35 | "type": "User", 36 | "login": "TestUser" 37 | }, 38 | "installation": { 39 | "id": 1234 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/fixtures/push-transition-comment.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "action": "created", 5 | "commits": [ 6 | { 7 | "id": "test-commit-id", 8 | "hash": "test-commit-hash", 9 | "author": { 10 | "username": "test-commit-author-username" 11 | }, 12 | "message": "TEST-123 #resolve This is a transition", 13 | "added": [ 14 | "test-added" 15 | ], 16 | "modified": [ 17 | "test-modified" 18 | ], 19 | "removed": [ 20 | "test-removal" 21 | ] 22 | } 23 | ], 24 | "repository": { 25 | "id": "test-repo-id", 26 | "name": "test-repo-name", 27 | "full_name": "example/test-repo-name", 28 | "html_url": "test-repo-url", 29 | "owner": { 30 | "login": "test-repo-owner" 31 | } 32 | }, 33 | "sender": { 34 | "type": "User", 35 | "login": "TestUser" 36 | }, 37 | "installation": { 38 | "id": 1234 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/fixtures/push-transition.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "action": "created", 5 | "commits": [ 6 | { 7 | "id": "test-commit-id", 8 | "hash": "test-commit-hash", 9 | "author": { 10 | "username": "test-commit-author-username" 11 | }, 12 | "message": "TEST-123 #resolve", 13 | "added": [ 14 | "test-added" 15 | ], 16 | "modified": [ 17 | "test-modified" 18 | ], 19 | "removed": [ 20 | "test-removal" 21 | ] 22 | } 23 | ], 24 | "repository": { 25 | "id": "test-repo-id", 26 | "name": "test-repo-name", 27 | "full_name": "example/test-repo-name", 28 | "html_url": "test-repo-url", 29 | "owner": { 30 | "login": "test-repo-owner" 31 | } 32 | }, 33 | "sender": { 34 | "type": "User", 35 | "login": "TestUser" 36 | }, 37 | "installation": { 38 | "id": 1234 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/fixtures/push-with-config-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "action": "created", 5 | "commits": [ 6 | { 7 | "id": "test-commit-id", 8 | "hash": "test-commit-hash", 9 | "author": { 10 | "username": "test-commit-author-username" 11 | }, 12 | "message": "Example commit #comment This is a comment", 13 | "added": [ 14 | "test-added", 15 | ".jira/config.yml" 16 | ], 17 | "modified": [ 18 | "test-modified" 19 | ], 20 | "removed": [ 21 | "test-removal" 22 | ] 23 | } 24 | ], 25 | "repository": { 26 | "id": "test-repo-id", 27 | "name": "test-repo-name", 28 | "full_name": "example/test-repo-name", 29 | "html_url": "test-repo-url", 30 | "owner": { 31 | "login": "test-repo-owner" 32 | } 33 | }, 34 | "sender": { 35 | "type": "User", 36 | "login": "TestUser" 37 | }, 38 | "installation": { 39 | "id": 1234 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/fixtures/push-worklog.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push", 3 | "payload": { 4 | "action": "created", 5 | "commits": [ 6 | { 7 | "id": "test-commit-id", 8 | "hash": "test-commit-hash", 9 | "author": { 10 | "username": "test-commit-author-username" 11 | }, 12 | "message": "TEST-123 #time 4h 12m This is a worklog", 13 | "added": [ 14 | "test-added" 15 | ], 16 | "modified": [ 17 | "test-modified" 18 | ], 19 | "removed": [ 20 | "test-removal" 21 | ] 22 | } 23 | ], 24 | "repository": { 25 | "id": "test-repo-id", 26 | "name": "test-repo-name", 27 | "full_name": "example/test-repo-name", 28 | "html_url": "test-repo-url", 29 | "owner": { 30 | "login": "test-repo-owner" 31 | } 32 | }, 33 | "sender": { 34 | "type": "User", 35 | "login": "TestUser" 36 | }, 37 | "installation": { 38 | "id": 1234 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/fixtures/text/existing-reference-link.rendered.md: -------------------------------------------------------------------------------- 1 | #### [TEST-2019] 2 | 3 | This is an example w/ an [embedded reference link] already present. 4 | 5 | [embedded reference link]: https://google.com 6 | 7 | [TEST-2019]: http://example.com/browse/TEST-2019 8 | -------------------------------------------------------------------------------- /test/fixtures/text/existing-reference-link.source.md: -------------------------------------------------------------------------------- 1 | #### [TEST-2019] 2 | 3 | This is an example w/ an [embedded reference link] already present. 4 | 5 | [embedded reference link]: https://google.com 6 | -------------------------------------------------------------------------------- /test/fixtures/text/find-existing-references.rendered.md: -------------------------------------------------------------------------------- 1 | #### [TEST-2019] 2 | 3 | [TEST-2019]: https://example.com/browse/TEST-2019 This issue is related to [TEST-2020] and [TEST-2021] and this 4 | is [TEST-2020]: https://example.com/browse/TEST-2020 is randomly placed in the text. 5 | 6 | [TEST-2019]: http://example.com/browse/TEST-2019 7 | 8 | [TEST-2020]: https://example.com/browse/TEST-2020 9 | 10 | [TEST-2021]: http://example.com/browse/TEST-2021 11 | -------------------------------------------------------------------------------- /test/fixtures/text/find-existing-references.source.md: -------------------------------------------------------------------------------- 1 | #### [TEST-2019] 2 | 3 | [TEST-2019]: https://example.com/browse/TEST-2019 This issue is related to [TEST-2020] and [TEST-2021] and this 4 | is [TEST-2020]: https://example.com/browse/TEST-2020 is randomly placed in the text. 5 | 6 | [TEST-2019]: http://example.com/browse/TEST-2019 7 | 8 | [TEST-2020]: https://example.com/browse/TEST-2020 9 | -------------------------------------------------------------------------------- /test/fixtures/text/issue-keys-with-alphanumeric-values.rendered.md: -------------------------------------------------------------------------------- 1 | #### [KEY-2018] 2 | 3 | These issues must have links: 4 | - [A1-2019] 5 | - [A1B2-2020] 6 | - [A1B2C3-2021] 7 | 8 | Meanwhile these issues shouldn't have links: 9 | - [A11-2019] 10 | - [A11B22-2020] 11 | - [A11B2C33-2021] 12 | 13 | And anything not enclosed inside square brackets won't have any links added. 14 | - KEY-2018 15 | - TEST1-2021 16 | 17 | [KEY-2018]: http://example.com/browse/KEY-2018 18 | [A1-2019]: http://example.com/browse/A1-2019 19 | [A1B2-2020]: http://example.com/browse/A1B2-2020 20 | [A1B2C3-2021]: http://example.com/browse/A1B2C3-2021 21 | 22 | -------------------------------------------------------------------------------- /test/fixtures/text/issue-keys-with-alphanumeric-values.source.md: -------------------------------------------------------------------------------- 1 | #### [KEY-2018] 2 | 3 | These issues must have links: 4 | - [A1-2019] 5 | - [A1B2-2020] 6 | - [A1B2C3-2021] 7 | 8 | Meanwhile these issues shouldn't have links: 9 | - [A11-2019] 10 | - [A11B22-2020] 11 | - [A11B2C33-2021] 12 | 13 | And anything not enclosed inside square brackets won't have any links added. 14 | - KEY-2018 15 | - TEST1-2021 -------------------------------------------------------------------------------- /test/fixtures/text/multiple-links.rendered.md: -------------------------------------------------------------------------------- 1 | #### [TEST-2019] 2 | 3 | This issue is related to [TEST-2020] and [TEST-2021]. 4 | 5 | [TEST-2019]: http://example.com/browse/TEST-2019 6 | [TEST-2020]: http://example.com/browse/TEST-2020 7 | [TEST-2021]: http://example.com/browse/TEST-2021 8 | -------------------------------------------------------------------------------- /test/fixtures/text/multiple-links.source.md: -------------------------------------------------------------------------------- 1 | #### [TEST-2019] 2 | 3 | This issue is related to [TEST-2020] and [TEST-2021]. 4 | -------------------------------------------------------------------------------- /test/fixtures/text/previously-referenced.rendered.md: -------------------------------------------------------------------------------- 1 | ### [TEST-2019] 2 | 3 | Shouldn't duplicate the link for [TEST-2019]! 4 | 5 | [TEST-2019]: http://example.com/browse/TEST-2019 6 | -------------------------------------------------------------------------------- /test/fixtures/text/previously-referenced.source.md: -------------------------------------------------------------------------------- 1 | ### [TEST-2019] 2 | 3 | Shouldn't duplicate the link for [TEST-2019]! 4 | 5 | [TEST-2019]: http://example.com/browse/TEST-2019 6 | -------------------------------------------------------------------------------- /test/fixtures/text/valid-and-invalid-issues.rendered.md: -------------------------------------------------------------------------------- 1 | Should linkify [TEST-200] and not [TEST-100] as a link 2 | 3 | [TEST-200]: http://example.com/browse/TEST-200 4 | -------------------------------------------------------------------------------- /test/fixtures/text/valid-and-invalid-issues.source.md: -------------------------------------------------------------------------------- 1 | Should linkify [TEST-200] and not [TEST-100] as a link 2 | -------------------------------------------------------------------------------- /test/setup/env-test.ts: -------------------------------------------------------------------------------- 1 | import { EnvVars } from "config/env"; 2 | import { cloneDeep, difference } from "lodash"; 3 | import { envCheck } from "utils/env-utils"; 4 | 5 | envCheck( 6 | "SQS_TEST_QUEUE_URL", 7 | "SQS_TEST_QUEUE_REGION" 8 | ); 9 | 10 | export interface TestEnvVars extends EnvVars { 11 | // Test Vars 12 | ATLASSIAN_SECRET?: string; 13 | AWS_ACCESS_KEY_ID?: string; 14 | AWS_SECRET_ACCESS_KEY?: string; 15 | SQS_TEST_QUEUE_URL: string; 16 | SQS_TEST_QUEUE_REGION: string; 17 | } 18 | 19 | // Save original env vars so we can reset between tests 20 | const originalEnvVars = cloneDeep(process.env); 21 | export const resetEnvVars = () => { 22 | const originalKeys = Object.keys(originalEnvVars); 23 | const newKeys = Object.keys(process.env); 24 | // Reset original keys back to process.env 25 | originalKeys.forEach(key => process.env[key] = originalEnvVars[key]); 26 | // Removing keys that's been added during the test 27 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 28 | difference(newKeys, originalKeys).forEach(key => delete process.env[key]); 29 | }; 30 | -------------------------------------------------------------------------------- /test/setup/matchers/nock.ts: -------------------------------------------------------------------------------- 1 | import nock from "nock"; 2 | 3 | declare global { 4 | // eslint-disable-next-line @typescript-eslint/no-namespace 5 | namespace jest { 6 | interface Matchers { 7 | toBeDone(): R; 8 | } 9 | } 10 | } 11 | 12 | expect.extend({ 13 | toBeDone: (scope: E) => { 14 | const pass = scope.isDone(); 15 | if (pass) { 16 | return { pass: true, message: () => "Expected nock scope to have pending mocks, but none were found.\n" }; 17 | } 18 | return { 19 | pass: false, 20 | message: () => `Expected nock scope to have no pending mocks, but some were found:\n${JSON.stringify(scope.pendingMocks(), null, 2)}\n` 21 | }; 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /test/setup/matchers/to-be-called-with-delay.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | declare namespace jest { 3 | interface Matchers { 4 | 5 | toBeCalledWithDelaySec(expectedDelaySec: number): Promise; 6 | } 7 | } 8 | 9 | expect.extend({ 10 | toBeCalledWithDelaySec: async (received: jest.Mock, expectedDelaySec: number) => { 11 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 12 | const actual = received.mock?.calls[0][0].DelaySeconds as number; 13 | const pass = actual == expectedDelaySec; 14 | const message = () => `Expected parameter to have DelaySeconds = ${expectedDelaySec} ${pass ? "" : `but was ${actual}`}`; 15 | 16 | return { message, pass }; 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /test/snapshots/routes/github/subscription/github-subscription-delete.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`delete-github-subscription Should 400 when missing body.installationId 1`] = ` 4 | [ 5 | { 6 | "err": Any, 7 | }, 8 | ] 9 | `; 10 | 11 | exports[`delete-github-subscription Should 400 when missing body.jiraHost 1`] = ` 12 | [ 13 | { 14 | "err": Any, 15 | }, 16 | ] 17 | `; 18 | -------------------------------------------------------------------------------- /test/snapshots/snapshot-resolver.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | const rootPath = process.cwd(); 4 | const srcPath = path.resolve(rootPath, "src"); 5 | const snapshotDirPath = path.resolve(rootPath, "test/snapshots/"); 6 | 7 | module.exports = { 8 | /** 9 | * @param testPath Path of the test file being tested 10 | * @param snapshotExtension The extension for snapshots (.snap usually) 11 | */ 12 | resolveSnapshotPath:(testPath: string, snapshotExtension: string) => 13 | path.join(snapshotDirPath, testPath.replace(srcPath, "") + snapshotExtension), 14 | 15 | /** 16 | * @param snapshotFilePath The filename of the snapshot (i.e. some.test.js.snap) 17 | * @param snapshotExtension The extension for snapshots (.snap) 18 | */ 19 | resolveTestPath:(snapshotFilePath, snapshotExtension) => 20 | snapshotFilePath 21 | .replace(snapshotDirPath, srcPath) // remove snapshot directory prepend 22 | .replace(snapshotExtension, ""), // Remove the .snap 23 | 24 | /* Used to validate resolveTestPath(resolveSnapshotPath( {this} )) */ 25 | testPathForConsistencyCheck: path.resolve(rootPath, "src/foo/bar/some.test.ts") 26 | }; 27 | -------------------------------------------------------------------------------- /test/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import { encodeSymmetric, createQueryStringHash, Request } from "atlassian-jwt"; 2 | 3 | export const buildContextTypeJWTToken = (shareSecret: string) => { 4 | return encodeSymmetric({ 5 | iss: "jira", 6 | qsh: "context-qsh" 7 | }, shareSecret); 8 | }; 9 | 10 | export const buildQueryTypeJWTToken = ( 11 | shareSecret: string, 12 | req: Request 13 | ) => { 14 | return encodeSymmetric({ 15 | iss: "jira", 16 | qsh: createQueryStringHash({ ... req }) 17 | }, shareSecret); 18 | }; 19 | -------------------------------------------------------------------------------- /test/utils/wait-until.ts: -------------------------------------------------------------------------------- 1 | export const waitUntil = ( 2 | predicate: () => Promise, 3 | delayMillis = 100, 4 | maxAttempts = 50 5 | ): Promise => { 6 | let attempts = 0; 7 | 8 | const tryPredicate = async () => { 9 | try { 10 | return await predicate(); 11 | } catch (error: unknown) { 12 | attempts++; 13 | if (attempts >= maxAttempts) { 14 | return Promise.reject(error); 15 | } 16 | await new Promise((resolve) => setTimeout(resolve, delayMillis)); 17 | return await tryPredicate(); 18 | } 19 | }; 20 | 21 | return tryPredicate(); 22 | }; 23 | -------------------------------------------------------------------------------- /tsconfig.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "removeComments": true 5 | }, 6 | "include": [ 7 | "src/**/*" 8 | ], 9 | "exclude": [ 10 | "src/**/*.test.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /views/partials/github-setup-form-error.hbs: -------------------------------------------------------------------------------- 1 |
2 | 5 | GitHub setup error message 6 | 7 |

8 | {{errorMessage}} 9 |

10 |
11 | -------------------------------------------------------------------------------- /views/partials/jira-and-github-header.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 |

{{title}}

4 |
5 | -------------------------------------------------------------------------------- /views/partials/loading-screen.hbs: -------------------------------------------------------------------------------- 1 |
2 |

{{title}}

3 |
4 | Jira 5 |
-
6 |
7 | 8 |
9 |
-
10 | Github 11 |
12 |

13 | {{message}} 14 |

15 |
16 | -------------------------------------------------------------------------------- /views/partials/modal.hbs: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /views/partials/navigation.hbs: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /views/partials/select-server-header.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if headerImgURL}} 3 | Jira and GitHub 4 | {{/if}} 5 |

{{header}}

6 |

{{{subHeader}}}

7 |
-------------------------------------------------------------------------------- /views/partials/select-table.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | GitHub 5 | {{contentTitle}} 6 |
7 | 8 | 9 | {{textForAddNew}} 10 | 11 |
12 | 13 |
14 | {{#each list as |item|}} 15 |
16 |
{{item.identifier}}
17 | 24 |
25 | {{/each}} 26 |
27 |
28 | -------------------------------------------------------------------------------- /views/partials/server-error-message-box.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |
6 |
7 |
8 | --------------------------------------------------------------------------------