├── .dockerignore ├── .editorconfig ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── WebAppHosting.md ├── azure-pipelines.yml ├── backend ├── jest.config.js ├── package.json ├── src │ ├── config.test.ts │ ├── config.ts │ ├── health.ts │ ├── index.ts │ ├── lib │ │ ├── author.test.ts │ │ ├── author.ts │ │ ├── cache.test.ts │ │ ├── cache.ts │ │ ├── clustersync.test.ts │ │ ├── clustersync.ts │ │ ├── common.ts │ │ ├── deployments.test.ts │ │ ├── deployments.ts │ │ ├── mocks │ │ │ ├── deploymentsData.ts │ │ │ └── deploymentsDataExtra.ts │ │ ├── pullRequest.test.ts │ │ ├── pullRequest.ts │ │ └── test-common.ts │ ├── server.ts │ └── version.ts ├── tsconfig.json ├── tsconfig.prod.json ├── tslint.json └── yarn.lock ├── chart ├── .helmignore ├── Chart.yaml ├── templates │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── ingress.yaml │ ├── loadbalancer.yaml │ └── service.yaml └── values.yaml ├── deploy ├── README.md ├── create-variable-group.sh ├── deploy-latest-image.sh └── deploy-spektate-ci.yaml ├── docker-entrypoint.sh ├── docs ├── gitops-observability-flux.md └── images │ ├── azuredevops-gitopsconnector.png │ ├── githubactions-gitopsconnector.png │ ├── gitops-connector.png │ ├── gitops_before_after.png │ ├── spektate1-fluxv2.png │ └── spektate2-fluxv2.png ├── frontend ├── images.d.ts ├── jest.config.js ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── Dashboard.tsx │ ├── Dashboard.types.ts │ ├── DeploymentFilter.tsx │ ├── DeploymentTable.tsx │ ├── assets │ │ └── pipeline-proto.png │ ├── cells │ │ ├── build.tsx │ │ ├── cluster.tsx │ │ ├── icons.tsx │ │ ├── persona.tsx │ │ ├── simple.tsx │ │ ├── status.tsx │ │ └── time.tsx │ ├── css │ │ └── dashboard.css │ ├── index.tsx │ └── registerServiceWorker.ts ├── tsconfig.json ├── tsconfig.prod.json ├── tslint.json └── yarn.lock ├── images ├── AADSettings.png ├── AuthNAuthZ.png ├── ContainerSettings.png ├── spektate-diagram.png ├── spektate-pieces-diagram.png ├── spektate-workflow.png └── variable_group.png ├── packages └── spektate │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── HttpHelper.ts │ ├── IDeployment.test.ts │ ├── IDeployment.ts │ ├── Validation.test.ts │ ├── Validation.ts │ ├── index.ts │ ├── mocks │ │ ├── cd-releases.json │ │ ├── ci-builds.json │ │ ├── deployments.json │ │ └── hld-builds.json │ ├── pipeline │ │ ├── AzureDevOpsPipeline.test.ts │ │ ├── AzureDevOpsPipeline.ts │ │ ├── Build.ts │ │ ├── GithubActions.test.ts │ │ ├── GithubActions.ts │ │ ├── GitlabPipeline.test.ts │ │ ├── GitlabPipeline.ts │ │ ├── Pipeline.ts │ │ ├── PipelineStage.ts │ │ ├── Release.ts │ │ ├── index.ts │ │ └── mocks │ │ │ ├── gh-actions-1115488431.json │ │ │ ├── gh-actions-1255355142.json │ │ │ ├── gitlab-pipeline-1.json │ │ │ ├── gitlab-pipeline-2.json │ │ │ ├── raw-build-stages.json │ │ │ ├── raw-builds.json │ │ │ └── raw-releases.json │ └── repository │ │ ├── Author.ts │ │ ├── IAzureDevOpsRepo.test.ts │ │ ├── IAzureDevOpsRepo.ts │ │ ├── IGitHub.test.ts │ │ ├── IGitHub.ts │ │ ├── IGitlabRepo.test.ts │ │ ├── IGitlabRepo.ts │ │ ├── IPullRequest.ts │ │ ├── Tag.ts │ │ ├── index.ts │ │ └── mocks │ │ ├── azdo-author-response.json │ │ ├── azdo-manifest-sync-tag-response.json │ │ ├── azdo-pr-response.json │ │ ├── azdo-sync-response.json │ │ ├── github-author-response.json │ │ ├── github-manifest-sync-tag-response.json │ │ ├── github-pr-response.json │ │ ├── github-sync-response-1.json │ │ ├── github-sync-response.json │ │ ├── gitlab-author-response.json │ │ ├── gitlab-pr-response.json │ │ ├── gitlab-releasesurl-response.json │ │ └── gitlab-tags-response.json │ ├── tsconfig.json │ ├── tslint.json │ ├── webpack.config.js │ └── yarn.lock └── pipeline-scripts ├── requirements.txt └── update_pipeline.py /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.{json,yaml,yml}] 13 | indent_style = space 14 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | .vscode 3 | 4 | # dependencies 5 | node_modules 6 | /.pnp 7 | .pnp.js 8 | /venv 9 | build/ 10 | 11 | # testing 12 | coverage/ 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .env 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | junit.xml 26 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # backend environment 2 | FROM node:12.2.0-alpine as backend 3 | WORKDIR /app 4 | ENV PATH /app/node_modules/.bin:$PATH 5 | 6 | WORKDIR /app 7 | COPY backend/package.json . 8 | COPY backend/yarn.lock . 9 | RUN yarn install --silent 10 | COPY backend . 11 | RUN yarn build 12 | 13 | # frontend environment 14 | FROM node:12.2.0-alpine as frontend 15 | WORKDIR /app 16 | ENV PATH /app/node_modules/.bin:$PATH 17 | 18 | WORKDIR /app 19 | COPY frontend/package.json . 20 | COPY frontend/yarn.lock . 21 | RUN yarn install --silent &> /dev/null 22 | COPY frontend . 23 | RUN yarn build 24 | 25 | # prod environment 26 | FROM node:12.2.0-alpine as production 27 | WORKDIR /app 28 | ENV PATH /app/node_modules/.bin:$PATH 29 | COPY --from=backend /app/build /app/build 30 | COPY --from=backend /app/node_modules /app/node_modules 31 | COPY --from=frontend /app/build /app/build 32 | WORKDIR /app/build 33 | EXPOSE 5000 34 | COPY docker-entrypoint.sh /app 35 | ENTRYPOINT ["/app/docker-entrypoint.sh"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 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 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [many more](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets Microsoft's [definition](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)) of a security vulnerability, please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** Instead, please report them to the Microsoft Security Response Center at [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://technet.microsoft.com/en-us/security/dn606155). 12 | 13 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 14 | 15 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 16 | 17 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 18 | * Full paths of source file(s) related to the manifestation of the issue 19 | * The location of the affected source code (tag/branch/commit or direct URL) 20 | * Any special configuration required to reproduce the issue 21 | * Step-by-step instructions to reproduce the issue 22 | * Proof-of-concept or exploit code (if possible) 23 | * Impact of the issue, including how an attacker might exploit the issue 24 | 25 | This information will help us triage your report more quickly. 26 | 27 | ## Preferred Languages 28 | 29 | We prefer all communications to be in English. 30 | 31 | ## Policy 32 | 33 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 34 | 35 | -------------------------------------------------------------------------------- /WebAppHosting.md: -------------------------------------------------------------------------------- 1 | # Hosting Spektate Dashboard with AAD via Azure WebApp for Containers 2 | 3 | ## Identify App Service Plan 4 | 5 | An app service plan needed to defines a set of compute resources for a web app to run. 6 | 7 | Create a new app service plan 8 | 9 | ```bash 10 | az appservice plan create -n $APP_SERVICE_PLAN_NAME -g $RESOURCE_GROUP --is-linux 11 | ``` 12 | 13 | _Alternatively_, you want to re-use an existing app service plan. Use Install `jq` and see what existing app service plans you may already have: 14 | 15 | ```bash 16 | az appservice plan list | jq '.[].id' 17 | ``` 18 | 19 | ## Create an Azure WebApp For Containers with Spektate 20 | 21 | Create an Azure WebApp and specify Spektate docker image 22 | 23 | ```bash 24 | az webapp create -g $RESOURCE_GROUP -p $PLAN_NAME -n $WEB_APP_NAME -i mcr.microsoft.com/k8s/bedrock/spektate 25 | ``` 26 | 27 | **Note**: \$PLAN_NAME should be in the form of `/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups//providers/Microsoft.Web/serverfarms/` 28 | 29 | ## Enable AAD Auth for Azure Web App 30 | 31 | Navigate to your newly created resource `$WEB_APP_NAME` in the Azure Portal. 32 | 33 | 1. Click on Authentication/Authorization 34 | 2. Click "On" under _App Service Authentication_ 35 | 3. Choose Login With Azure Active Directory 36 | 4. Click Azure Active Directory under _Authentication Providers_ 37 | 38 | ![](./images/AuthNAuthZ.png) 39 | 40 | 1. Choose Express for _Management Mode_ 41 | 2. Click Ok at the bottom 42 | 3. Make sure to click Save at near the top of the _Authentication / Authorization_ page 43 | 44 | ![](./images/AADSettings.png) 45 | 46 | ## Enable CORS 47 | 48 | Allowed the Spektate dashboard to make cross-origin calls 49 | 50 | ```bash 51 | az webapp cors add -g $RESOURCE_GROUP -n $WEB_APP_NAME --allowed-origins "*" 52 | ``` 53 | 54 | ## Set the Environment Variables 55 | 56 | Set the environment variables that Spektate needs to run. 57 | 58 | ```bash 59 | az webapp config appsettings set -g $RESOURCE_GROUP -n $WEB_APP_NAME --settings \ 60 | REACT_APP_MANIFEST=$MANIFEST_REPO_NAME \ 61 | REACT_APP_MANIFEST_ACCESS_TOKEN=$MANIFEST_REPO_PAT \ 62 | REACT_APP_PIPELINE_ACCESS_TOKEN=$AZDO_PIPELINE_PAT \ 63 | REACT_APP_PIPELINE_ORG=$AZDO_ORG_NAME \ 64 | REACT_APP_PIPELINE_PROJECT=$AZDO_PROJECT_NAME \ 65 | REACT_APP_SOURCE_REPO_ACCESS_TOKEN=$MANIFEST_REPO_PAT \ 66 | REACT_APP_STORAGE_ACCESS_KEY=$AZ_STORAGE_KEY \ 67 | REACT_APP_STORAGE_ACCOUNT_NAME=$AZ_STORAGE_NAME \ 68 | REACT_APP_STORAGE_PARTITION_KEY=$PARTITION_KEY_NAME \ 69 | REACT_APP_STORAGE_TABLE_NAME=$AZ_STORAGE_TABLE_NAME 70 | ``` 71 | 72 | ## Validate the Docker container is running 73 | 74 | Navigate to https://$WEB_APP_NAME.azurewebsites.net. It may take a while for the Docker image to be pulled and loaded. 75 | 76 | Visit Container Settings in the left bar menu to read logs about the progress of the Docker image pull and load. 77 | 78 | ![](./images/ContainerSettings.png) 79 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | - master 3 | 4 | pool: 5 | vmImage: "ubuntu-latest" 6 | 7 | stages: 8 | - stage: spektate_ci 9 | jobs: 10 | - job: build_and_test 11 | pool: 12 | vmImage: "Ubuntu 16.04" 13 | steps: 14 | - task: NodeTool@0 15 | inputs: 16 | versionSpec: "10.x" 17 | displayName: "Install Node.js" 18 | 19 | - script: | 20 | yarn install 21 | yarn lint 22 | yarn build 23 | workingDirectory: "./frontend" 24 | displayName: "Frontend install, lint and build" 25 | 26 | - script: | 27 | yarn install 28 | yarn lint 29 | yarn build 30 | yarn test 31 | workingDirectory: "./backend" 32 | displayName: "Backend install, lint and build" 33 | 34 | - script: | 35 | set -e 36 | cd packages/spektate 37 | yarn 38 | yarn build 39 | yarn lint 40 | yarn test 41 | displayName: "Spektate build, lint and test" 42 | 43 | - task: PublishCodeCoverageResults@1 44 | inputs: 45 | codeCoverageTool: Cobertura 46 | summaryFileLocation: "$(System.DefaultWorkingDirectory)/**/*coverage.xml" 47 | reportDirectory: "$(System.DefaultWorkingDirectory)/**/coverage" 48 | 49 | - job: docker_build 50 | pool: 51 | vmImage: "Ubuntu 16.04" 52 | steps: 53 | - task: Docker@2 54 | inputs: 55 | containerRegistry: "spektateacrconnection" 56 | repository: "spektate" 57 | command: "buildAndPush" 58 | Dockerfile: "**/Dockerfile" 59 | tags: "spektate-$(Build.SourceBranchName)-$(Build.BuildId)" 60 | condition: ne(variables['Build.Reason'], 'PullRequest') 61 | displayName: Build docker image 62 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | modulePathIgnorePatterns: ["build/"] 5 | }; 6 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spektate", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "dependencies": { 6 | "@types/express": "^4.17.0", 7 | "spektate": "^1.0.16", 8 | "express": "^4.17.1" 9 | }, 10 | "scripts": { 11 | "start": "ts-node src/index.ts", 12 | "build": "tsc", 13 | "lint": "tslint -p tsconfig.json -c tslint.json src/**/*.ts", 14 | "prettier": "prettier --write src", 15 | "test": "jest --coverage --coverageReporters=cobertura --coverageReporters=html", 16 | "test-watch": "jest --watchAll" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^12.6.8", 20 | "@types/jest": "^24.0.18", 21 | "husky": "^4.2.5", 22 | "lint-staged": "^10.1.5", 23 | "prettier": "^2.0.5", 24 | "ts-node": "^8.9.0", 25 | "tslint": "^6.1.1", 26 | "tslint-config-prettier": "^1.18.0", 27 | "ts-jest": "^25.2.1", 28 | "typescript": "^3.8.3", 29 | "jest": "^25.1.0" 30 | }, 31 | "husky": { 32 | "hooks": { 33 | "pre-commit": "lint-staged" 34 | } 35 | }, 36 | "lint-staged": { 37 | "*.{ts,tsx,js,jsx,css,json,md,yml}": [ 38 | "prettier --write", 39 | "git add" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/config.test.ts: -------------------------------------------------------------------------------- 1 | import { cacheRefreshInterval, isConfigValid } from "./config"; 2 | import * as config from "./config"; 3 | 4 | describe("test cacheRefreshInterval function", () => { 5 | it("sanity test: default value", () => { 6 | process.env.REACT_APP_CACHE_REFRESH_INTERVAL_IN_SEC = ""; 7 | expect(cacheRefreshInterval()).toBe(30000); 8 | }); 9 | it("sanity test: set 50 seconds", () => { 10 | process.env.REACT_APP_CACHE_REFRESH_INTERVAL_IN_SEC = "50"; 11 | expect(cacheRefreshInterval()).toBe(50000); 12 | }); 13 | it("negative test", () => { 14 | process.env.REACT_APP_CACHE_REFRESH_INTERVAL_IN_SEC = ")*"; 15 | expect(cacheRefreshInterval()).toBe(30000); 16 | }); 17 | }); 18 | 19 | describe("test isConfigValid function", () => { 20 | // postiive tests are already covered by other functions 21 | it("negative test", () => { 22 | jest.spyOn(config, "getConfig").mockImplementationOnce( 23 | (): config.IConfig => { 24 | return { 25 | org: "", 26 | project: "", 27 | manifestAccessToken: "", 28 | pipelineAccessToken: "", 29 | sourceRepoAccessToken: "", 30 | storageAccessKey: "", 31 | storagePartitionKey: "", 32 | storageAccountName: "", 33 | storageTableName: "", 34 | githubManifestUsername: "", 35 | manifestRepoName: "", 36 | dockerVersion: "", 37 | sourceRepo: "", 38 | hldRepo: "", 39 | sourceRepoProjectId: "", 40 | hldRepoProjectId: "", 41 | manifestProjectId: "", 42 | }; 43 | } 44 | ); 45 | expect(isConfigValid()).toBe(false); 46 | }); 47 | it("negative test with response object", () => { 48 | const send = jest.fn(); 49 | const status = (code: number) => { 50 | expect(code).toBe(500); 51 | return { 52 | send, 53 | }; 54 | }; 55 | 56 | isConfigValid({ 57 | status, 58 | } as any); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /backend/src/config.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "express"; 2 | 3 | /** 4 | * Config interface 5 | */ 6 | export interface IConfig { 7 | // AzDO pipeline 8 | org?: string; 9 | project?: string; 10 | 11 | // Github actions 12 | sourceRepo?: string; 13 | hldRepo?: string; 14 | githubManifestUsername?: string; 15 | 16 | // gitlab 17 | sourceRepoProjectId?: string; 18 | hldRepoProjectId?: string; 19 | manifestProjectId?: string; 20 | 21 | pipelineAccessToken: string; 22 | manifestRepoName: string; 23 | manifestAccessToken: string; 24 | sourceRepoAccessToken: string; 25 | storageAccessKey: string; 26 | storageAccountName: string; 27 | storageTableName: string; 28 | storagePartitionKey: string; 29 | dockerVersion: string; 30 | } 31 | 32 | /** 33 | * Gets config 34 | */ 35 | export const getConfig = (): IConfig => { 36 | return { 37 | dockerVersion: process.env.REACT_APP_DOCKER_VERSION || "", 38 | githubManifestUsername: process.env.REACT_APP_GITHUB_MANIFEST_USERNAME, 39 | manifestAccessToken: process.env.REACT_APP_MANIFEST_ACCESS_TOKEN || "", 40 | manifestRepoName: process.env.REACT_APP_MANIFEST || "", 41 | org: process.env.REACT_APP_PIPELINE_ORG, 42 | pipelineAccessToken: process.env.REACT_APP_PIPELINE_ACCESS_TOKEN || "", 43 | project: process.env.REACT_APP_PIPELINE_PROJECT, 44 | sourceRepoAccessToken: process.env.REACT_APP_SOURCE_REPO_ACCESS_TOKEN || "", 45 | storageAccessKey: process.env.REACT_APP_STORAGE_ACCESS_KEY || "", 46 | storageAccountName: process.env.REACT_APP_STORAGE_ACCOUNT_NAME || "", 47 | storagePartitionKey: process.env.REACT_APP_STORAGE_PARTITION_KEY || "", 48 | storageTableName: process.env.REACT_APP_STORAGE_TABLE_NAME || "", 49 | sourceRepo: process.env.REACT_APP_SOURCE_REPO, 50 | hldRepo: process.env.REACT_APP_HLD_REPO, 51 | sourceRepoProjectId: process.env.REACT_APP_SOURCE_REPO_PROJECT_ID, 52 | hldRepoProjectId: process.env.REACT_APP_HLD_REPO_PROJECT_ID, 53 | manifestProjectId: process.env.REACT_APP_MANIFEST_REPO_PROJECT_ID, 54 | }; 55 | }; 56 | 57 | /** 58 | * Gets cache refresh interval 59 | */ 60 | export const cacheRefreshInterval = (): number => { 61 | const interval = process.env.REACT_APP_CACHE_REFRESH_INTERVAL_IN_SEC || "30"; 62 | const val = parseInt(interval, 10); 63 | return isNaN(val) ? 30 * 1000 : val * 1000; 64 | }; 65 | 66 | export const isAzdo = (): boolean => { 67 | const config = getConfig(); 68 | return ( 69 | config.org !== undefined && 70 | config.project !== undefined && 71 | config.org !== "" && 72 | config.project !== "" 73 | ); 74 | }; 75 | export const isGithubActions = (): boolean => { 76 | const config = getConfig(); 77 | return ( 78 | config.pipelineAccessToken !== undefined && 79 | config.sourceRepo !== undefined && 80 | config.hldRepo !== undefined 81 | ); 82 | }; 83 | export const isGitlab = (): boolean => { 84 | const config = getConfig(); 85 | return ( 86 | config.pipelineAccessToken !== undefined && 87 | config.sourceRepoProjectId !== undefined && 88 | config.hldRepoProjectId !== undefined 89 | ); 90 | }; 91 | 92 | /** 93 | * Checks whether config is valid or not 94 | * @param res Response obj 95 | */ 96 | export const isConfigValid = (res?: Response) => { 97 | const config = getConfig(); 98 | if ( 99 | (isAzdo() || isGithubActions() || isGitlab()) && 100 | !!config.storageAccountName && 101 | !!config.storageAccessKey && 102 | !!config.storageTableName && 103 | !!config.storagePartitionKey 104 | ) { 105 | return true; 106 | } 107 | 108 | if (res) { 109 | res 110 | .status(500) 111 | .send( 112 | "Environment variables need to be exported for Spektate configuration" 113 | ); 114 | } 115 | return false; 116 | }; 117 | -------------------------------------------------------------------------------- /backend/src/health.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import * as validation from "spektate/lib/Validation"; 3 | import { getConfig } from "./config"; 4 | 5 | export interface IHealth extends validation.IErrors { 6 | variables: ISpektateConfig; 7 | } 8 | 9 | export interface ISpektateConfig { 10 | [id: string]: string; 11 | } 12 | 13 | /** 14 | * Masks secrets with * and returns displayable string 15 | * @param key 16 | */ 17 | export const getKeyToDisplay = (key: string): string => { 18 | return key ? key.replace(/.(?=.{4})/g, "*") : ""; 19 | }; 20 | 21 | /** 22 | * Express get function for health 23 | * @param req Request obj 24 | * @param res Response obj 25 | */ 26 | export const get = async (req: Request, res: Response) => { 27 | const config = getConfig(); 28 | try { 29 | const status = await validation.validateConfiguration( 30 | config.storageAccountName, 31 | config.storageAccessKey, 32 | config.storageTableName, 33 | config.storagePartitionKey, 34 | config.org ?? "", 35 | config.project ?? "", 36 | config.pipelineAccessToken, 37 | config.sourceRepoAccessToken, 38 | config.manifestRepoName, 39 | config.manifestAccessToken, 40 | config.githubManifestUsername 41 | ); 42 | const health: IHealth = { 43 | errors: status.errors, 44 | variables: { 45 | AZURE_ORG: config.org ?? "", 46 | AZURE_PIPELINE_ACCESS_TOKEN: getKeyToDisplay( 47 | config.pipelineAccessToken 48 | ), 49 | AZURE_PROJECT: config.project ?? "", 50 | MANIFEST: config.manifestRepoName, 51 | MANIFEST_ACCESS_TOKEN: getKeyToDisplay(config.manifestAccessToken), 52 | SOURCE_REPO_ACCESS_TOKEN: getKeyToDisplay(config.sourceRepoAccessToken), 53 | STORAGE_ACCOUNT_KEY: getKeyToDisplay(config.storageAccessKey), 54 | STORAGE_ACCOUNT_NAME: config.storageAccountName, 55 | STORAGE_PARTITION_KEY: config.storagePartitionKey, 56 | STORAGE_TABLE_NAME: config.storageTableName, 57 | }, 58 | }; 59 | if (config.githubManifestUsername !== "") { 60 | health.variables.GITHUB_MANIFEST_USERNAME = 61 | config.githubManifestUsername ?? ""; 62 | } 63 | res.json(health || {}); 64 | } catch (err) { 65 | console.log(err); 66 | res.sendStatus(500); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { cacheRefreshInterval } from "./config"; 3 | import { get as healthGet } from "./health"; 4 | import { fetch as fetchDeployment, update as updateCache } from "./lib/cache"; 5 | import { get as versionGet } from "./version"; 6 | const app = express(); 7 | 8 | app.use((req, res, next) => { 9 | res.header("Access-Control-Allow-Origin", "*"); 10 | res.header( 11 | "Access-Control-Allow-Headers", 12 | "Origin, X-Requested-With, Content-Type, Accept" 13 | ); 14 | next(); 15 | }); 16 | 17 | app.get("/api/deployments", (req: express.Request, res: express.Response) => { 18 | try { 19 | res.json(fetchDeployment()); 20 | } catch (err) { 21 | res.status(500).send(err.message); 22 | } 23 | }); 24 | app.get("/api/health", (req: express.Request, res: express.Response) => { 25 | healthGet(req, res); 26 | }); 27 | app.get("/api/version", (req: express.Request, res: express.Response) => { 28 | versionGet(req, res); 29 | }); 30 | 31 | (async () => { 32 | await updateCache(); 33 | 34 | setInterval(async () => { 35 | await updateCache(); 36 | }, cacheRefreshInterval()); 37 | // start the Express server 38 | const port = 8001; // default port to listen 39 | app.listen(port, () => { 40 | console.log(`server started at http://localhost:${port}`); 41 | }); 42 | })(); 43 | -------------------------------------------------------------------------------- /backend/src/lib/author.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "./author"; 2 | import * as common from "./test-common"; 3 | 4 | describe("test get function", () => { 5 | it("negative test", async () => { 6 | common.mockFetchAuthor(); 7 | const author = await get({} as any); 8 | expect(author).toBeUndefined(); 9 | }); 10 | it("with srcToDockerBuild", async () => { 11 | common.mockFetchAuthor(); 12 | const author = await get({ 13 | commitId: "be3c7f6", 14 | deploymentId: "c3e4a8937af9", 15 | srcToDockerBuild: { 16 | repository: { 17 | reponame: "hello-service-hooks", 18 | username: "johndoe", 19 | }, 20 | sourceVersion: "be3c7f65b7c9eb792a7af3ff1f7d3d187cd47773", 21 | }, 22 | } as any); 23 | expect(author).toBeDefined(); 24 | if (author) { 25 | expect(author.url).toBe("hello-service-hooks\tjohndoe"); 26 | } 27 | }); 28 | it("with sourceRepo", async () => { 29 | common.mockFetchAuthor(); 30 | const author = await get({ 31 | commitId: "be3c7f6", 32 | deploymentId: "c3e4a8937af9", 33 | sourceRepo: 34 | "https://dev.azure.com/epictest/hellobedrockprivate/_git/hello-bedrock", 35 | srcToDockerBuild: { 36 | sourceVersion: "be3c7f65b7c9eb792a7af3ff1f7d3d187cd47773", 37 | }, 38 | } as any); 39 | expect(author).toBeDefined(); 40 | if (author) { 41 | expect(author.url).toBe("epictest\thellobedrockprivate\thello-bedrock"); 42 | } 43 | }); 44 | it("with hldToManifestBuild", async () => { 45 | common.mockFetchAuthor(); 46 | const author = await get({ 47 | commitId: "be3c7f6", 48 | deploymentId: "c3e4a8937af9", 49 | hldToManifestBuild: { 50 | repository: { 51 | reponame: "hello-service-hooks-hld", 52 | username: "johndoe", 53 | }, 54 | sourceVersion: "e25a60a027a0a689a6afb68a1433772f7ee73e45", 55 | }, 56 | } as any); 57 | expect(author).toBeDefined(); 58 | if (author) { 59 | expect(author.url).toBe("hello-service-hooks-hld\tjohndoe"); 60 | } 61 | }); 62 | it("with hldRepo", async () => { 63 | common.mockFetchAuthor(); 64 | const author = await get({ 65 | commitId: "be3c7f6", 66 | deploymentId: "c3e4a8937af9", 67 | hldRepo: 68 | "https://dev.azure.com/epictest/hellobedrockprivate/_git/hello-bedrock", 69 | hldToManifestBuild: { 70 | sourceVersion: "e25a60a027a0a689a6afb68a1433772f7ee73e45", 71 | }, 72 | } as any); 73 | expect(author).toBeDefined(); 74 | if (author) { 75 | expect(author.url).toBe("epictest\thellobedrockprivate\thello-bedrock"); 76 | } 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /backend/src/lib/author.ts: -------------------------------------------------------------------------------- 1 | import { fetchAuthor, getRepositoryFromURL } from "spektate/lib/IDeployment"; 2 | import { IAuthor } from "spektate/lib/repository/Author"; 3 | import { IAzureDevOpsRepo } from "spektate/lib/repository/IAzureDevOpsRepo"; 4 | import { IGitHub } from "spektate/lib/repository/IGitHub"; 5 | import { getConfig } from "../config"; 6 | import { IDeploymentData } from "./common"; 7 | import { IGitlabRepo } from "spektate/lib/repository/IGitlabRepo"; 8 | 9 | /** 10 | * Fetches author information 11 | * 12 | * @param deployment Deployment instance 13 | */ 14 | export const get = async ( 15 | deployment: IDeploymentData 16 | ): Promise => { 17 | const config = getConfig(); 18 | let commit = 19 | deployment.srcToDockerBuild?.sourceVersion || 20 | deployment.hldToManifestBuild?.sourceVersion; 21 | 22 | let repo: IAzureDevOpsRepo | IGitHub | IGitlabRepo | undefined = 23 | deployment.srcToDockerBuild?.repository || 24 | deployment.hldToManifestBuild?.repository; 25 | if (!repo && deployment.sourceRepo) { 26 | repo = getRepositoryFromURL(deployment.sourceRepo); 27 | commit = deployment.srcToDockerBuild?.sourceVersion; 28 | } 29 | if (!repo && deployment.hldRepo) { 30 | repo = getRepositoryFromURL(deployment.hldRepo); 31 | commit = deployment.hldToManifestBuild?.sourceVersion; 32 | } 33 | 34 | if (commit && repo) { 35 | return fetchAuthor( 36 | repo, 37 | commit, 38 | config.sourceRepoAccessToken || config.pipelineAccessToken 39 | ); 40 | } 41 | console.log("Repository could not be recognized"); 42 | return undefined; 43 | }; 44 | -------------------------------------------------------------------------------- /backend/src/lib/cache.ts: -------------------------------------------------------------------------------- 1 | import { get as getAuthor } from "./author"; 2 | import { deepClone, IDeploymentData, IDeployments } from "./common"; 3 | import { list as listDeployments } from "./deployments"; 4 | import { get as getPullRequest } from "./pullRequest"; 5 | import { get as getManifestRepoSyncState } from "./clustersync"; 6 | import { IClusterSync } from 'spektate/lib/repository/Tag'; 7 | 8 | let cacheData: IDeployments = { 9 | deployments: [], 10 | clusterSync: undefined 11 | }; 12 | 13 | /** 14 | * Populates author information to deployment. 15 | * 16 | * @param deployment Deployment instance 17 | */ 18 | export const fetchAuthor = async ( 19 | deployment: IDeploymentData 20 | ): Promise => { 21 | try { 22 | deployment.author = await getAuthor(deployment); 23 | } catch (e) { 24 | // If there's an error with author, we want to fail silently since 25 | // deployments can still be displayed. 26 | console.error(e); 27 | } 28 | return undefined; 29 | }; 30 | 31 | /** 32 | * Populates pull request information to deployment. 33 | * 34 | * @param deployment Deployment instance 35 | */ 36 | export const fetchPullRequest = async ( 37 | deployment: IDeploymentData 38 | ): Promise => { 39 | try { 40 | deployment.pullRequest = await getPullRequest(deployment); 41 | } catch (e) { 42 | // If there's an error with PRs, we want to fail silently since 43 | // deployments can still be displayed. 44 | console.error(e); 45 | } 46 | return undefined; 47 | }; 48 | 49 | /** 50 | * Fetches latest cluster sync data 51 | */ 52 | export const fetchClusterSync = async (): Promise => { 53 | try { 54 | return await getManifestRepoSyncState(); 55 | } catch (e) { 56 | // If there's an error with cluster sync, we want to fail silently since 57 | // deployments can still be displayed. 58 | console.error(e); 59 | } 60 | return undefined; 61 | } 62 | 63 | /** 64 | * Updates cache where there are new instances. 65 | * 66 | * @param cache Cloned cache 67 | * @param newData latest deployments 68 | */ 69 | export const updateNewDeployment = async ( 70 | cache: IDeployments, 71 | newData: IDeployments 72 | ): Promise => { 73 | const cacheIds = cache.deployments.map((d) => d.deploymentId); 74 | const newDeployments = newData.deployments.filter( 75 | (d) => cacheIds.indexOf(d.deploymentId) === -1 76 | ); 77 | 78 | if (newDeployments.length > 0) { 79 | // reverse to keep the latest item to the top 80 | newDeployments.reverse().forEach((d) => { 81 | cache.deployments.unshift(d); 82 | }); 83 | // For new deployments, always need to fetch author, pull request and cluster sync 84 | await Promise.all(newDeployments.map((d) => fetchAuthor(d))); 85 | await Promise.all(newDeployments.map((d) => fetchPullRequest(d))); 86 | } 87 | return newDeployments.length > 0; 88 | }; 89 | 90 | /** 91 | * Purge cache where there are old instances 92 | * 93 | * @param cache Cloned cache 94 | * @param newData latest deployments 95 | */ 96 | export const updateOldDeployment = ( 97 | cache: IDeploymentData[], 98 | newData: IDeploymentData[] 99 | ): IDeploymentData[] => { 100 | const cacheIds = newData.map((d) => d.deploymentId); 101 | return cache.filter((d) => cacheIds.indexOf(d.deploymentId) !== -1); 102 | }; 103 | 104 | /** 105 | * Checks if deployment is eligible for quick refresh, if its timestamp has changed or 106 | * it was in progress or incomplete 107 | * @param oldDeployment deployment in cache 108 | * @param newDeployment newly fetched deployment 109 | */ 110 | export const isDeploymentChanged = ( 111 | oldDeployment: IDeploymentData, 112 | newDeployment: IDeploymentData 113 | ): boolean => { 114 | return ( 115 | newDeployment.timeStamp !== oldDeployment.timeStamp || 116 | oldDeployment.status?.toLowerCase() !== "complete" 117 | ); 118 | }; 119 | 120 | /** 121 | * Replaces cache where there are changed instances 122 | * 123 | * @param cache Cloned cache 124 | * @param newData latest deployments 125 | */ 126 | export const updateChangedDeployment = async ( 127 | cache: IDeployments, 128 | newData: IDeployments 129 | ): Promise => { 130 | const cacheIds = cache.deployments.map((d) => d.deploymentId); 131 | const cacheId2deployment = cache.deployments.reduce((a, c) => { 132 | a[c.deploymentId] = c; 133 | return a; 134 | }, {}); 135 | cache.deployments.map((d) => d.deploymentId); 136 | const changed = newData.deployments.filter((d) => { 137 | if (cacheIds.indexOf(d.deploymentId) === -1) { 138 | return false; 139 | } 140 | // We want to update the deployments that have been updated or were in progress, 141 | // to reflect new changes in them 142 | return isDeploymentChanged(cacheId2deployment[d.deploymentId], d); 143 | }); 144 | 145 | if (changed.length > 0) { 146 | changed.forEach((ch) => { 147 | const idx = cacheIds.indexOf(ch.deploymentId); 148 | cache.deployments.splice(idx, 1, ch); 149 | }); 150 | 151 | // For changed deployments, fetch author only if it was empty, and PR only if 152 | // it wasn't closed (to pull merge updates) 153 | await Promise.all( 154 | changed.map((d) => { 155 | if (!cacheId2deployment[d.deploymentId].author) { 156 | fetchAuthor(d); 157 | } else { 158 | d.author = cacheId2deployment[d.deploymentId].author; 159 | } 160 | }) 161 | ); 162 | await Promise.all( 163 | changed.map((d) => { 164 | if (!cacheId2deployment[d.deploymentId].pullRequest?.mergedBy) { 165 | fetchPullRequest(d); 166 | } else { 167 | d.pullRequest = cacheId2deployment[d.deploymentId].pullRequest; 168 | } 169 | }) 170 | ); 171 | } 172 | return changed.length > 0; 173 | }; 174 | 175 | /** 176 | * Updates cache with latest deployments 177 | */ 178 | export const update = async () => { 179 | try { 180 | const latest: IDeployments = { 181 | deployments: deepClone(await listDeployments()), 182 | clusterSync: cacheData.clusterSync 183 | }; 184 | 185 | // clone the current cache data and do an atomic replace later. 186 | const clone = deepClone(cacheData); 187 | const areDeploymentsChanged = await updateChangedDeployment(clone, latest); 188 | const areDeploymentsAdded = await updateNewDeployment(clone, latest); 189 | cacheData.deployments = updateOldDeployment(clone.deployments, latest.deployments); 190 | if (areDeploymentsChanged || areDeploymentsAdded) { 191 | cacheData.clusterSync = await fetchClusterSync(); 192 | } 193 | } catch (e) { 194 | console.log(e); 195 | } 196 | }; 197 | 198 | /** 199 | * Purges cache 200 | */ 201 | export const purge = () => { 202 | cacheData = { 203 | deployments: [], 204 | clusterSync: undefined 205 | }; 206 | }; 207 | 208 | /** 209 | * Returns cached data 210 | */ 211 | export const fetch = () => { 212 | return cacheData; 213 | }; 214 | -------------------------------------------------------------------------------- /backend/src/lib/clustersync.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "./clustersync"; 2 | import * as AzureDevOpsRepo from "spektate/lib/repository/IAzureDevOpsRepo"; 3 | import * as GitHub from "spektate/lib/repository/IGitHub"; 4 | import { ITag } from "spektate/lib/repository/Tag"; 5 | import { getMockedConfig } from "./test-common"; 6 | import * as config from "../config"; 7 | 8 | const mockedTag: ITag = { 9 | name: "tag", 10 | commit: "aaaaaa", 11 | date: new Date(), 12 | }; 13 | 14 | jest.spyOn(AzureDevOpsRepo, "getManifestSyncState").mockResolvedValue( 15 | new Promise((resolve) => { 16 | resolve([mockedTag]); 17 | }) 18 | ); 19 | jest.spyOn(GitHub, "getManifestSyncState").mockResolvedValue( 20 | new Promise((resolve) => { 21 | resolve([mockedTag]); 22 | }) 23 | ); 24 | 25 | describe("test get function", () => { 26 | it("cluster sync github", async () => { 27 | jest.spyOn(config, "getConfig").mockImplementation( 28 | (): config.IConfig => { 29 | return { 30 | org: "", 31 | project: "", 32 | manifestAccessToken: "", 33 | pipelineAccessToken: "", 34 | sourceRepoAccessToken: "", 35 | storageAccessKey: "", 36 | storagePartitionKey: "", 37 | storageAccountName: "", 38 | storageTableName: "", 39 | githubManifestUsername: "test", 40 | manifestRepoName: "manifest", 41 | dockerVersion: "", 42 | sourceRepo: "", 43 | hldRepo: "", 44 | sourceRepoProjectId: "", 45 | hldRepoProjectId: "", 46 | manifestProjectId: "", 47 | }; 48 | } 49 | ); 50 | const tags = await get(); 51 | expect(tags?.releasesURL).toBe("https://github.com/test/manifest/releases"); 52 | expect(tags?.tags).toHaveLength(1); 53 | expect(tags?.tags![0]).toStrictEqual(mockedTag); 54 | }); 55 | it("cluster sync azdo", async () => { 56 | jest.spyOn(config, "getConfig").mockImplementation( 57 | (): config.IConfig => { 58 | return getMockedConfig(); 59 | } 60 | ); 61 | const tags = await get(); 62 | expect(tags?.releasesURL).toBe( 63 | "https://dev.azure.com/epicorg/hellobedrock/_git/hello-manifests/tags" 64 | ); 65 | expect(tags?.tags).toHaveLength(1); 66 | expect(tags?.tags![0]).toStrictEqual(mockedTag); 67 | }); 68 | it("cluster sync negative test", async () => { 69 | jest.spyOn(config, "getConfig").mockImplementation( 70 | (): config.IConfig => { 71 | return { 72 | org: "", 73 | project: "", 74 | manifestAccessToken: "", 75 | pipelineAccessToken: "", 76 | sourceRepoAccessToken: "", 77 | storageAccessKey: "", 78 | storagePartitionKey: "", 79 | storageAccountName: "", 80 | storageTableName: "", 81 | githubManifestUsername: "", 82 | manifestRepoName: "", 83 | dockerVersion: "", 84 | sourceRepo: "", 85 | hldRepo: "", 86 | sourceRepoProjectId: "", 87 | hldRepoProjectId: "", 88 | manifestProjectId: "", 89 | }; 90 | } 91 | ); 92 | let flag = false; 93 | try { 94 | await get(); 95 | } catch (e) { 96 | flag = true; 97 | } 98 | expect(flag).toBe(true); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /backend/src/lib/clustersync.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getManifestSyncState as getADOClusterSync, 3 | getReleasesURL as getADOReleasesURL, 4 | IAzureDevOpsRepo, 5 | } from "spektate/lib/repository/IAzureDevOpsRepo"; 6 | import { 7 | getManifestSyncState as getGitHubClusterSync, 8 | getReleasesURL as getGitHubReleasesURL, 9 | IGitHub, 10 | } from "spektate/lib/repository/IGitHub"; 11 | import { IClusterSync } from "spektate/lib/repository/Tag"; 12 | import { getConfig } from "../config"; 13 | import { 14 | getManifestSyncState as getGitlabClusterSync, 15 | getReleasesURL as getGitlabReleasesURL, 16 | IGitlabRepo, 17 | } from "spektate/lib/repository/IGitlabRepo"; 18 | 19 | /** 20 | * Gets manifest repo sync state to determine cluster sync status 21 | */ 22 | export const get = (): Promise => { 23 | let manifestRepo: IAzureDevOpsRepo | IGitHub | IGitlabRepo | undefined; 24 | let releasesURL = ""; 25 | const config = getConfig(); 26 | 27 | if ( 28 | config.manifestRepoName && 29 | config.githubManifestUsername && 30 | config.githubManifestUsername !== "" 31 | ) { 32 | manifestRepo = { 33 | reponame: config.manifestRepoName, 34 | username: config.githubManifestUsername, 35 | }; 36 | releasesURL = getGitHubReleasesURL(manifestRepo); 37 | 38 | return new Promise((resolve, reject) => { 39 | getGitHubClusterSync(manifestRepo as IGitHub, config.manifestAccessToken) 40 | .then((syncCommits) => { 41 | resolve({ 42 | releasesURL, 43 | tags: syncCommits, 44 | }); 45 | }) 46 | .catch((err) => { 47 | reject(err); 48 | }); 49 | }); 50 | } else if ( 51 | config.manifestRepoName && 52 | config.manifestRepoName !== "" && 53 | config.org && 54 | config.project 55 | ) { 56 | manifestRepo = { 57 | org: config.org, 58 | project: config.project, 59 | repo: config.manifestRepoName, 60 | }; 61 | releasesURL = getADOReleasesURL(manifestRepo); 62 | return new Promise((resolve, reject) => { 63 | getADOClusterSync( 64 | manifestRepo as IAzureDevOpsRepo, 65 | config.manifestAccessToken 66 | ) 67 | .then((syncCommits) => { 68 | resolve({ 69 | releasesURL, 70 | tags: syncCommits, 71 | }); 72 | }) 73 | .catch((err) => { 74 | reject(err); 75 | }); 76 | }); 77 | } else if ( 78 | config.sourceRepoProjectId && 79 | config.hldRepoProjectId && 80 | config.manifestProjectId 81 | ) { 82 | manifestRepo = { 83 | projectId: config.manifestProjectId, 84 | }; 85 | 86 | return new Promise((resolve, reject) => { 87 | getGitlabClusterSync( 88 | manifestRepo as IGitlabRepo, 89 | config.manifestAccessToken 90 | ) 91 | .then((syncCommits) => { 92 | getGitlabReleasesURL( 93 | manifestRepo as IGitlabRepo, 94 | config.manifestAccessToken 95 | ).then((releasesUrl) => { 96 | resolve({ 97 | releasesURL: releasesUrl, 98 | tags: syncCommits, 99 | }); 100 | }); 101 | }) 102 | .catch((err) => { 103 | reject(err); 104 | }); 105 | }); 106 | } 107 | return Promise.reject("No tags were found"); 108 | }; 109 | -------------------------------------------------------------------------------- /backend/src/lib/common.ts: -------------------------------------------------------------------------------- 1 | import { IDeployment } from "spektate/lib/IDeployment"; 2 | import { IAuthor } from "spektate/lib/repository/Author"; 3 | import { IPullRequest } from "spektate/lib/repository/IPullRequest"; 4 | import { IClusterSync } from 'spektate/lib/repository/Tag'; 5 | 6 | export interface IDeploymentData extends IDeployment { 7 | author?: IAuthor | undefined; 8 | pullRequest?: IPullRequest | undefined; 9 | } 10 | 11 | export interface IDeployments { 12 | deployments: IDeploymentData[]; 13 | clusterSync: IClusterSync | undefined; 14 | } 15 | 16 | export const deepClone = (o: T): T => { 17 | return JSON.parse(JSON.stringify(o)); 18 | }; 19 | -------------------------------------------------------------------------------- /backend/src/lib/deployments.test.ts: -------------------------------------------------------------------------------- 1 | import * as deploymentService from "spektate/lib/IDeployment"; 2 | import * as config from "../config"; 3 | import { list } from "./deployments"; 4 | import { data as deploymentData } from "./mocks/deploymentsData"; 5 | import { getMockedConfig } from "./test-common"; 6 | 7 | beforeAll(() => { 8 | jest.spyOn(config, "getConfig").mockImplementation( 9 | (): config.IConfig => { 10 | return getMockedConfig(); 11 | } 12 | ); 13 | }); 14 | 15 | describe("sanity test", () => { 16 | it("positive test", async () => { 17 | jest 18 | .spyOn(deploymentService, "getDeployments") 19 | .mockResolvedValueOnce(deploymentData as any); 20 | const result = await list(); 21 | expect(result).toStrictEqual(deploymentData); 22 | }); 23 | it("negative test", async () => { 24 | jest 25 | .spyOn(deploymentService, "getDeployments") 26 | .mockRejectedValue(Error("dummy")); 27 | await expect(list()).rejects.toThrow(); 28 | }); 29 | it("negative test: config error", async () => { 30 | jest.spyOn(config, "isConfigValid").mockReturnValueOnce(false); 31 | await expect(list()).rejects.toThrow(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /backend/src/lib/deployments.ts: -------------------------------------------------------------------------------- 1 | import { getDeployments, IDeployment } from "spektate/lib/IDeployment"; 2 | import AzureDevOpsPipeline from "spektate/lib/pipeline/AzureDevOpsPipeline"; 3 | import { getConfig, isConfigValid } from "../config"; 4 | import GithubActions from "spektate/lib/pipeline/GithubActions"; 5 | import GitlabPipeline from "spektate/lib/pipeline/GitlabPipeline"; 6 | 7 | /** 8 | * Create instance of AzDO pipeline 9 | */ 10 | const createPipeline = () => { 11 | const config = getConfig(); 12 | if (config.org && config.org !== "" && config.project && config.project !== "") { 13 | return new AzureDevOpsPipeline( 14 | config.org!, 15 | config.project!, 16 | config.pipelineAccessToken 17 | ); 18 | } else if (config.sourceRepo && config.sourceRepo !== "") { 19 | return new GithubActions(config.sourceRepo!, config.pipelineAccessToken); 20 | } else if (config.sourceRepoProjectId) { 21 | return new GitlabPipeline( 22 | config.sourceRepoProjectId, 23 | config.pipelineAccessToken 24 | ); 25 | } 26 | 27 | throw new Error("Configuration is invalid"); 28 | }; 29 | 30 | const createManifestPipeline = () => { 31 | const config = getConfig(); 32 | if (config.org && config.org !== "" && config.project && config.project !== "") { 33 | return createPipeline(); 34 | } else if (config.hldRepo && config.hldRepo !== "") { 35 | return new GithubActions(config.hldRepo!, config.pipelineAccessToken); 36 | } else if (config.hldRepoProjectId) { 37 | return new GitlabPipeline( 38 | config.hldRepoProjectId, 39 | config.pipelineAccessToken 40 | ); 41 | } 42 | throw new Error("Configuration is invalid"); 43 | }; 44 | 45 | /** 46 | * Fetches deployments 47 | */ 48 | export const list = async (): Promise => { 49 | // Create three instances of pipelines 50 | const srcPipeline = createPipeline(); 51 | const hldPipeline = createPipeline(); 52 | const clusterPipeline = createManifestPipeline(); 53 | const config = getConfig(); 54 | 55 | if (!isConfigValid()) { 56 | throw Error("Invalid configuration"); 57 | } 58 | 59 | return await getDeployments( 60 | config.storageAccountName, 61 | config.storageAccessKey, 62 | config.storageTableName, 63 | config.storagePartitionKey, 64 | srcPipeline, 65 | hldPipeline, 66 | clusterPipeline, 67 | undefined 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /backend/src/lib/pullRequest.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "./pullRequest"; 2 | import * as common from "./test-common"; 3 | 4 | describe("test get function", () => { 5 | it("without hldRepo", async () => { 6 | const pr = await get({} as any); 7 | expect(pr).toBeUndefined(); 8 | }); 9 | it("with hldRepo without prId", async () => { 10 | common.mockFetchPullRequest(); 11 | const pr = await get({ 12 | hldRepo: 13 | "https://dev.azure.com/epictest/hellobedrockprivate/_git/hello-bedrock", 14 | } as any); 15 | expect(pr).toBeUndefined(); 16 | }); 17 | it("with hldRepo and prId", async () => { 18 | common.mockFetchPullRequest(); 19 | const pr = await get({ 20 | hldRepo: 21 | "https://dev.azure.com/epictest/hellobedrockprivate/_git/hello-bedrock", 22 | pr: 100, 23 | } as any); 24 | expect(pr).toBeDefined(); 25 | expect(pr?.title).toBe("oh-pr-oh-pr-for-epictest"); 26 | }); 27 | it("with git hub hldRepo and prId", async () => { 28 | common.mockFetchPullRequest(); 29 | const pr = await get({ 30 | hldRepo: "https://github.com/johndoe/spartan-app", 31 | pr: 100, 32 | } as any); 33 | expect(pr).toBeDefined(); 34 | expect(pr?.title).toBe("oh-pr-oh-pr-for-spartan-app"); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /backend/src/lib/pullRequest.ts: -------------------------------------------------------------------------------- 1 | import { fetchPR, getRepositoryFromURL } from "spektate/lib/IDeployment"; 2 | import { IPullRequest } from "spektate/lib/repository/IPullRequest"; 3 | import { getConfig } from "../config"; 4 | import { IDeploymentData } from "./common"; 5 | 6 | /** 7 | * Fetches PR information 8 | * 9 | * @param deployment Deployment instance 10 | */ 11 | export const get = async ( 12 | deployment: IDeploymentData 13 | ): Promise => { 14 | const config = getConfig(); 15 | if (deployment.hldRepo && deployment.pr) { 16 | const repo = getRepositoryFromURL(deployment.hldRepo); 17 | 18 | if (repo) { 19 | return await fetchPR( 20 | repo, 21 | deployment.pr.toString(), 22 | config.sourceRepoAccessToken || config.pipelineAccessToken 23 | ); 24 | } 25 | } 26 | 27 | return undefined; 28 | }; 29 | -------------------------------------------------------------------------------- /backend/src/lib/test-common.ts: -------------------------------------------------------------------------------- 1 | import * as deployment from "spektate/lib/IDeployment"; 2 | import { IAuthor } from "spektate/lib/repository/Author"; 3 | import { IAzureDevOpsRepo } from "spektate/lib/repository/IAzureDevOpsRepo"; 4 | import { IGitHub } from "spektate/lib/repository/IGitHub"; 5 | import { IPullRequest } from "spektate/lib/repository/IPullRequest"; 6 | import { IConfig } from "../config"; 7 | 8 | /** 9 | * Mock for author fetch 10 | * @param hasUndefined 11 | */ 12 | export const mockFetchAuthor = (hasUndefined = false) => { 13 | jest.spyOn(deployment, "fetchAuthor").mockImplementationOnce( 14 | async ( 15 | repo: IGitHub | IAzureDevOpsRepo, 16 | commitId: string, 17 | accessToken?: string | undefined 18 | ): Promise => { 19 | if (!hasUndefined) { 20 | if ("reponame" in repo) { 21 | return { 22 | imageUrl: "", 23 | name: "", 24 | url: `${repo.reponame}\t${repo.username}`, 25 | username: "", 26 | }; 27 | } else if ("org" in repo) { 28 | return { 29 | imageUrl: "", 30 | name: "", 31 | url: `${repo.org}\t${repo.project}\t${repo.repo}`, 32 | username: "", 33 | }; 34 | } 35 | } 36 | return undefined; 37 | } 38 | ); 39 | }; 40 | 41 | /** 42 | * Mock for PR fetch 43 | * @param hasUndefined 44 | */ 45 | export const mockFetchPullRequest = (hasUndefined = false) => { 46 | jest.spyOn(deployment, "fetchPR").mockImplementationOnce( 47 | async ( 48 | repo: IGitHub | IAzureDevOpsRepo, 49 | prId: string, 50 | accessToken?: string | undefined 51 | ): Promise => { 52 | if (!hasUndefined) { 53 | if ("reponame" in repo) { 54 | return { 55 | description: "", 56 | id: parseInt(prId, 10), 57 | sourceBranch: "master", 58 | targetBranch: "master", 59 | title: `oh-pr-oh-pr-for-${repo.reponame}`, 60 | url: 61 | "https://dev.azure.com/epicorg/f8a98d9c-8f11-46ef-89e4-07b4a56d1ad5/_release?releaseId=643", 62 | }; 63 | } else if ("org" in repo) { 64 | return { 65 | description: "", 66 | id: parseInt(prId, 10), 67 | sourceBranch: "master", 68 | targetBranch: "master", 69 | title: `oh-pr-oh-pr-for-${repo.org}`, 70 | url: 71 | "https://dev.azure.com/epicorg/f8a98d9c-8f11-46ef-89e4-07b4a56d1ad5/_release?releaseId=643", 72 | }; 73 | } 74 | } 75 | return undefined; 76 | } 77 | ); 78 | }; 79 | 80 | /** 81 | * Mock for config 82 | */ 83 | export const getMockedConfig = (): IConfig => { 84 | return { 85 | dockerVersion: "mockedVersion", 86 | githubManifestUsername: "", 87 | manifestAccessToken: "cf8a78a2abcdsomekey65b0cb9bd8dsomekeyfsomekey", 88 | manifestRepoName: "hello-manifests", 89 | org: "epicorg", 90 | pipelineAccessToken: "cf8a78a2abcdsomekey65b0cb9bd8dsomekeyfsomekey", 91 | project: "hellobedrock", 92 | sourceRepoAccessToken: "cf8a78a2abcdsomekey65b0cb9bd8dsomekeyfsomekey", 93 | storageAccessKey: "access-key-seeeeeecret", 94 | storageAccountName: "storageaccount", 95 | storagePartitionKey: "partition-key", 96 | storageTableName: "table-name", 97 | sourceRepo: "", 98 | hldRepo: "", 99 | sourceRepoProjectId: "", 100 | hldRepoProjectId: "", 101 | manifestProjectId: "", 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /backend/src/server.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import * as path from "path"; 3 | import { cacheRefreshInterval } from "./config"; 4 | import { get as healthGet } from "./health"; 5 | import { fetch as fetchDeployment, update as updateCache } from "./lib/cache"; 6 | import { get as versionGet } from "./version"; 7 | 8 | const app = express(); 9 | 10 | // Serve static files from the React app 11 | app.use(express.static(path.join(__dirname))); 12 | 13 | app.get("/api/deployments", (req: express.Request, res: express.Response) => { 14 | try { 15 | res.json(fetchDeployment()); 16 | } catch (err) { 17 | res.status(500).send(err.message); 18 | } 19 | }); 20 | app.get("/api/health", (req: express.Request, res: express.Response) => { 21 | healthGet(req, res); 22 | }); 23 | app.get("/api/version", (req: express.Request, res: express.Response) => { 24 | versionGet(req, res); 25 | }); 26 | 27 | // The "catchall" handler: for any request that doesn't 28 | // match one above, send back React's index.html file. 29 | app.get("*", (req, res) => { 30 | res.sendFile(path.join(__dirname + "/index.html")); 31 | }); 32 | 33 | (async () => { 34 | await updateCache(); 35 | 36 | setInterval(async () => { 37 | await updateCache(); 38 | }, cacheRefreshInterval()); 39 | const port = process.env.PORT || 5000; 40 | app.listen(port); 41 | console.log(`Listening on ${port}`); 42 | })(); 43 | -------------------------------------------------------------------------------- /backend/src/version.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { getConfig } from "./config"; 3 | 4 | export const get = async (req: Request, res: Response) => { 5 | const config = getConfig(); 6 | try { 7 | res.json({ 8 | version: config.dockerVersion === "" ? "unknown" : config.dockerVersion, 9 | }); 10 | } catch (err) { 11 | console.log(err); 12 | res.sendStatus(500); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "outDir": "build", 7 | "module": "commonjs", 8 | "target": "es6", 9 | "lib": ["es2017", "es2015", "dom", "es6"], 10 | "sourceMap": true, 11 | "jsx": "react", 12 | "moduleResolution": "node", 13 | "rootDir": "src", 14 | "forceConsistentCasingInFileNames": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noImplicitAny": true, 18 | "importHelpers": true, 19 | "strictNullChecks": true, 20 | "suppressImplicitAnyIndexErrors": true, 21 | "noUnusedLocals": true, 22 | "skipLibCheck": false 23 | }, 24 | "exclude": [ 25 | "node_modules", 26 | "build", 27 | "scripts", 28 | "acceptance-tests", 29 | "webpack", 30 | "pipeline-scripts", 31 | "packages" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /backend/tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": ".", 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "outDir": "build/dist", 8 | "module": "commonjs", 9 | "target": "es6", 10 | "lib": ["es2017", "es2015", "dom", "es6"], 11 | "sourceMap": true, 12 | "jsx": "react", 13 | "moduleResolution": "node", 14 | "rootDir": "src", 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noImplicitAny": true, 19 | "importHelpers": true, 20 | "strictNullChecks": true, 21 | "suppressImplicitAnyIndexErrors": true, 22 | "noUnusedLocals": true, 23 | "skipLibCheck": false 24 | }, 25 | "exclude": [ 26 | "node_modules", 27 | "build", 28 | "scripts", 29 | "acceptance-tests", 30 | "webpack", 31 | "src/setupTests.ts", 32 | "pipeline-scripts", 33 | "packages" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /backend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": [ 5 | "config/**/*.js", 6 | "node_modules/**/*.ts", 7 | "coverage/lcov-report/*.js" 8 | ] 9 | }, 10 | "rules": { 11 | "no-console": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /chart/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | -------------------------------------------------------------------------------- /chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | description: A Helm chart for Spektate 3 | name: spektate 4 | version: 1.0 5 | -------------------------------------------------------------------------------- /chart/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | */}} 13 | {{- define "fullname" -}} 14 | {{- $name := default .Chart.Name .Values.nameOverride -}} 15 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 16 | {{- end -}} 17 | -------------------------------------------------------------------------------- /chart/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ template "fullname" . }} 5 | labels: 6 | app: {{ template "name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | replicas: {{ .Values.replicaCount }} 12 | selector: 13 | matchLabels: 14 | app: {{ template "name" . }} 15 | release: {{ .Release.Name }} 16 | template: 17 | metadata: 18 | labels: 19 | app: {{ template "name" . }} 20 | release: {{ .Release.Name }} 21 | spec: 22 | imagePullSecrets: 23 | - name: {{ .Values.image.pullSecret }} 24 | containers: 25 | - name: {{ .Chart.Name }} 26 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 27 | imagePullPolicy: {{ .Values.image.pullPolicy }} 28 | env: 29 | - name: REACT_APP_STORAGE_ACCESS_KEY 30 | value: {{ .Values.storageAccessKey }} 31 | - name: REACT_APP_STORAGE_TABLE_NAME 32 | value: {{ .Values.storageTableName }} 33 | - name: REACT_APP_STORAGE_PARTITION_KEY 34 | value: {{ .Values.storagePartitionKey }} 35 | - name: REACT_APP_STORAGE_ACCOUNT_NAME 36 | value: {{ .Values.storageAccountName }} 37 | - name: REACT_APP_PIPELINE_PROJECT 38 | value: {{ .Values.pipelineProject }} 39 | - name: REACT_APP_PIPELINE_ORG 40 | value: {{ .Values.pipelineOrg }} 41 | - name: REACT_APP_PIPELINE_ACCESS_TOKEN 42 | value: {{ .Values.pipelineAccessToken }} 43 | - name: REACT_APP_MANIFEST 44 | value: {{ .Values.manifest }} 45 | - name: REACT_APP_MANIFEST_ACCESS_TOKEN 46 | value: {{ .Values.manifestAccessToken }} 47 | - name: REACT_APP_GITHUB_MANIFEST_USERNAME 48 | value: {{ .Values.githubManifestUsername }} 49 | - name: REACT_APP_SOURCE_REPO_ACCESS_TOKEN 50 | value: {{ .Values.sourceRepoAccessToken }} 51 | - name: REACT_APP_DOCKER_VERSION 52 | value: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 53 | ports: 54 | - containerPort: {{ .Values.service.internalPort }} 55 | livenessProbe: 56 | httpGet: 57 | path: / 58 | port: {{ .Values.service.internalPort }} 59 | readinessProbe: 60 | httpGet: 61 | path: / 62 | port: {{ .Values.service.internalPort }} 63 | resources: 64 | {{ toYaml .Values.resources | indent 12 }} 65 | {{- if .Values.nodeSelector }} 66 | nodeSelector: 67 | {{ toYaml .Values.nodeSelector | indent 8 }} 68 | {{- end }} 69 | -------------------------------------------------------------------------------- /chart/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $serviceName := include "fullname" . -}} 3 | {{- $servicePort := .Values.service.externalPort -}} 4 | apiVersion: extensions/v1beta1 5 | kind: Ingress 6 | metadata: 7 | name: {{ template "fullname" . }} 8 | labels: 9 | app: {{ template "name" . }} 10 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 11 | release: {{ .Release.Name }} 12 | heritage: {{ .Release.Service }} 13 | annotations: 14 | {{- range $key, $value := .Values.ingress.annotations }} 15 | {{ $key }}: {{ $value | quote }} 16 | {{- end }} 17 | spec: 18 | rules: 19 | {{- range $host := .Values.ingress.hosts }} 20 | - host: {{ $host }} 21 | http: 22 | paths: 23 | - path: / 24 | backend: 25 | serviceName: {{ $serviceName }} 26 | servicePort: {{ $servicePort }} 27 | {{- end -}} 28 | {{- if .Values.ingress.tls }} 29 | tls: 30 | {{ toYaml .Values.ingress.tls | indent 4 }} 31 | {{- end -}} 32 | {{- end -}} 33 | -------------------------------------------------------------------------------- /chart/templates/loadbalancer.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.externalIP -}} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: loadbalancer 6 | spec: 7 | selector: 8 | app: {{ template "name" . }} 9 | ports: 10 | - port: {{ .Values.service.externalPort }} 11 | targetPort: {{ .Values.service.internalPort }} 12 | type: LoadBalancer 13 | {{- end -}} -------------------------------------------------------------------------------- /chart/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "fullname" . }} 5 | labels: 6 | app: {{ template "name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | type: {{ .Values.service.type }} 12 | ports: 13 | - port: {{ .Values.service.externalPort }} 14 | targetPort: {{ .Values.service.internalPort }} 15 | protocol: TCP 16 | name: {{ .Values.service.name }} 17 | selector: 18 | app: {{ template "name" . }} 19 | release: {{ .Release.Name }} 20 | -------------------------------------------------------------------------------- /chart/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for chart. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | storageAccessKey: 6 | storageTableName: 7 | storagePartitionKey: 8 | storageAccountName: 9 | pipelineProject: 10 | pipelineOrg: 11 | pipelineAccessToken: 12 | manifest: 13 | manifestAccessToken: 14 | githubManifestUsername: 15 | sourceRepoAccessToken: 16 | 17 | externalIP: false 18 | replicaCount: 1 19 | image: 20 | repository: mcr.microsoft.com/k8s/bedrock/spektate 21 | tag: latest 22 | pullSecret: 23 | pullPolicy: IfNotPresent 24 | service: 25 | name: web 26 | type: NodePort 27 | externalPort: 80 28 | internalPort: 5000 29 | ingress: 30 | enabled: false 31 | # Used to create an Ingress record. 32 | hosts: 33 | - chart-example.local 34 | annotations: 35 | tls: 36 | resources: {} 37 | -------------------------------------------------------------------------------- /deploy/README.md: -------------------------------------------------------------------------------- 1 | # Continuous Deployment 2 | 3 | ## create-variable-group.sh 4 | 5 | This file is intended to be run manually as a one-time setup to create a variable group with variables in Azure DevOps. 6 | 7 | ## deploy-latest-image.sh 8 | 9 | This script can be run manually or in an automated fashion. It will deploy the latest (time based) ACR image of Spektate to an Azure Container Instance. The script has logic to determine if an image of the same name is already running in the Azure Container Instance. An Azure pipelines YAML file at the root of this repo will refer to this file for continuous deployment. 10 | 11 | ## deploy-spektate-ci.yaml 12 | 13 | This is an Azure pipelines YAML file that will orchestrate the deployment of the latest Spektate ACR to an Azure Container Instance. It depends on a variable group created by `create-variable-group.sh` and executes `deploy-latest-image.sh` 14 | -------------------------------------------------------------------------------- /deploy/create-variable-group.sh: -------------------------------------------------------------------------------- 1 | # Expected set ENV VAR below 2 | 3 | # SP_APP_ID="REPLACE_ME" 4 | # SP_PASS="REPLACE_ME" 5 | # SP_TENANT="REPLACE_ME" 6 | # AZDO_ORG_NAME="REPLACE_ME" 7 | # AZDO_PROJECT_NAME="REPLACE_ME" 8 | # MANIFEST_REPO_NAME="REPLACE_ME" 9 | # AZ_STORAGE_NAME="REPLACE_ME" 10 | # PARTITION_KEY_NAME="REPLACE_ME" 11 | # AZ_STORAGE_TABLE_NAME="REPLACE_ME" 12 | # MANIFEST_REPO_PAT="REPLACE_ME" 13 | # AZDO_PIPELINE_PAT="REPLACE_ME" 14 | # AZ_STORAGE_KEY="REPLACE_ME" 15 | 16 | AZDO_ORG_URL="https://dev.azure.com/$AZDO_ORG_NAME" 17 | 18 | # Delete and create variable group 19 | vg_name="spektate-ci-vg" 20 | vg_result=$(az pipelines variable-group list --org $AZDO_ORG_URL -p $AZDO_PROJECT_NAME) 21 | vg_exists=$(echo $vg_result | jq -r --arg vg_name "$vg_name" '.[].name | select(. == $vg_name ) != null') 22 | vg_id=$(echo "$vg_result" | jq -r --arg vg_name "$vg_name" '.[] | select(.name == $vg_name) | .id') 23 | 24 | if [ $vg_id ]; then 25 | echo "variable group to delete is $vg_id" 26 | az pipelines variable-group delete --id "$vg_id" --yes --org $AZDO_ORG_URL -p $AZDO_PROJECT_NAME 27 | fi 28 | 29 | echo "Creating variable group $vg_name" 30 | CREATE_RESULT=$(az pipelines variable-group create --name $vg_name \ 31 | --org $AZDO_ORG_URL \ 32 | -p $AZDO_PROJECT_NAME \ 33 | --variables \ 34 | MANIFEST_REPO_NAME=$MANIFEST_REPO_NAME \ 35 | AZ_STORAGE_NAME=$AZ_STORAGE_NAME \ 36 | PARTITION_KEY_NAME=$PARTITION_KEY_NAME \ 37 | AZ_STORAGE_TABLE_NAME=$AZ_STORAGE_TABLE_NAME \ 38 | SP_APP_ID=$SP_APP_ID \ 39 | SP_TENANT=$SP_TENANT) 40 | 41 | GROUP_ID=$(echo $CREATE_RESULT | jq ".id") 42 | echo "The group id is $GROUP_ID" 43 | 44 | echo "Setting secret variables..." 45 | az pipelines variable-group variable create \ 46 | --org $AZDO_ORG_URL \ 47 | -p $AZDO_PROJECT_NAME \ 48 | --group-id "$GROUP_ID" \ 49 | --secret true \ 50 | --name "MANIFEST_REPO_PAT" \ 51 | --value $MANIFEST_REPO_PAT 52 | 53 | az pipelines variable-group variable create \ 54 | --org $AZDO_ORG_URL \ 55 | -p $AZDO_PROJECT_NAME \ 56 | --group-id "$GROUP_ID" \ 57 | --secret true \ 58 | --name "AZDO_PIPELINE_PAT" \ 59 | --value $AZDO_PIPELINE_PAT 60 | 61 | az pipelines variable-group variable create \ 62 | --org $AZDO_ORG_URL \ 63 | -p $AZDO_PROJECT_NAME \ 64 | --group-id "$GROUP_ID" \ 65 | --secret true \ 66 | --name "AZ_STORAGE_KEY" \ 67 | --value $AZ_STORAGE_KEY 68 | 69 | az pipelines variable-group variable create \ 70 | --org $AZDO_ORG_URL \ 71 | -p $AZDO_PROJECT_NAME \ 72 | --group-id "$GROUP_ID" \ 73 | --secret true \ 74 | --name "SP_PASS" \ 75 | --value $SP_PASS -------------------------------------------------------------------------------- /deploy/deploy-latest-image.sh: -------------------------------------------------------------------------------- 1 | #Fail fast 2 | set -e 3 | 4 | # ACR 5 | ACR_NAME="spektateacr" 6 | ACR_REPO="spektate" 7 | ACR_PASS=$(az acr credential show -n $ACR_NAME | jq -r ".passwords[0].value") 8 | ACR_SERVER_URL="https://$ACR_NAME.azurecr.io" 9 | ACR_BRANCH_PATTERN="$ACR_REPO-master-" 10 | 11 | # ACI 12 | CONTAINER_NAME="spektate-ci-env" 13 | SPEKTATE_PORT=5000 14 | RESTART_POLICY="Always" 15 | 16 | # Expected set ENV VAR below 17 | 18 | # AZDO_ORG_NAME="REPLACE_ME" 19 | # AZDO_PROJECT_NAME="REPLACE_ME" 20 | # MANIFEST_REPO_NAME="REPLACE_ME" 21 | # AZ_STORAGE_NAME="REPLACE_ME" 22 | # PARTITION_KEY_NAME="REPLACE_ME" 23 | # AZ_STORAGE_TABLE_NAME="REPLACE_ME" 24 | # MANIFEST_REPO_PAT="REPLACE_ME" 25 | # AZDO_PIPELINE_PAT="REPLACE_ME" 26 | # AZ_STORAGE_KEY="REPLACE_ME" 27 | 28 | # Login to Azure 29 | # echo "az login --service-principal --username $(SP_APP_ID) --password $(SP_PASS) --tenant $(SP_TENANT)" 30 | # az login --service-principal --username "$(SP_APP_ID)" --password "$(SP_PASS)" --tenant "$(SP_TENANT)" 31 | 32 | # Get the latest MASTER image in ACR 33 | SPEKTATE_TAGS=$(az acr repository show-manifests -n $ACR_NAME --repository $ACR_REPO --top 100 --orderby time_desc) 34 | LATEST_SPEKTATE_TAG=$(echo $SPEKTATE_TAGS | jq -r --arg ACR_BRANCH_PATTERN "$ACR_BRANCH_PATTERN" '[.[].tags[0] | select(startswith($ACR_BRANCH_PATTERN))][0]') 35 | echo "\nThe latest tag is $LATEST_SPEKTATE_TAG\n" 36 | CUSTOM_IMAGE_NAME="$ACR_NAME.azurecr.io/$ACR_REPO:$LATEST_SPEKTATE_TAG" 37 | 38 | CONTAINER_COUNT=$(az container list -g $RESOURCE_GROUP --query "[?containers[0].name=='$CONTAINER_NAME']" | jq length) 39 | ACI_EXISTS="" 40 | IMAGE_NAME_SAME="" 41 | 42 | # ACI instance already exists? 43 | if [ "$CONTAINER_COUNT" -eq "1" ]; then 44 | ACI_EXISTS=true 45 | CURRENT_IMAGE=$(az container show \ 46 | --name $CONTAINER_NAME \ 47 | --resource-group $RESOURCE_GROUP \ 48 | | jq -r ".containers[0].image") 49 | 50 | echo "The current installed image on $CONTAINER_NAME is $CURRENT_IMAGE\n" 51 | 52 | # Lowercase 53 | LOWER_INSTALLED_IMAGE=$(echo $CURRENT_IMAGE | tr '[:upper:]' '[:lower:]') 54 | LOWER_LATEST_IMAGE=$(echo $CUSTOM_IMAGE_NAME | tr '[:upper:]' '[:lower:]') 55 | 56 | # Remove beginning and ending whitespace 57 | TRIMMED_INSTALLED_IMAGE="$(echo -e "${LOWER_INSTALLED_IMAGE}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" 58 | TRIMMED_LATEST_IMAGE="$(echo -e "${LOWER_LATEST_IMAGE}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" 59 | echo $TRIMMED_INSTALLED_IMAGE 60 | echo $TRIMMED_LATEST_IMAGE 61 | 62 | if [ "$TRIMMED_LATEST_IMAGE" = "$TRIMMED_INSTALLED_IMAGE" ]; then 63 | echo "Images have the same name...\n" 64 | IMAGE_NAME_SAME=1 65 | fi 66 | fi 67 | 68 | # ACI instance has the same named image installed? 69 | if [ $IMAGE_NAME_SAME ]; then 70 | echo "Restarting $CONTAINER_NAME since the latest image is already installed.\n" 71 | az container restart \ 72 | --name $CONTAINER_NAME \ 73 | --resource-group $RESOURCE_GROUP 74 | echo "Restarted $CONTAINER_NAME. Exiting." 75 | exit 0 76 | fi 77 | 78 | # If ACI instance already exists, then stop the instance 79 | if [ $ACI_EXISTS ]; then 80 | echo "Stopping container instance '$CONTAINER_NAME'\n" 81 | az container stop \ 82 | --name $CONTAINER_NAME \ 83 | --resource-group $RESOURCE_GROUP 84 | fi 85 | 86 | # Create/re-install image on instance 87 | echo "Install the latest image on the container instance '$CONTAINER_NAME'\n" 88 | az container create \ 89 | --name $CONTAINER_NAME \ 90 | --resource-group $RESOURCE_GROUP \ 91 | --image $CUSTOM_IMAGE_NAME \ 92 | --cpu 1 \ 93 | --memory 1 \ 94 | --registry-login-server "$ACR_NAME.azurecr.io" \ 95 | --registry-username $ACR_NAME \ 96 | --registry-password $ACR_PASS \ 97 | --port 80 $SPEKTATE_PORT \ 98 | --ip-address Public \ 99 | --restart-policy $RESTART_POLICY \ 100 | --environment-variables \ 101 | REACT_APP_MANIFEST=$MANIFEST_REPO_NAME \ 102 | REACT_APP_MANIFEST_ACCESS_TOKEN="$MANIFEST_REPO_PAT" \ 103 | REACT_APP_PIPELINE_ACCESS_TOKEN="$AZDO_PIPELINE_PAT" \ 104 | REACT_APP_PIPELINE_ORG=$AZDO_ORG_NAME \ 105 | REACT_APP_PIPELINE_PROJECT=$AZDO_PROJECT_NAME \ 106 | REACT_APP_SOURCE_REPO_ACCESS_TOKEN="$MANIFEST_REPO_PAT" \ 107 | REACT_APP_STORAGE_ACCESS_KEY="$AZ_STORAGE_KEY" \ 108 | REACT_APP_STORAGE_ACCOUNT_NAME=$AZ_STORAGE_NAME \ 109 | REACT_APP_STORAGE_PARTITION_KEY=$PARTITION_KEY_NAME \ 110 | REACT_APP_STORAGE_TABLE_NAME=$AZ_STORAGE_TABLE_NAME \ 111 | --dns-name-label $CONTAINER_NAME 112 | 113 | # If ACI instance already existed just start it since we stopped it 114 | if [ $ACI_EXISTS ]; then 115 | echo "Starting container instance '$CONTAINER_NAME'\n" 116 | az container start \ 117 | --name $CONTAINER_NAME \ 118 | --resource-group $RESOURCE_GROUP 119 | fi 120 | 121 | # Where to look for the running instance 122 | fqdn=$(az container show -n $CONTAINER_NAME -g $RESOURCE_GROUP | jq -r ".ipAddress.fqdn") 123 | echo "\nVisit http://$fqdn:$SPEKTATE_PORT" 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /deploy/deploy-spektate-ci.yaml: -------------------------------------------------------------------------------- 1 | trigger: none 2 | 3 | schedules: 4 | - cron: "0 3 * * Mon-Fri" 5 | branches: 6 | include: [master] 7 | displayName: M-F 7:00PM (UTC - 8:00) Pacific daily build 8 | always: true 9 | 10 | variables: 11 | - name: skipComponentGovernanceDetection 12 | value: "true" 13 | - group: "spektate-ci-vg" 14 | 15 | jobs: 16 | - job: Spektate_Publish 17 | steps: 18 | - script: | 19 | # Login to Azure 20 | echo "az login --service-principal --username $(SP_APP_ID) --password $(SP_PASS) --tenant $(SP_TENANT)" 21 | az login --service-principal --username "$(SP_APP_ID)" --password "$(SP_PASS)" --tenant "$(SP_TENANT)" 22 | 23 | # Make env vars available to child processes by exporting 24 | export MANIFEST_REPO_NAME=$(MANIFEST_REPO_NAME) 25 | export MANIFEST_REPO_PAT=$(MANIFEST_REPO_PAT) 26 | export AZDO_PIPELINE_PAT=$(AZDO_PIPELINE_PAT) 27 | export AZDO_ORG_NAME=$(AZDO_ORG_NAME) 28 | export AZDO_PROJECT_NAME=$(AZDO_PROJECT_NAME) 29 | export AZ_STORAGE_KEY=$(AZ_STORAGE_KEY) 30 | export AZ_STORAGE_NAME=$(AZ_STORAGE_NAME) 31 | export PARTITION_KEY_NAME=$(PARTITION_KEY_NAME) 32 | export AZ_STORAGE_TABLE_NAME=$(AZ_STORAGE_TABLE_NAME) 33 | export RESOURCE_GROUP=$(RESOURCE_GROUP) 34 | export SOURCE_REPO_ACCESS_TOKEN=$(SOURCE_REPO_ACCESS_TOKEN) 35 | 36 | sh ./deploy/deploy-latest-image.sh 37 | displayName: "Run deploy script" 38 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -eu 3 | for word in $(env | grep '^REACT_APP_*') 4 | do 5 | key=${word%%=*} 6 | value=${word#*=} 7 | pair=$(echo "\"$key\":\"$value\"") 8 | export $key=$value 9 | done 10 | 11 | node server.js 12 | -------------------------------------------------------------------------------- /docs/images/azuredevops-gitopsconnector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/docs/images/azuredevops-gitopsconnector.png -------------------------------------------------------------------------------- /docs/images/githubactions-gitopsconnector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/docs/images/githubactions-gitopsconnector.png -------------------------------------------------------------------------------- /docs/images/gitops-connector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/docs/images/gitops-connector.png -------------------------------------------------------------------------------- /docs/images/gitops_before_after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/docs/images/gitops_before_after.png -------------------------------------------------------------------------------- /docs/images/spektate1-fluxv2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/docs/images/spektate1-fluxv2.png -------------------------------------------------------------------------------- /docs/images/spektate2-fluxv2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/docs/images/spektate2-fluxv2.png -------------------------------------------------------------------------------- /frontend/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg"; 2 | declare module "*.png"; 3 | declare module "*.jpg"; 4 | declare module "*.jpeg"; 5 | declare module "*.gif"; 6 | declare module "*.bmp"; 7 | declare module "*.tiff"; 8 | declare module "react-http-request"; 9 | -------------------------------------------------------------------------------- /frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node" 4 | }; 5 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spektate", 3 | "version": "0.1.0", 4 | "main": "build/dist/index.js", 5 | "types": "build/dist/index.d.ts", 6 | "proxy": "http://localhost:8001", 7 | "license": "MIT", 8 | "dependencies": { 9 | "azure-devops-ui": "^2.166.1", 10 | "react": "^16.8.5", 11 | "react-dom": "^16.8.5", 12 | "react-scripts-ts": "^4.0.8", 13 | "spektate": "^1.0.16" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts-ts start", 17 | "build": "react-scripts-ts build", 18 | "eject": "react-scripts-ts eject", 19 | "lint": "tslint -p tsconfig.json -c tslint.json src/**/*.ts", 20 | "prettier": "prettier --write src", 21 | "refresh": "rm -rf ./node_modules ./package-lock.json && npm install" 22 | }, 23 | "devDependencies": { 24 | "@types/jest": "^24.0.18", 25 | "@types/jest-when": "^2.7.0", 26 | "@types/node": "^12.6.8", 27 | "@types/react": "^16.8.10", 28 | "@types/react-dom": "^16.8.3", 29 | "husky": ">=4", 30 | "lint-staged": ">=10", 31 | "prettier": "^2.0.5", 32 | "ts-jest": "^25.4.0", 33 | "ts-node": "^8.9.0", 34 | "tslint": "^6.1.1", 35 | "tslint-config-prettier": "^1.18.0", 36 | "tslint-react": "^4.2.0", 37 | "typescript": "^3.8.3" 38 | }, 39 | "browserslist": [ 40 | ">0.2%", 41 | "not dead", 42 | "not ie <= 11", 43 | "not op_mini all" 44 | ], 45 | "pkg": { 46 | "assets": [ 47 | "./node_modules/@types", 48 | "./node_modules/typescript/lib/*.d.ts", 49 | "src/**/*.ts", 50 | "./tsconfig.json" 51 | ] 52 | }, 53 | "husky": { 54 | "hooks": { 55 | "pre-commit": "lint-staged" 56 | } 57 | }, 58 | "lint-staged": { 59 | "*.{ts,tsx,js,jsx,css,json,md,yml,yaml}": "prettier --write" 60 | }, 61 | "resolutions": { 62 | "**/*/serialize-javascript": ">=2.1.1", 63 | "**/*/mem": ">=4.0.0", 64 | "**/*/braces": ">=2.3.1" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | 24 | Spektate 25 | 26 | 27 | 28 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/Dashboard.types.ts: -------------------------------------------------------------------------------- 1 | import { IStatusProps } from "azure-devops-ui/Status"; 2 | import { IDeployment } from "spektate/lib/IDeployment"; 3 | import { IAuthor } from "spektate/lib/repository/Author"; 4 | import { IPullRequest } from "spektate/lib/repository/IPullRequest"; 5 | import { IClusterSync, ITag } from "spektate/lib/repository/Tag"; 6 | 7 | export interface IStatusIndicatorData { 8 | statusProps: IStatusProps; 9 | label: string; 10 | classname: string; 11 | } 12 | export interface IAuthors { 13 | [commitId: string]: IAuthor; 14 | } 15 | export interface IPRs { 16 | [pr: string]: IPullRequest; 17 | } 18 | 19 | export interface IDeploymentData extends IDeployment { 20 | author?: IAuthor | undefined; 21 | pullRequest?: IPullRequest | undefined; 22 | } 23 | 24 | export enum DeploymentType { 25 | DEPLOYMENT = "Bedrock Deployments", 26 | HLD_EDIT = "Manual HLD Edits", 27 | } 28 | 29 | export interface IDeployments { 30 | deployments: IDeploymentData[]; 31 | clusterSync: IClusterSync | undefined; 32 | } 33 | 34 | export interface IDashboardFilterState { 35 | currentlySelectedServices?: string[]; 36 | currentlySelectedEnvs?: string[]; 37 | currentlySelectedAuthors?: string[]; 38 | currentlySelectedTypes?: string[]; 39 | currentlySelectedKeyword?: string; 40 | defaultApplied: boolean; 41 | } 42 | 43 | export interface IDashboardState { 44 | deployments: IDeploymentData[]; 45 | manifestSyncStatuses?: ITag[]; 46 | filteredDeployments: IDeploymentData[]; 47 | error?: string; 48 | rowLimit: number; 49 | refreshRate: number; 50 | releasesURL?: string; 51 | } 52 | 53 | export interface IDeploymentField { 54 | deploymentType?: DeploymentType; 55 | deploymentId: string; 56 | service: string; 57 | startTime?: Date; 58 | imageTag?: string; 59 | srcCommitId?: string; 60 | srcBranchName?: string; 61 | srcCommitURL?: string; 62 | srcPipelineId?: string; 63 | srcPipelineURL?: string; 64 | srcPipelineResult?: string; 65 | dockerPipelineId?: string; 66 | dockerPipelineURL?: string; 67 | environment?: string; 68 | dockerPipelineResult?: string; 69 | hldCommitId?: string; 70 | hldCommitURL?: string; 71 | hldPipelineId?: string; 72 | hldPipelineURL?: string; 73 | hldPipelineResult?: string; 74 | duration: string; 75 | status: string; 76 | clusters?: string[]; 77 | endTime?: Date; 78 | authorName?: string; 79 | authorURL?: string; 80 | pr?: number; 81 | prURL?: string; 82 | prSourceBranch?: string; 83 | mergedByName?: string; 84 | mergedByImageURL?: string; 85 | manifestCommitId?: string; 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/DeploymentFilter.tsx: -------------------------------------------------------------------------------- 1 | import { ObservableValue } from "azure-devops-ui/Core/Observable"; 2 | import { DropdownFilterBarItem } from "azure-devops-ui/Dropdown"; 3 | import { FilterBar } from "azure-devops-ui/FilterBar"; 4 | import { KeywordFilterBarItem } from "azure-devops-ui/TextFilterBarItem"; 5 | import { DropdownMultiSelection } from "azure-devops-ui/Utilities/DropdownSelection"; 6 | import { 7 | Filter, 8 | FILTER_CHANGE_EVENT /* FilterOperatorType */, 9 | } from "azure-devops-ui/Utilities/Filter"; 10 | import * as React from "react"; 11 | import "./css/dashboard.css"; 12 | import { DeploymentType } from "./Dashboard.types"; 13 | 14 | export interface IDeploymentFilterProps { 15 | listOfServices: string[]; 16 | listOfAuthors: Set; 17 | listOfEnvironments: string[]; 18 | onFiltered: (filterData: Filter) => void; 19 | filter: Filter; 20 | } 21 | 22 | export class DeploymentFilter extends React.Component< 23 | IDeploymentFilterProps, 24 | {} 25 | > { 26 | private currentState = new ObservableValue(""); 27 | 28 | constructor(props: IDeploymentFilterProps) { 29 | super(props); 30 | this.props.filter.subscribe(() => { 31 | this.currentState.value = JSON.stringify( 32 | this.props.filter.getState(), 33 | null, 34 | 4 35 | ); 36 | this.props.onFiltered(this.props.filter); 37 | }, FILTER_CHANGE_EVENT); 38 | } 39 | 40 | public render() { 41 | return
{this.createFilters()}
; 42 | } 43 | 44 | private createFilters = () => { 45 | return ( 46 |
47 | 48 | 49 | { 53 | return { 54 | iconProps: { iconName: "Cloud" }, 55 | id: i, 56 | text: i, 57 | }; 58 | })} 59 | selection={new DropdownMultiSelection()} 60 | placeholder="Filter by Type" 61 | noItemsText="No types found" 62 | /> 63 | { 67 | return { 68 | iconProps: { iconName: "Home" }, 69 | id: i, 70 | text: i, 71 | }; 72 | })} 73 | selection={new DropdownMultiSelection()} 74 | placeholder="Filter by Service" 75 | noItemsText="No services found" 76 | /> 77 | { 81 | return { 82 | iconProps: { iconName: "Contact" }, 83 | id: i, 84 | text: i, 85 | }; 86 | })} 87 | selection={new DropdownMultiSelection()} 88 | placeholder="Filter by Author" 89 | noItemsText="No authors found" 90 | /> 91 | { 95 | return { 96 | iconProps: { iconName: "Globe" }, 97 | id: i, 98 | text: i.toUpperCase(), 99 | }; 100 | })} 101 | selection={new DropdownMultiSelection()} 102 | placeholder="Filter by Ring" 103 | noItemsText="No environments found" 104 | /> 105 | 106 |
107 | ); 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /frontend/src/assets/pipeline-proto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/frontend/src/assets/pipeline-proto.png -------------------------------------------------------------------------------- /frontend/src/cells/build.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "azure-devops-ui/Link"; 2 | import { 3 | ITableColumn, 4 | SimpleTableCell, 5 | TwoLineTableCell, 6 | } from "azure-devops-ui/Table"; 7 | import { Tooltip } from "azure-devops-ui/TooltipEx"; 8 | import * as React from "react"; 9 | import "../css/dashboard.css"; 10 | import { IDeploymentField } from "../Dashboard.types"; 11 | import { getIcon, WithIcon } from "./icons"; 12 | 13 | /** 14 | * Interface for build cell props 15 | */ 16 | interface IBuildProps { 17 | columnIndex: number; 18 | tableColumn: ITableColumn; 19 | pipelineResult?: string; 20 | pipelineId?: string; 21 | pipelineURL?: string; 22 | commitId?: string; 23 | commitURL?: string; 24 | iconName?: string; 25 | } 26 | 27 | export const Build: React.FC = (props: IBuildProps) => { 28 | if (!props.pipelineId || !props.pipelineURL || !props.commitId) { 29 | return ( 30 | 34 | - 35 | 36 | ); 37 | } 38 | const commitCell = WithIcon({ 39 | children: props.commitId, 40 | className: "overflow-text", 41 | iconProps: { iconName: props.iconName }, 42 | }); 43 | return ( 44 | 52 | {props.pipelineURL && ( 53 | (parent.window.location.href = props.pipelineURL!)} 58 | > 59 | {props.pipelineId} 60 | 61 | )} 62 | 63 | } 64 | line2={ 65 | 66 | 67 | {props.commitId && props.commitURL && props.commitURL !== "" && ( 68 | (parent.window.location.href = props.commitURL!)} 73 | > 74 | {commitCell} 75 | 76 | )} 77 | {props.commitId && props.commitURL === "" && commitCell} 78 | 79 | 80 | } 81 | /> 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /frontend/src/cells/cluster.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "azure-devops-ui/Link"; 2 | import { 3 | ITableColumn, 4 | SimpleTableCell, 5 | TwoLineTableCell, 6 | } from "azure-devops-ui/Table"; 7 | import { Tooltip } from "azure-devops-ui/TooltipEx"; 8 | import * as React from "react"; 9 | import { IDeploymentField } from "../Dashboard.types"; 10 | 11 | /** 12 | * Interface for cluster cell props 13 | */ 14 | interface IClusterProps { 15 | columnIndex: number; 16 | tableColumn: ITableColumn; 17 | deployment: IDeploymentField; 18 | releasesUrl?: string; 19 | } 20 | export const Cluster: React.FC = (props: IClusterProps) => { 21 | if (!props.deployment.clusters || props.deployment.clusters.length === 0) { 22 | return ( 23 | 27 | - 28 | 29 | ); 30 | } 31 | const strClusters = props.deployment.clusters.join(", "); 32 | 33 | // If there are more than two clusters, don't show their names but show as "and n more..." 34 | if (props.deployment.clusters.length > 2) { 35 | return ( 36 | 52 | ); 53 | } 54 | return ( 55 | 59 | {renderCluster( 60 | strClusters, 61 | props.deployment.clusters!, 62 | props.releasesUrl 63 | )} 64 | 65 | ); 66 | }; 67 | 68 | /** 69 | * Renders a single cluster name with tooltip and link 70 | * @param text text to display 71 | * @param allClusters list of all other clusters to show as tooltip 72 | * @param releasesUrl url link 73 | */ 74 | export const renderCluster = ( 75 | text: string, 76 | allClusters: string[], 77 | releasesUrl?: string 78 | ): React.ReactNode => { 79 | return ( 80 | renderCustomClusterTooltip(allClusters)} 83 | overflowOnly={false} 84 | > 85 | 90 | {text} 91 | 92 | 93 | ); 94 | }; 95 | 96 | /** 97 | * Renders tooltip for cluster 98 | * @param clusters text to be rendered inside tooltip 99 | */ 100 | export const renderCustomClusterTooltip = (clusters: string[]) => { 101 | const tooltip: React.ReactNode[] = []; 102 | clusters.forEach((cluster) => { 103 | tooltip.push( 104 | 105 | {cluster} 106 |
107 |
108 | ); 109 | }); 110 | return {tooltip}; 111 | }; 112 | -------------------------------------------------------------------------------- /frontend/src/cells/icons.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, IIconProps } from "azure-devops-ui/Icon"; 2 | import { Statuses } from "azure-devops-ui/Status"; 3 | import * as React from "react"; 4 | import "../css/dashboard.css"; 5 | import { IStatusIndicatorData } from "../Dashboard.types"; 6 | 7 | /** 8 | * Icon colors based on color palette that follows rules certain rules 9 | * https://projects.susielu.com/viz-palette?colors=%5B%22#2aa05b%22,%22%23e08a00%22,%22%23c8281f%22%5D&backgroundColor=%22white%22&fontColor=%22black%22&mode=%22normal%22 10 | */ 11 | const iconColors = { 12 | blue: "#0a78d4", 13 | gray: "#3b606d", 14 | green: "#2aa05b", 15 | purple: "#5b50e2", 16 | red: "#c8281f", 17 | yellow: "#e08a00", 18 | }; 19 | 20 | /** 21 | * Returns icon HTML for icon props 22 | */ 23 | export const WithIcon = (props: { 24 | className?: string; 25 | iconProps: IIconProps; 26 | children?: React.ReactNode; 27 | }): React.ReactNode => { 28 | return ( 29 |
30 | {Icon({ ...props.iconProps, className: "icon-margin" })} 31 | {props.children} 32 |
33 | ); 34 | }; 35 | 36 | /** 37 | * Returns status indicator 38 | * @param statusStr status string, such as successful, in progress etc. 39 | */ 40 | export const getStatusIndicatorData = ( 41 | statusStr: string 42 | ): IStatusIndicatorData => { 43 | statusStr = statusStr || ""; 44 | statusStr = statusStr.toLowerCase(); 45 | const indicatorData: IStatusIndicatorData = { 46 | classname: "icon-green", 47 | label: "Success", 48 | statusProps: { 49 | ...Statuses.Success, 50 | ariaLabel: "Success", 51 | color: iconColors.green, 52 | }, 53 | }; 54 | switch (statusStr.toLowerCase()) { 55 | case "failed": 56 | indicatorData.statusProps = { 57 | ...Statuses.Failed, 58 | ariaLabel: "Failed", 59 | color: iconColors.red, 60 | }; 61 | indicatorData.label = "Failed"; 62 | indicatorData.classname = "icon-red"; 63 | break; 64 | case "running": 65 | case "in progress": 66 | indicatorData.statusProps = { 67 | ...Statuses.Running, 68 | ariaLabel: "Running", 69 | color: iconColors.blue, 70 | }; 71 | indicatorData.label = "Running"; 72 | indicatorData.classname = "icon-blue"; 73 | break; 74 | case "waiting": 75 | indicatorData.statusProps = { 76 | ...Statuses.Waiting, 77 | ariaLabel: "Waiting", 78 | color: iconColors.purple, 79 | }; 80 | indicatorData.label = "Waiting"; 81 | indicatorData.classname = "icon-purple"; 82 | break; 83 | case "incomplete": 84 | indicatorData.statusProps = { 85 | ...Statuses.Warning, 86 | ariaLabel: "Incomplete", 87 | color: iconColors.yellow, 88 | }; 89 | indicatorData.label = "Incomplete"; 90 | indicatorData.classname = "icon-yellow"; 91 | break; 92 | case "canceled": 93 | indicatorData.statusProps = { 94 | ...Statuses.Canceled, 95 | ariaLabel: "Canceled", 96 | color: iconColors.gray, 97 | }; 98 | indicatorData.label = "Canceled"; 99 | indicatorData.classname = "icon-gray"; 100 | break; 101 | } 102 | return indicatorData; 103 | }; 104 | 105 | /** 106 | * Returns icon for a status 107 | * @param statusStr status string, such as succeeded, in progress etc. 108 | */ 109 | export const getIcon = (statusStr?: string): IIconProps => { 110 | if (statusStr === "succeeded" || statusStr === "success") { 111 | return { 112 | iconName: "SkypeCircleCheck", 113 | style: { color: iconColors.green }, 114 | }; 115 | } else if (statusStr === undefined || statusStr === "inProgress" || statusStr === "running") { 116 | return { iconName: "AwayStatus", style: { color: iconColors.blue } }; // SyncStatusSolid 117 | } else if (statusStr === "canceled") { 118 | return { 119 | iconName: "SkypeCircleSlash", 120 | style: { color: iconColors.gray }, 121 | }; 122 | } else if (statusStr === "waiting") { 123 | return { iconName: "AwayStatus", style: { color: iconColors.purple } }; 124 | } 125 | return { iconName: "StatusErrorFull", style: { color: iconColors.red } }; 126 | }; 127 | -------------------------------------------------------------------------------- /frontend/src/cells/persona.tsx: -------------------------------------------------------------------------------- 1 | import { ITableColumn, SimpleTableCell } from "azure-devops-ui/Table"; 2 | import { Tooltip } from "azure-devops-ui/TooltipEx"; 3 | import { VssPersona } from "azure-devops-ui/VssPersona"; 4 | import * as React from "react"; 5 | import { IDeploymentField } from "../Dashboard.types"; 6 | 7 | const defaultAvatarUrl = "https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y"; 8 | /** 9 | * Interface for author cell props 10 | */ 11 | interface IPersonaProps { 12 | columnIndex: number; 13 | tableColumn: ITableColumn; 14 | deployment: IDeploymentField; 15 | name: string; 16 | imageUrl?: string; 17 | } 18 | 19 | export const Persona: React.FC = (props: IPersonaProps) => { 20 | if (!props.deployment[props.tableColumn.id]) { 21 | return ( 22 | 26 | ); 27 | } 28 | 29 | return ( 30 | 36 | 37 |
   
38 |
39 | 40 | 41 | {props.deployment[props.tableColumn.id]} 42 | 43 | 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /frontend/src/cells/simple.tsx: -------------------------------------------------------------------------------- 1 | import { ITableColumn, SimpleTableCell } from "azure-devops-ui/Table"; 2 | import { Tooltip } from "azure-devops-ui/TooltipEx"; 3 | import * as React from "react"; 4 | import "../css/dashboard.css"; 5 | import { IDeploymentField } from "../Dashboard.types"; 6 | 7 | /** 8 | * Interface for simple cell props 9 | */ 10 | interface ISimpleProps { 11 | columnIndex: number; 12 | tableColumn: ITableColumn; 13 | text?: string; 14 | className?: string; 15 | } 16 | 17 | export const Simple: React.FC = (props: ISimpleProps) => { 18 | if (!props.text) { 19 | return ( 20 | 24 | ); 25 | } 26 | return ( 27 | 35 |
36 | 37 | {props.text} 38 | 39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/src/cells/status.tsx: -------------------------------------------------------------------------------- 1 | import { Status as StatusControl, StatusSize } from "azure-devops-ui/Status"; 2 | import { ITableColumn, SimpleTableCell } from "azure-devops-ui/Table"; 3 | import * as React from "react"; 4 | import { IDeploymentField } from "../Dashboard.types"; 5 | import { getStatusIndicatorData } from "./icons"; 6 | 7 | /** 8 | * Interface for status cell props 9 | */ 10 | interface IStatusProps { 11 | columnIndex: number; 12 | tableColumn: ITableColumn; 13 | status: string; 14 | } 15 | export const Status: React.FC = (props: IStatusProps) => { 16 | if (!props.status) { 17 | return ( 18 | 22 | ); 23 | } 24 | const indicatorData = getStatusIndicatorData(props.status); 25 | return ( 26 | 32 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /frontend/src/cells/time.tsx: -------------------------------------------------------------------------------- 1 | import { Ago } from "azure-devops-ui/Ago"; 2 | import { Duration } from "azure-devops-ui/Duration"; 3 | import { 4 | ITableColumn, 5 | SimpleTableCell, 6 | TwoLineTableCell, 7 | } from "azure-devops-ui/Table"; 8 | import * as React from "react"; 9 | import { IDeploymentField } from "../Dashboard.types"; 10 | import { WithIcon } from "./icons"; 11 | 12 | /** 13 | * Interface for last updated time cell props 14 | */ 15 | interface ITimeProps { 16 | columnIndex: number; 17 | tableColumn: ITableColumn; 18 | deployment: IDeploymentField; 19 | } 20 | export const Time: React.FC = (props: ITimeProps) => { 21 | if (!props.deployment.startTime || !props.deployment.endTime) { 22 | return ( 23 | 27 | ); 28 | } 29 | return ( 30 | , 36 | className: "fontSizeM", 37 | iconProps: { iconName: "Calendar" }, 38 | })} 39 | line2={WithIcon({ 40 | children: ( 41 | 45 | ), 46 | className: "fontSizeM bolt-table-two-line-cell-item", 47 | iconProps: { iconName: "Clock" }, 48 | })} 49 | /> 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /frontend/src/css/dashboard.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | 7 | .App { 8 | max-width: 100vw; 9 | text-align: center; 10 | } 11 | 12 | .App-header { 13 | background-color: #222; 14 | color: white; 15 | position: relative; 16 | } 17 | 18 | .App-title { 19 | font-size: 1.5em; 20 | display: inline-block; 21 | } 22 | 23 | .App-last-update { 24 | float: right; 25 | height: 100%; 26 | position: absolute; 27 | right: 20px; 28 | font-size: 1em; 29 | top: 0px; 30 | } 31 | 32 | .App-last-update > div { 33 | height: 100px; 34 | display: flex; 35 | justify-content: center; 36 | align-items: center; 37 | font-family: sans-serif; 38 | max-height: 100%; 39 | } 40 | 41 | .App-intro { 42 | font-size: large; 43 | } 44 | 45 | .PrototypeTable { 46 | overflow: scroll; 47 | } 48 | 49 | .FilterBar { 50 | margin-top: -8px; 51 | } 52 | 53 | .icon-blue { 54 | color: #0a78d4; 55 | } 56 | .icon-red { 57 | color: #c8281f; 58 | } 59 | .icon-gray { 60 | color: #3b606d; 61 | } 62 | .icon-green { 63 | color: #2aa05b; 64 | } 65 | .icon-yellow { 66 | color: #e08a00; 67 | } 68 | .icon-purple { 69 | color: #5b50e2; 70 | } 71 | 72 | .italic-text { 73 | font-style: italic; 74 | } 75 | 76 | .overflow-text { 77 | white-space: nowrap; 78 | text-overflow: ellipsis; 79 | width: 100%; 80 | display: block; 81 | overflow: hidden; 82 | align-items: center; 83 | } 84 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import "./css/dashboard.css"; 4 | import Dashboard from "./Dashboard"; 5 | import registerServiceWorker from "./registerServiceWorker"; 6 | 7 | ReactDOM.render(, document.getElementById("root") as HTMLElement); 8 | registerServiceWorker(); 9 | -------------------------------------------------------------------------------- /frontend/src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-console 2 | // In production, we register a service worker to serve assets from local cache. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on the 'N+1' visit to a page, since previously 7 | // cached resources are updated in the background. 8 | 9 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 10 | // This link also includes instructions on opting out of this behavior. 11 | 12 | const isLocalhost = Boolean( 13 | window.location.hostname === "localhost" || 14 | // [::1] is the IPv6 localhost address. 15 | window.location.hostname === "[::1]" || 16 | // 127.0.0.1/8 is considered localhost for IPv4. 17 | window.location.hostname.match( 18 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 19 | ) 20 | ); 21 | 22 | export default function register() { 23 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 24 | // The URL constructor is available in all browsers that support SW. 25 | const publicUrl = new URL( 26 | process.env.PUBLIC_URL!, 27 | window.location.toString() 28 | ); 29 | if (publicUrl.origin !== window.location.origin) { 30 | // Our service worker won't work if PUBLIC_URL is on a different origin 31 | // from what our page is served on. This might happen if a CDN is used to 32 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 33 | return; 34 | } 35 | 36 | window.addEventListener("load", () => { 37 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 38 | 39 | if (isLocalhost) { 40 | // This is running on localhost. Lets check if a service worker still exists or not. 41 | checkValidServiceWorker(swUrl); 42 | 43 | // Add some additional logging to localhost, pointing developers to the 44 | // service worker/PWA documentation. 45 | navigator.serviceWorker.ready.then(() => { 46 | console.log( 47 | "This web app is being served cache-first by a service " + 48 | "worker. To learn more, visit https://goo.gl/SC7cgQ" 49 | ); 50 | }); 51 | } else { 52 | // Is not local host. Just register service worker 53 | registerValidSW(swUrl); 54 | } 55 | }); 56 | } 57 | } 58 | 59 | function registerValidSW(swUrl: string) { 60 | navigator.serviceWorker 61 | .register(swUrl) 62 | .then((registration) => { 63 | registration.onupdatefound = () => { 64 | const installingWorker = registration.installing; 65 | if (installingWorker) { 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === "installed") { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the old content will have been purged and 70 | // the fresh content will have been added to the cache. 71 | // It's the perfect time to display a 'New content is 72 | // available; please refresh.' message in your web app. 73 | console.log("New content is available; please refresh."); 74 | } else { 75 | // At this point, everything has been precached. 76 | // It's the perfect time to display a 77 | // 'Content is cached for offline use.' message. 78 | console.log("Content is cached for offline use."); 79 | } 80 | } 81 | }; 82 | } 83 | }; 84 | }) 85 | .catch((error) => { 86 | console.error("Error during service worker registration:", error); 87 | }); 88 | } 89 | 90 | function checkValidServiceWorker(swUrl: string) { 91 | // Check if the service worker can be found. If it can't reload the page. 92 | fetch(swUrl) 93 | .then((response) => { 94 | // Ensure service worker exists, and that we really are getting a JS file. 95 | if ( 96 | response.status === 404 || 97 | response.headers.get("content-type")!.indexOf("javascript") === -1 98 | ) { 99 | // No service worker found. Probably a different app. Reload the page. 100 | navigator.serviceWorker.ready.then((registration) => { 101 | registration.unregister().then(() => { 102 | window.location.reload(); 103 | }); 104 | }); 105 | } else { 106 | // Service worker found. Proceed as normal. 107 | registerValidSW(swUrl); 108 | } 109 | }) 110 | .catch(() => { 111 | console.log( 112 | "No internet connection found. App is running in offline mode." 113 | ); 114 | }); 115 | } 116 | 117 | export function unregister() { 118 | if ("serviceWorker" in navigator) { 119 | navigator.serviceWorker.ready.then((registration) => { 120 | registration.unregister(); 121 | }); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "outDir": "build", 7 | "module": "commonjs", 8 | "target": "es6", 9 | "lib": ["es2017", "es2015", "dom", "es6"], 10 | "sourceMap": true, 11 | "jsx": "react", 12 | "moduleResolution": "node", 13 | "rootDir": "src", 14 | "forceConsistentCasingInFileNames": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noImplicitAny": true, 18 | "importHelpers": true, 19 | "strictNullChecks": true, 20 | "suppressImplicitAnyIndexErrors": true, 21 | "noUnusedLocals": true, 22 | "skipLibCheck": false 23 | }, 24 | "exclude": [ 25 | "node_modules", 26 | "build", 27 | "scripts", 28 | "acceptance-tests", 29 | "webpack", 30 | "pipeline-scripts", 31 | "packages" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /frontend/tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": ".", 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "outDir": "build/dist", 8 | "module": "commonjs", 9 | "target": "es6", 10 | "lib": ["es2017", "es2015", "dom", "es6"], 11 | "sourceMap": true, 12 | "jsx": "react", 13 | "moduleResolution": "node", 14 | "rootDir": "src", 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noImplicitAny": true, 19 | "importHelpers": true, 20 | "strictNullChecks": true, 21 | "suppressImplicitAnyIndexErrors": true, 22 | "noUnusedLocals": true, 23 | "skipLibCheck": false 24 | }, 25 | "exclude": [ 26 | "node_modules", 27 | "build", 28 | "scripts", 29 | "acceptance-tests", 30 | "webpack", 31 | "src/setupTests.ts", 32 | "pipeline-scripts", 33 | "packages" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": [ 5 | "config/**/*.js", 6 | "node_modules/**/*.ts", 7 | "coverage/lcov-report/*.js" 8 | ] 9 | }, 10 | "rules": { 11 | "no-console": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /images/AADSettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/images/AADSettings.png -------------------------------------------------------------------------------- /images/AuthNAuthZ.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/images/AuthNAuthZ.png -------------------------------------------------------------------------------- /images/ContainerSettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/images/ContainerSettings.png -------------------------------------------------------------------------------- /images/spektate-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/images/spektate-diagram.png -------------------------------------------------------------------------------- /images/spektate-pieces-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/images/spektate-pieces-diagram.png -------------------------------------------------------------------------------- /images/spektate-workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/images/spektate-workflow.png -------------------------------------------------------------------------------- /images/variable_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/spektate/4b4b8f489c5c10afd7594c32d042b4f3f9a2dff5/images/variable_group.png -------------------------------------------------------------------------------- /packages/spektate/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | /venv 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /dist 14 | /lib 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /packages/spektate/.npmignore: -------------------------------------------------------------------------------- 1 | *.api.json 2 | *.config.js 3 | *.log 4 | *.nuspec 5 | *.test.* 6 | *.yml 7 | .editorconfig 8 | .gitattributes 9 | .gitignore 10 | .vscode 11 | coverage 12 | gulpfile.js 13 | images 14 | index.html 15 | jsconfig.json 16 | node_modules 17 | results 18 | src/**/* 19 | temp 20 | tsconfig.json 21 | tsd.json 22 | tslint.json 23 | typings 24 | visualtests -------------------------------------------------------------------------------- /packages/spektate/README.md: -------------------------------------------------------------------------------- 1 | # Spektate 2 | 3 | This library contains the modules that extract information from an Azure storage table containing deployments for [Bedrock](https://github.com/microsoft/bedrock)'s observability. 4 | 5 | This is currently being used as a backend to [Spektate dashboard](https://github.com/microsoft/spektate) and [CLI](https://github.com/CatalystCode/spk) and can be extended to other applications. 6 | 7 | ## Getting started 8 | 9 | To add this package to your project, run `yarn add spektate` or `npm install spektate` depending on your package manager. 10 | 11 | To get a list of all deployments, run 12 | 13 | ```ts 14 | const getPipeline = () => { 15 | return new AzureDevOpsPipeline( 16 | config.AZURE_ORG, 17 | config.AZURE_PROJECT, 18 | config.AZURE_PIPELINE_ACCESS_TOKEN 19 | ); 20 | }; 21 | 22 | const deployments: IDeployment[] = await getDeployments( 23 | config.STORAGE_ACCOUNT_NAME, 24 | config.STORAGE_ACCOUNT_KEY, 25 | config.STORAGE_TABLE_NAME, 26 | config.STORAGE_PARTITION_KEY, 27 | getPipeline(), // SRC pipeline 28 | getPipeline(), // ACR pipeline 29 | getPipeline(), // HLD pipeline 30 | undefined 31 | ); 32 | ``` 33 | 34 | To see examples of how this library is used, refer to code [here](https://github.com/microsoft/spektate/tree/master/src/backend). 35 | 36 | ## Development 37 | 38 | 1. Clone the project and run `yarn`. 39 | 2. Run `yarn build` to build the lib folder that contains all the built files 40 | 3. To publish a change, you must be logged into npm. Run `yarn publish` to publish a new version to this package. 41 | -------------------------------------------------------------------------------- /packages/spektate/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | modulePathIgnorePatterns: ["lib/"] 5 | }; 6 | -------------------------------------------------------------------------------- /packages/spektate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spektate", 3 | "version": "1.0.16", 4 | "description": "Visualization tool backend for microsoft/bedrock", 5 | "main": "lib/spektate.js", 6 | "types": "lib/src/index.d.ts", 7 | "scripts": { 8 | "build": "shx rm -rf lib && tsc -p . && webpack", 9 | "change": "beachball change", 10 | "check": "beachball check", 11 | "lint": "tslint -p tsconfig.json -c tslint.json src/**/*.ts", 12 | "publish": "beachball publish", 13 | "test": "jest --coverage --coverageReporters=cobertura --coverageReporters=html", 14 | "test-watch": "jest --watchAll" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/microsoft/spektate.git" 19 | }, 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/microsoft/spektate/issues" 23 | }, 24 | "homepage": "https://github.com/microsoft/spektate#readme", 25 | "devDependencies": { 26 | "@types/jest": "^24.0.18", 27 | "@types/jest-when": "^2.7.0", 28 | "@types/node": "^12.7.4", 29 | "beachball": "^1.13.1", 30 | "jest": "^25.1.0", 31 | "jest-when": "^2.7.0", 32 | "lint-staged": "^10.1.6", 33 | "shx": "^0.3.2", 34 | "ts-jest": "^25.2.1", 35 | "ts-loader": "^6.0.4", 36 | "tslint": "^5.20.0", 37 | "typescript": "^3.6.2", 38 | "webpack": "^4.39.3", 39 | "webpack-cli": "^3.3.8" 40 | }, 41 | "dependencies": { 42 | "axios": "^0.21.1", 43 | "azure-storage": "^2.10.3", 44 | "tslint-config-prettier": "^1.18.0" 45 | }, 46 | "lint-staged": { 47 | "*.{ts,tsx,js,jsx,css,json,md,yaml,yml}": [ 48 | "prettier --write", 49 | "git add" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/spektate/src/HttpHelper.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export class HttpHelper { 4 | public static httpGet( 5 | theUrl: string, 6 | accessToken?: string, 7 | body?: string 8 | ) { 9 | return axios 10 | .get( 11 | theUrl, 12 | accessToken 13 | ? { 14 | data: body, 15 | headers: { 16 | Authorization: 17 | "Basic " + Buffer.from(":" + accessToken).toString("base64") 18 | } 19 | } 20 | : { 21 | data: body, 22 | headers: { 23 | "Content-Type": "application/json" 24 | } 25 | } 26 | ) 27 | .catch(error => { 28 | throw error; 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/spektate/src/Validation.test.ts: -------------------------------------------------------------------------------- 1 | import * as azure from "azure-storage"; 2 | import * as fs from "fs"; 3 | import { HttpHelper } from "./HttpHelper"; 4 | import { IDeployment } from "./IDeployment"; 5 | import * as Deployment from "./IDeployment"; 6 | import { 7 | compare, 8 | duration, 9 | endTime, 10 | getDeploymentsBasedOnFilters, 11 | getRepositoryFromURL, 12 | parseDeploymentsFromDB, 13 | status 14 | } from "./IDeployment"; 15 | import { AzureDevOpsPipeline } from "./pipeline/AzureDevOpsPipeline"; 16 | import { IBuild } from "./pipeline/Build"; 17 | import IPipeline from "./pipeline/Pipeline"; 18 | import { IRelease } from "./pipeline/Release"; 19 | import { IAuthor } from "./repository/Author"; 20 | import { IAzureDevOpsRepo } from "./repository/IAzureDevOpsRepo"; 21 | import * as AzureDevOpsRepo from "./repository/IAzureDevOpsRepo"; 22 | import { IGitHub } from "./repository/IGitHub"; 23 | import * as GitHub from "./repository/IGitHub"; 24 | import { IPullRequest } from "./repository/IPullRequest"; 25 | import { 26 | createPipeline, 27 | IErrors, 28 | IValidationError, 29 | validateConfiguration, 30 | verifyManifestRepo, 31 | verifyPipeline, 32 | verifySourceRepoAccess, 33 | verifyStorageCredentials 34 | } from "./Validation"; 35 | const mockDirectory = "src/mocks/"; 36 | let rawDeployments: IDeployment[]; 37 | 38 | // Declare these with a test name since response is mocked 39 | const srcPipeline = new AzureDevOpsPipeline("test-org", "test-project"); 40 | const hldPipeline = new AzureDevOpsPipeline("test-org", "test-project"); 41 | const clusterPipeline = new AzureDevOpsPipeline("test-org", "test-project"); 42 | const dummyAuthor = { 43 | imageUrl: "", 44 | name: "", 45 | url: "", 46 | username: "" 47 | }; 48 | const dummyPR = { 49 | description: "", 50 | id: 0, 51 | sourceBranch: "", 52 | targetBranch: "", 53 | title: "", 54 | url: "" 55 | }; 56 | 57 | jest 58 | .spyOn(AzureDevOpsPipeline.prototype, "getBuildStages") 59 | .mockReturnValue(Promise.resolve({})); 60 | jest 61 | .spyOn(AzureDevOpsPipeline.prototype, "getListOfReleases") 62 | .mockReturnValue(Promise.resolve({})); 63 | jest 64 | .spyOn(AzureDevOpsPipeline.prototype, "getListOfBuilds") 65 | .mockReturnValue(Promise.resolve({})); 66 | jest.spyOn(GitHub, "getAuthor").mockReturnValue(Promise.resolve(dummyAuthor)); 67 | jest 68 | .spyOn(AzureDevOpsRepo, "getAuthor") 69 | .mockReturnValue(Promise.resolve(dummyAuthor)); 70 | jest.spyOn(GitHub, "getPullRequest").mockReturnValue(Promise.resolve(dummyPR)); 71 | jest 72 | .spyOn(AzureDevOpsRepo, "getPullRequest") 73 | .mockReturnValue(Promise.resolve(dummyPR)); 74 | 75 | jest.spyOn(Deployment, "cleanUpDeploymentsFromDB").mockReturnValue(); 76 | 77 | beforeAll(() => { 78 | rawDeployments = JSON.parse( 79 | fs.readFileSync(mockDirectory + "deployments.json", "utf-8") 80 | ); 81 | srcPipeline.builds = JSON.parse( 82 | fs.readFileSync(mockDirectory + "ci-builds.json", "utf-8") 83 | ); 84 | hldPipeline.releases = JSON.parse( 85 | fs.readFileSync(mockDirectory + "cd-releases.json", "utf-8") 86 | ); 87 | clusterPipeline.builds = JSON.parse( 88 | fs.readFileSync(mockDirectory + "hld-builds.json", "utf-8") 89 | ); 90 | parseDeploymentsFromDB( 91 | rawDeployments, 92 | srcPipeline, 93 | hldPipeline, 94 | clusterPipeline, 95 | "account-name", 96 | "account-key", 97 | "table-name", 98 | value => { 99 | jest 100 | .spyOn(Deployment, "getDeployments") 101 | .mockReturnValue(Promise.resolve(value as IDeployment[])); 102 | }, 103 | // tslint:disable-next-line: no-empty 104 | () => {} 105 | ); 106 | updatePipelineDates(); 107 | }); 108 | 109 | describe("Deployment", () => { 110 | test("fetch PR", async () => { 111 | jest.spyOn(Deployment, "getDeployments").mockResolvedValue(rawDeployments); 112 | await validateConfiguration( 113 | "account-name", 114 | "account-key", 115 | "table-name", 116 | "partition-key", 117 | "org", 118 | "", 119 | "pipeline-token", 120 | "source-repo-token", 121 | "manifest-repo", 122 | "manifest-token", 123 | "github-org" 124 | ).then(async (e: IErrors) => { 125 | expect(e.errors).toHaveLength(3); 126 | 127 | await verifyManifestRepo("manifest-repo", "pat", "org", "project") 128 | .then(async (e1: IValidationError | undefined) => { 129 | expect(e1).toBeDefined(); 130 | 131 | await verifyManifestRepo("", "pat", "org", "project").then( 132 | async (e2: IValidationError | undefined) => { 133 | expect(e2).toBeDefined(); 134 | 135 | await verifyPipeline("", "", "").then( 136 | async (e3: IValidationError | undefined) => { 137 | expect(e3).toBeDefined(); 138 | 139 | jest 140 | .spyOn(Deployment, "fetchAuthor") 141 | .mockReturnValueOnce(Promise.resolve(undefined)); 142 | await verifySourceRepoAccess( 143 | "account-name", 144 | "account-key", 145 | "table-name", 146 | "partition-key", 147 | "source-repo-token", 148 | srcPipeline, 149 | hldPipeline, 150 | clusterPipeline 151 | ).then((e4: IValidationError | undefined) => { 152 | expect(e4).toBeDefined(); 153 | }); 154 | } 155 | ); 156 | } 157 | ); 158 | }) 159 | .catch(_ => { 160 | expect(true).toBeFalsy(); 161 | }); 162 | }); 163 | }, 30000); 164 | }); 165 | 166 | const updateDatesOnPipeline = (oBuilds: { 167 | [id: string]: IBuild | IRelease; 168 | }) => { 169 | for (const id in oBuilds) { 170 | if (id) { 171 | const item = oBuilds[id]; 172 | item.finishTime = new Date(item.finishTime); 173 | item.startTime = new Date(item.startTime); 174 | item.queueTime = new Date(item.queueTime); 175 | if (item.lastUpdateTime) { 176 | item.lastUpdateTime = new Date(item.lastUpdateTime); 177 | } 178 | } 179 | } 180 | }; 181 | 182 | // Since pipelines are coming from mock json, they need to be converted to date formats 183 | const updatePipelineDates = () => { 184 | updateDatesOnPipeline(srcPipeline.builds); 185 | updateDatesOnPipeline(hldPipeline.releases); 186 | updateDatesOnPipeline(clusterPipeline.builds); 187 | }; 188 | -------------------------------------------------------------------------------- /packages/spektate/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./IDeployment"; 2 | export * from "./HttpHelper"; 3 | export * from "./pipeline"; 4 | export * from "./repository"; 5 | export * from "./Validation"; 6 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/AzureDevOpsPipeline.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import * as fs from "fs"; 3 | import { HttpHelper } from "../HttpHelper"; 4 | import { AzureDevOpsPipeline } from "./AzureDevOpsPipeline"; 5 | import { IBuilds } from "./Pipeline"; 6 | 7 | const mockDirectory = "src/pipeline/mocks/"; 8 | 9 | // Declare these with a test name since response is mocked 10 | const buildPipeline = new AzureDevOpsPipeline("test-org", "test-project"); 11 | let currentRawResponse = {}; 12 | const releasePipeline = new AzureDevOpsPipeline("test-org", "test-project"); 13 | 14 | jest.spyOn(HttpHelper, "httpGet").mockImplementation( 15 | (theUrl: string, accessToken?: string): Promise> => { 16 | return new Promise(resolve => { 17 | const response: AxiosResponse = { 18 | config: {}, 19 | data: currentRawResponse, 20 | headers: "", 21 | status: 200, 22 | statusText: "" 23 | }; 24 | resolve(response); 25 | }); 26 | } 27 | ); 28 | 29 | describe("Pipeline", () => { 30 | test("Gets builds, releases and stages correctly", async () => { 31 | currentRawResponse = { 32 | value: JSON.parse( 33 | fs.readFileSync(mockDirectory + "raw-builds.json", "utf-8") 34 | ) 35 | }; 36 | 37 | // empty set 38 | const result = await buildPipeline.getListOfBuilds(new Set()); 39 | expect(result).toStrictEqual({}); 40 | 41 | await buildPipeline.getListOfBuilds(new Set(["7271", "7176"])); 42 | expect(Object.keys(buildPipeline.builds)).toHaveLength(2); 43 | 44 | await buildPipeline.getListOfBuilds(); 45 | expect(Object.keys(buildPipeline.builds)).toHaveLength(2); 46 | 47 | currentRawResponse = JSON.parse( 48 | fs.readFileSync(mockDirectory + "raw-build-stages.json", "utf-8") 49 | ); 50 | 51 | await buildPipeline.getBuildStages(buildPipeline.builds["7271"]); 52 | expect(buildPipeline.builds["7271"].stages).toBeDefined(); 53 | }); 54 | }); 55 | 56 | describe("Pipeline", () => { 57 | test("Gets releases correctly", async () => { 58 | currentRawResponse = { 59 | value: JSON.parse( 60 | fs.readFileSync(mockDirectory + "raw-releases.json", "utf-8") 61 | ) 62 | }; 63 | const result = await releasePipeline.getListOfReleases(new Set()); 64 | expect(result).toStrictEqual({}); 65 | 66 | await releasePipeline.getListOfReleases( 67 | new Set(["261", "262", "263", "264"]) 68 | ); 69 | expect(Object.keys(releasePipeline.releases)).toHaveLength(4); 70 | 71 | await releasePipeline.getListOfReleases(); 72 | expect(Object.keys(releasePipeline.releases)).toHaveLength(4); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/Build.ts: -------------------------------------------------------------------------------- 1 | import { IAzureDevOpsRepo } from "../repository/IAzureDevOpsRepo"; 2 | import { IGitHub } from "../repository/IGitHub"; 3 | import { IGitlabRepo } from "../repository/IGitlabRepo"; 4 | import { IPipelineStages } from "./PipelineStage"; 5 | 6 | export interface IBuild { 7 | buildNumber: string; 8 | id: string; 9 | author: string; 10 | queueTime: Date; 11 | result: string; 12 | status: string; 13 | sourceBranch: string; 14 | sourceVersion: string; 15 | sourceVersionURL: string; 16 | startTime: Date; 17 | finishTime: Date; 18 | URL: string; 19 | repository?: IGitHub | IAzureDevOpsRepo | IGitlabRepo; 20 | lastUpdateTime?: Date; 21 | timelineURL: string; 22 | stages?: IPipelineStages; 23 | } 24 | 25 | export const copy = (build: IBuild): IBuild | undefined => { 26 | if (!build) { 27 | return undefined; 28 | } 29 | const newBuild: IBuild = { 30 | URL: build.URL, 31 | author: build.author, 32 | buildNumber: build.buildNumber, 33 | finishTime: build.finishTime, 34 | id: build.id, 35 | lastUpdateTime: build.lastUpdateTime, 36 | queueTime: build.queueTime, 37 | repository: build.repository, 38 | result: build.result, 39 | sourceBranch: build.sourceBranch, 40 | sourceVersion: build.sourceVersion, 41 | sourceVersionURL: build.sourceVersionURL, 42 | stages: build.stages, 43 | startTime: build.startTime, 44 | status: build.status, 45 | timelineURL: build.timelineURL 46 | }; 47 | return newBuild; 48 | }; 49 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/GithubActions.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import * as fs from "fs"; 3 | import { HttpHelper } from "../HttpHelper"; 4 | import { GithubActions } from "./GithubActions"; 5 | 6 | const mockDirectory = "src/pipeline/mocks/"; 7 | 8 | describe("Github actions pipeline", () => { 9 | test("gets builds and releases", async () => { 10 | let pipeline = new GithubActions("source-repo", "accesstoken"); 11 | const jobsResponse: { [id: string]: any } = { 12 | "1115488431": JSON.parse( 13 | fs.readFileSync(mockDirectory + "gh-actions-1115488431.json", "utf-8") 14 | ), 15 | "1255355142": JSON.parse( 16 | fs.readFileSync(mockDirectory + "gh-actions-1255355142.json", "utf-8") 17 | ), 18 | }; 19 | 20 | jest.spyOn(HttpHelper, "httpGet").mockImplementation( 21 | (theUrl: string, accessToken?: string): Promise> => { 22 | const jobNumber = theUrl.split("/").slice(-1)[0]; 23 | if (jobNumber in jobsResponse) { 24 | return new Promise(resolve => { 25 | const response: AxiosResponse = { 26 | config: {}, 27 | data: jobsResponse[jobNumber], 28 | headers: "", 29 | status: 200, 30 | statusText: "" 31 | }; 32 | resolve(response); 33 | }); 34 | } 35 | 36 | throw Error(`Job id ${jobNumber} not found`); 37 | } 38 | ); 39 | 40 | const builds = await pipeline.getListOfBuilds(new Set()); 41 | expect(builds).toStrictEqual({}); 42 | 43 | await pipeline.getListOfBuilds(new Set(["1115488431"])); 44 | expect(Object.keys(pipeline.builds)).toHaveLength(1); 45 | expect(Object.keys(pipeline.builds).includes("1115488431")); 46 | 47 | await pipeline.getListOfBuilds(new Set(["1115488431", "1255355142"])); 48 | expect(Object.keys(pipeline.builds)).toHaveLength(2); 49 | expect(Object.keys(pipeline.builds).includes("1115488431")); 50 | expect(Object.keys(pipeline.builds).includes("1255355142")); 51 | 52 | pipeline = new GithubActions("source-repo", "accesstoken"); 53 | 54 | await pipeline.getListOfReleases(new Set()); 55 | expect(pipeline.releases).toStrictEqual({}); 56 | 57 | await pipeline.getListOfReleases(new Set(["1115488431", "1255355142"])); 58 | expect(Object.keys(pipeline.releases)).toHaveLength(2); 59 | expect(Object.keys(pipeline.releases).includes("1115488431")); 60 | expect(Object.keys(pipeline.releases).includes("1255355142")); 61 | }); 62 | }); -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/GithubActions.ts: -------------------------------------------------------------------------------- 1 | import { HttpHelper } from "../HttpHelper"; 2 | import { IBuild } from "./Build"; 3 | import { IBuilds, IPipeline, IReleases } from "./Pipeline"; 4 | import { IPipelineStage, IPipelineStages } from "./PipelineStage"; 5 | 6 | const jobsUrl = "https://api.github.com/repos/{repository}/actions/jobs/{jobId}"; 7 | const shaUrl = "https://github.com/{repository}/commit/{commitId}"; 8 | 9 | export class GithubActions implements IPipeline { 10 | public builds: IBuilds = {}; 11 | public releases: IReleases = {}; 12 | public sourceRepo: string = ""; 13 | public pipelineAccessToken?: string; 14 | 15 | constructor(sourceRepo: string, accessToken?: string) { 16 | this.sourceRepo = sourceRepo; 17 | this.pipelineAccessToken = accessToken; 18 | } 19 | 20 | public async getListOfBuilds(buildIds?: Set): Promise { 21 | const promises: Array> = []; 22 | const evaluateData = (data: any) => { 23 | if (!data) { 24 | throw new Error( 25 | "Data could not be fetched from Github Actions. Please check the personal access token and repo names." 26 | ); 27 | } 28 | const newBuild = { 29 | URL: data.html_url, 30 | author: "Unavailable", 31 | buildNumber: data.id, 32 | finishTime: new Date(data.completed_at), 33 | id: data.id, 34 | lastUpdateTime: new Date(data.started_at), 35 | queueTime: new Date(data.started_at), 36 | result: data.conclusion, 37 | sourceBranch: "sourceBranch", 38 | sourceVersion: data.head_sha, 39 | sourceVersionURL: shaUrl.replace("{repository}", this.sourceRepo).replace("{commitId}", data.head_sha), 40 | startTime: new Date(data.started_at), 41 | status: data.status, 42 | timelineURL: data.html_url 43 | }; 44 | this.builds[data.id] = newBuild; 45 | } 46 | try { 47 | if (buildIds) { 48 | buildIds.forEach((buildId: string) => { 49 | if (buildId && buildId.trim() !== "") { 50 | promises.push(HttpHelper.httpGet( 51 | jobsUrl.replace("{repository}", this.sourceRepo).replace("{jobId}", buildId), this.pipelineAccessToken 52 | )); 53 | } 54 | }); 55 | } 56 | 57 | await Promise.all(promises).then((data) => { 58 | data.forEach(row => { 59 | evaluateData(row.data); 60 | }); 61 | }); 62 | 63 | } catch (ex) { 64 | console.error(ex); 65 | } 66 | return this.builds; 67 | } 68 | public async getListOfReleases(releaseIds?: Set): Promise { 69 | const builds = await this.getListOfBuilds(releaseIds); 70 | if (builds) { 71 | // tslint:disable-next-line: forin 72 | for (const id in builds) { 73 | this.releases[id] = { 74 | releaseName: id, 75 | // tslint:disable-next-line: object-literal-sort-keys 76 | id, 77 | imageVersion: "test-image", 78 | queueTime: builds[id].queueTime, 79 | status: builds[id].status, 80 | result: builds[id].result, 81 | startTime: builds[id].startTime, 82 | finishTime: builds[id].finishTime, 83 | URL: builds[id].URL, 84 | lastUpdateTime: builds[id].lastUpdateTime 85 | } 86 | } 87 | } 88 | 89 | return this.releases; 90 | } 91 | public async getBuildStages(build: IBuild): Promise { 92 | return {}; 93 | }; 94 | } 95 | 96 | export default GithubActions; 97 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/GitlabPipeline.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import * as fs from "fs"; 3 | import { HttpHelper } from "../HttpHelper"; 4 | import { GitlabPipeline } from "./GitlabPipeline"; 5 | 6 | const mockDirectory = "src/pipeline/mocks/"; 7 | 8 | describe("Gitlab pipeline", () => { 9 | test("gets builds and releases", async () => { 10 | let pipeline = new GitlabPipeline("4738978697"); 11 | const jobsResponse: { [id: string]: any } = { 12 | "208859532": JSON.parse( 13 | fs.readFileSync(mockDirectory + "gitlab-pipeline-1.json", "utf-8") 14 | ), 15 | "208955061": JSON.parse( 16 | fs.readFileSync(mockDirectory + "gitlab-pipeline-2.json", "utf-8") 17 | ), 18 | }; 19 | 20 | jest.spyOn(HttpHelper, "httpGet").mockImplementation( 21 | (theUrl: string, accessToken?: string): Promise> => { 22 | const jobNumber = theUrl.split("/").slice(-1)[0]; 23 | if (jobNumber in jobsResponse) { 24 | return new Promise(resolve => { 25 | const response: AxiosResponse = { 26 | config: {}, 27 | data: jobsResponse[jobNumber], 28 | headers: "", 29 | status: 200, 30 | statusText: "" 31 | }; 32 | resolve(response); 33 | }); 34 | } 35 | 36 | throw Error(`Job id ${jobNumber} not found`); 37 | } 38 | ); 39 | 40 | const builds = await pipeline.getListOfBuilds(new Set()); 41 | expect(builds).toStrictEqual({}); 42 | 43 | await pipeline.getListOfBuilds(new Set(["208859532"])); 44 | expect(Object.keys(pipeline.builds)).toHaveLength(1); 45 | expect(Object.keys(pipeline.builds).includes("208859532")); 46 | 47 | await pipeline.getListOfBuilds(new Set(["208859532", "208955061"])); 48 | expect(Object.keys(pipeline.builds)).toHaveLength(2); 49 | expect(Object.keys(pipeline.builds).includes("208859532")); 50 | expect(Object.keys(pipeline.builds).includes("208955061")); 51 | 52 | pipeline = new GitlabPipeline("4738978697"); 53 | 54 | await pipeline.getListOfReleases(new Set()); 55 | expect(pipeline.releases).toStrictEqual({}); 56 | 57 | await pipeline.getListOfReleases(new Set(["208859532", "208955061"])); 58 | expect(Object.keys(pipeline.releases)).toHaveLength(2); 59 | expect(Object.keys(pipeline.releases).includes("208859532")); 60 | expect(Object.keys(pipeline.releases).includes("208955061")); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/GitlabPipeline.ts: -------------------------------------------------------------------------------- 1 | import { HttpHelper } from "../HttpHelper"; 2 | import { IBuild } from "./Build"; 3 | import { IBuilds, IPipeline, IReleases } from "./Pipeline"; 4 | import { IPipelineStage, IPipelineStages } from "./PipelineStage"; 5 | 6 | const pipelinesApi = "https://gitlab.com/api/v4/projects/{projectId}/pipelines/{pipelineId}"; 7 | 8 | export class GitlabPipeline implements IPipeline { 9 | public builds: IBuilds = {}; 10 | public releases: IReleases = {}; 11 | public repoProjectId: string = ""; 12 | public pipelineAccessToken?: string; 13 | 14 | constructor(repoProjectId: string, accessToken?: string) { 15 | this.repoProjectId = repoProjectId; 16 | this.pipelineAccessToken = accessToken; 17 | } 18 | public async getListOfBuilds(buildIds?: Set): Promise { 19 | const promises: Array> = []; 20 | const evaluateData = (data: any) => { 21 | if (!data) { 22 | throw new Error( 23 | "Data could not be fetched from Github Actions. Please check the personal access token and repo names." 24 | ); 25 | } 26 | const sourceVersionUrl = data.web_url ? data.web_url.replace("pipelines", "commit").replace(data.web_url.split("/").pop(), "") + data.sha : ""; 27 | const newBuild: IBuild = { 28 | URL: data.web_url, 29 | author: "Unavailable", 30 | buildNumber: data.id, 31 | finishTime: new Date(data.finished_at), 32 | id: data.id, 33 | lastUpdateTime: new Date(data.updated_at), 34 | queueTime: new Date(data.started_at), 35 | result: data.status, 36 | sourceBranch: data.ref, 37 | sourceVersion: data.sha, 38 | sourceVersionURL: sourceVersionUrl, 39 | startTime: new Date(data.started_at), 40 | status: data.status, 41 | timelineURL: data.web_url, 42 | // tslint:disable-next-line: object-literal-sort-keys 43 | repository: { 44 | projectId: this.repoProjectId 45 | } 46 | }; 47 | this.builds[data.id] = newBuild; 48 | } 49 | try { 50 | if (buildIds) { 51 | buildIds.forEach((buildId: string) => { 52 | if (buildId && buildId.trim() !== "") { 53 | promises.push(HttpHelper.httpGet( 54 | pipelinesApi.replace("{projectId}", this.repoProjectId).replace("{pipelineId}", buildId), this.pipelineAccessToken 55 | )); 56 | } 57 | }); 58 | } 59 | 60 | await Promise.all(promises).then((data) => { 61 | data.forEach(row => { 62 | evaluateData(row.data); 63 | }); 64 | }); 65 | 66 | } catch (ex) { 67 | console.error(ex); 68 | } 69 | return this.builds; 70 | } 71 | public async getListOfReleases(releaseIds?: Set): Promise { 72 | const builds = await this.getListOfBuilds(releaseIds); 73 | if (builds) { 74 | // tslint:disable-next-line: forin 75 | for (const id in builds) { 76 | this.releases[id] = { 77 | releaseName: id, 78 | // tslint:disable-next-line: object-literal-sort-keys 79 | id, 80 | imageVersion: "test-image", 81 | queueTime: builds[id].queueTime, 82 | status: builds[id].status, 83 | result: builds[id].result, 84 | startTime: builds[id].startTime, 85 | finishTime: builds[id].finishTime, 86 | URL: builds[id].URL, 87 | lastUpdateTime: builds[id].lastUpdateTime 88 | } 89 | } 90 | } 91 | 92 | return this.releases; 93 | } 94 | public async getBuildStages(build: IBuild): Promise { 95 | return {}; 96 | }; 97 | } 98 | 99 | export default GitlabPipeline; 100 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/Pipeline.ts: -------------------------------------------------------------------------------- 1 | import { IBuild } from "./Build"; 2 | import { IPipelineStages } from "./PipelineStage"; 3 | import { IRelease } from "./Release"; 4 | 5 | export interface IBuilds { 6 | [buildId: string]: IBuild; 7 | } 8 | export interface IReleases { 9 | [releaseId: string]: IRelease; 10 | } 11 | 12 | export interface IPipeline { 13 | builds: IBuilds; 14 | releases: IReleases; 15 | getListOfBuilds: (buildIds?: Set) => Promise; 16 | getListOfReleases: (releaseIds?: Set) => Promise; 17 | getBuildStages(build: IBuild): Promise; 18 | } 19 | 20 | export default IPipeline; 21 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/PipelineStage.ts: -------------------------------------------------------------------------------- 1 | export interface IPipelineStage { 2 | name: string; 3 | id: string; 4 | state: string; 5 | result: string; 6 | order: number; 7 | } 8 | export interface IPipelineStages { 9 | [order: number]: IPipelineStage; 10 | } 11 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/Release.ts: -------------------------------------------------------------------------------- 1 | export interface IRelease { 2 | releaseName: string; 3 | id: string; 4 | imageVersion?: string; 5 | registryURL?: string; 6 | registryResourceGroup?: string; 7 | queueTime: Date; 8 | status: string; 9 | result?: string; 10 | startTime: Date; 11 | finishTime: Date; 12 | URL: string; 13 | lastUpdateTime?: Date; 14 | } 15 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./AzureDevOpsPipeline"; 2 | export * from "./Build"; 3 | export * from "./Pipeline"; 4 | export * from "./Release"; 5 | export * from "./GithubActions"; 6 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/mocks/gh-actions-1115488431.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1115488431, 3 | "run_id": 254907244, 4 | "run_url": "https://api.github.com/repos/andrebriggs/spartan-app/actions/runs/254907244", 5 | "node_id": "MDg6Q2hlY2tSdW4xMTE1NDg4NDMx", 6 | "head_sha": "32b0b95f4ca771dee6808a17415d48790f62c6a9", 7 | "url": "https://api.github.com/repos/andrebriggs/spartan-app/actions/jobs/1115488431", 8 | "html_url": "https://github.com/andrebriggs/spartan-app/runs/1115488431", 9 | "status": "completed", 10 | "conclusion": "success", 11 | "started_at": "2020-09-15T02:29:56Z", 12 | "completed_at": "2020-09-15T02:33:38Z", 13 | "name": "Build", 14 | "steps": [ 15 | { 16 | "name": "Set up job", 17 | "status": "completed", 18 | "conclusion": "success", 19 | "number": 1, 20 | "started_at": "2020-09-14T19:29:56.000-07:00", 21 | "completed_at": "2020-09-14T19:30:03.000-07:00" 22 | }, 23 | { 24 | "name": "Set current date as env variable", 25 | "status": "completed", 26 | "conclusion": "success", 27 | "number": 2, 28 | "started_at": "2020-09-14T19:30:03.000-07:00", 29 | "completed_at": "2020-09-14T19:30:03.000-07:00" 30 | }, 31 | { 32 | "name": "Debug Env Vars", 33 | "status": "completed", 34 | "conclusion": "success", 35 | "number": 3, 36 | "started_at": "2020-09-14T19:30:03.000-07:00", 37 | "completed_at": "2020-09-14T19:30:04.000-07:00" 38 | }, 39 | { 40 | "name": "Set up Go 1.x", 41 | "status": "completed", 42 | "conclusion": "success", 43 | "number": 4, 44 | "started_at": "2020-09-14T19:30:04.000-07:00", 45 | "completed_at": "2020-09-14T19:30:05.000-07:00" 46 | }, 47 | { 48 | "name": "Check out code into the Go module directory", 49 | "status": "completed", 50 | "conclusion": "success", 51 | "number": 5, 52 | "started_at": "2020-09-14T19:30:05.000-07:00", 53 | "completed_at": "2020-09-14T19:30:06.000-07:00" 54 | }, 55 | { 56 | "name": "Get dependencies", 57 | "status": "completed", 58 | "conclusion": "success", 59 | "number": 6, 60 | "started_at": "2020-09-14T19:30:06.000-07:00", 61 | "completed_at": "2020-09-14T19:30:26.000-07:00" 62 | }, 63 | { 64 | "name": "Test", 65 | "status": "completed", 66 | "conclusion": "success", 67 | "number": 7, 68 | "started_at": "2020-09-14T19:30:26.000-07:00", 69 | "completed_at": "2020-09-14T19:30:39.000-07:00" 70 | }, 71 | { 72 | "name": "Run vet & lint", 73 | "status": "completed", 74 | "conclusion": "success", 75 | "number": 8, 76 | "started_at": "2020-09-14T19:30:39.000-07:00", 77 | "completed_at": "2020-09-14T19:30:41.000-07:00" 78 | }, 79 | { 80 | "name": "Azure Login", 81 | "status": "completed", 82 | "conclusion": "success", 83 | "number": 9, 84 | "started_at": "2020-09-14T19:30:41.000-07:00", 85 | "completed_at": "2020-09-14T19:30:55.000-07:00" 86 | }, 87 | { 88 | "name": "Get and Set Job Run Id", 89 | "status": "completed", 90 | "conclusion": "success", 91 | "number": 10, 92 | "started_at": "2020-09-14T19:30:55.000-07:00", 93 | "completed_at": "2020-09-14T19:30:55.000-07:00" 94 | }, 95 | { 96 | "name": "Update Spektate storage", 97 | "status": "completed", 98 | "conclusion": "success", 99 | "number": 11, 100 | "started_at": "2020-09-14T19:30:55.000-07:00", 101 | "completed_at": "2020-09-14T19:31:13.000-07:00" 102 | }, 103 | { 104 | "name": "Azure CLI script", 105 | "status": "completed", 106 | "conclusion": "success", 107 | "number": 12, 108 | "started_at": "2020-09-14T19:31:13.000-07:00", 109 | "completed_at": "2020-09-14T19:33:38.000-07:00" 110 | }, 111 | { 112 | "name": "Post Check out code into the Go module directory", 113 | "status": "completed", 114 | "conclusion": "success", 115 | "number": 24, 116 | "started_at": "2020-09-14T19:33:38.000-07:00", 117 | "completed_at": "2020-09-14T19:33:38.000-07:00" 118 | }, 119 | { 120 | "name": "Complete job", 121 | "status": "completed", 122 | "conclusion": "success", 123 | "number": 25, 124 | "started_at": "2020-09-14T19:33:38.000-07:00", 125 | "completed_at": "2020-09-14T19:33:38.000-07:00" 126 | } 127 | ], 128 | "check_run_url": "https://api.github.com/repos/andrebriggs/spartan-app/check-runs/1115488431" 129 | } 130 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/mocks/gh-actions-1255355142.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1255355142, 3 | "run_id": 307160224, 4 | "run_url": "https://api.github.com/repos/samiyaakhtar/hello-world-full-stack/actions/runs/307160224", 5 | "node_id": "MDg6Q2hlY2tSdW4xMjU1MzU1MTQy", 6 | "head_sha": "18df2621e7f62b87dbcd6d534deb1ff3cd1b3116", 7 | "url": "https://api.github.com/repos/samiyaakhtar/hello-world-full-stack/actions/jobs/1255355142", 8 | "html_url": "https://github.com/samiyaakhtar/hello-world-full-stack/runs/1255355142", 9 | "status": "completed", 10 | "conclusion": "success", 11 | "started_at": "2020-10-14T19:20:03Z", 12 | "completed_at": "2020-10-14T19:21:43Z", 13 | "name": "Build", 14 | "steps": [ 15 | { 16 | "name": "Set up job", 17 | "status": "completed", 18 | "conclusion": "success", 19 | "number": 1, 20 | "started_at": "2020-10-14T12:20:03.000-07:00", 21 | "completed_at": "2020-10-14T12:20:14.000-07:00" 22 | }, 23 | { 24 | "name": "Set current date as env variable", 25 | "status": "completed", 26 | "conclusion": "success", 27 | "number": 2, 28 | "started_at": "2020-10-14T12:20:14.000-07:00", 29 | "completed_at": "2020-10-14T12:20:14.000-07:00" 30 | }, 31 | { 32 | "name": "Debug Env Vars", 33 | "status": "completed", 34 | "conclusion": "success", 35 | "number": 3, 36 | "started_at": "2020-10-14T12:20:14.000-07:00", 37 | "completed_at": "2020-10-14T12:20:15.000-07:00" 38 | }, 39 | { 40 | "name": "Set up Go 1.x", 41 | "status": "completed", 42 | "conclusion": "success", 43 | "number": 4, 44 | "started_at": "2020-10-14T12:20:15.000-07:00", 45 | "completed_at": "2020-10-14T12:20:16.000-07:00" 46 | }, 47 | { 48 | "name": "Check out code into the Go module directory", 49 | "status": "completed", 50 | "conclusion": "success", 51 | "number": 5, 52 | "started_at": "2020-10-14T12:20:16.000-07:00", 53 | "completed_at": "2020-10-14T12:20:16.000-07:00" 54 | }, 55 | { 56 | "name": "Azure Login", 57 | "status": "completed", 58 | "conclusion": "success", 59 | "number": 6, 60 | "started_at": "2020-10-14T12:20:16.000-07:00", 61 | "completed_at": "2020-10-14T12:20:33.000-07:00" 62 | }, 63 | { 64 | "name": "Get and Set Job Run Id", 65 | "status": "completed", 66 | "conclusion": "success", 67 | "number": 7, 68 | "started_at": "2020-10-14T12:20:33.000-07:00", 69 | "completed_at": "2020-10-14T12:20:33.000-07:00" 70 | }, 71 | { 72 | "name": "Update Spektate storage", 73 | "status": "completed", 74 | "conclusion": "success", 75 | "number": 8, 76 | "started_at": "2020-10-14T12:20:33.000-07:00", 77 | "completed_at": "2020-10-14T12:20:49.000-07:00" 78 | }, 79 | { 80 | "name": "Azure CLI script", 81 | "status": "completed", 82 | "conclusion": "success", 83 | "number": 9, 84 | "started_at": "2020-10-14T12:20:49.000-07:00", 85 | "completed_at": "2020-10-14T12:21:43.000-07:00" 86 | }, 87 | { 88 | "name": "Post Check out code into the Go module directory", 89 | "status": "completed", 90 | "conclusion": "success", 91 | "number": 18, 92 | "started_at": "2020-10-14T12:21:43.000-07:00", 93 | "completed_at": "2020-10-14T12:21:43.000-07:00" 94 | }, 95 | { 96 | "name": "Complete job", 97 | "status": "completed", 98 | "conclusion": "success", 99 | "number": 19, 100 | "started_at": "2020-10-14T12:21:43.000-07:00", 101 | "completed_at": "2020-10-14T12:21:43.000-07:00" 102 | } 103 | ], 104 | "check_run_url": "https://api.github.com/repos/samiyaakhtar/hello-world-full-stack/check-runs/1255355142" 105 | } 106 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/mocks/gitlab-pipeline-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 208859532, 3 | "sha": "de399bc427cec3e0ddf67c8f59f4f0a7fc36893b", 4 | "ref": "master", 5 | "status": "success", 6 | "created_at": "2020-10-28T17:36:53.885Z", 7 | "updated_at": "2020-10-28T17:43:48.925Z", 8 | "web_url": "https://gitlab.com/samiyaakhtar/hello-world-full-stack/-/pipelines/208859532", 9 | "before_sha": "0000000000000000000000000000000000000000", 10 | "tag": false, 11 | "yaml_errors": null, 12 | "user": { 13 | "id": 1384451, 14 | "name": "samiya akhtar", 15 | "username": "samiyaakhtar", 16 | "state": "active", 17 | "avatar_url": "https://secure.gravatar.com/avatar/890514c6e4e1d88993e8d6e65383cd57?s=80\u0026d=identicon", 18 | "web_url": "https://gitlab.com/samiyaakhtar" 19 | }, 20 | "started_at": "2020-10-28T17:36:55.584Z", 21 | "finished_at": "2020-10-28T17:43:48.917Z", 22 | "committed_at": null, 23 | "duration": 412, 24 | "coverage": null, 25 | "detailed_status": { 26 | "icon": "status_success", 27 | "text": "passed", 28 | "label": "passed", 29 | "group": "success", 30 | "tooltip": "passed", 31 | "has_details": true, 32 | "details_path": "/samiyaakhtar/hello-world-full-stack/-/pipelines/208859532", 33 | "illustration": null, 34 | "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/spektate/src/pipeline/mocks/gitlab-pipeline-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 208955061, 3 | "sha": "81533e9b482d5aa32bb5897d3973389f476391a3", 4 | "ref": "master", 5 | "status": "success", 6 | "created_at": "2020-10-28T22:31:11.354Z", 7 | "updated_at": "2020-10-28T22:37:27.662Z", 8 | "web_url": "https://gitlab.com/samiyaakhtar/hello-world-full-stack/-/pipelines/208955061", 9 | "before_sha": "0000000000000000000000000000000000000000", 10 | "tag": false, 11 | "yaml_errors": null, 12 | "user": { 13 | "id": 1384451, 14 | "name": "samiya akhtar", 15 | "username": "samiyaakhtar", 16 | "state": "active", 17 | "avatar_url": "https://secure.gravatar.com/avatar/890514c6e4e1d88993e8d6e65383cd57?s=80\u0026d=identicon", 18 | "web_url": "https://gitlab.com/samiyaakhtar" 19 | }, 20 | "started_at": "2020-10-28T22:31:13.609Z", 21 | "finished_at": "2020-10-28T22:37:27.656Z", 22 | "committed_at": null, 23 | "duration": 371, 24 | "coverage": null, 25 | "detailed_status": { 26 | "icon": "status_success", 27 | "text": "passed", 28 | "label": "passed", 29 | "group": "success", 30 | "tooltip": "passed", 31 | "has_details": true, 32 | "details_path": "/samiyaakhtar/hello-world-full-stack/-/pipelines/208955061", 33 | "illustration": null, 34 | "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/Author.ts: -------------------------------------------------------------------------------- 1 | export interface IAuthor { 2 | url: string; 3 | name: string; 4 | username: string; 5 | imageUrl: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/IAzureDevOpsRepo.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | import { HttpHelper } from "../HttpHelper"; 5 | import { IAuthor } from "./Author"; 6 | import { 7 | getAuthor, 8 | getManifestSyncState, 9 | getPullRequest, 10 | getReleasesURL, 11 | IAzureDevOpsRepo 12 | } from "./IAzureDevOpsRepo"; 13 | import { ITag } from "./Tag"; 14 | 15 | let authorRawResponse = {}; 16 | let syncTagRawResponse = {}; 17 | let manifestSyncTagResponse = {}; 18 | let prRawResponse = {}; 19 | const mockDirectory = path.join("src", "repository", "mocks"); 20 | const repo: IAzureDevOpsRepo = { 21 | org: "org", 22 | project: "project", 23 | repo: "repo" 24 | }; 25 | 26 | beforeAll(() => { 27 | authorRawResponse = JSON.parse( 28 | fs.readFileSync( 29 | path.join(mockDirectory, "azdo-author-response.json"), 30 | "utf-8") 31 | ); 32 | syncTagRawResponse = JSON.parse( 33 | fs.readFileSync( 34 | path.join(mockDirectory, "azdo-sync-response.json"), 35 | "utf-8") 36 | ); 37 | manifestSyncTagResponse = JSON.parse( 38 | fs.readFileSync( 39 | path.join(mockDirectory, "azdo-manifest-sync-tag-response.json"), 40 | "utf-8" 41 | ) 42 | ); 43 | prRawResponse = JSON.parse( 44 | fs.readFileSync( 45 | path.join(mockDirectory, "azdo-pr-response.json"), 46 | "utf-8") 47 | ); 48 | }); 49 | 50 | const mockedFunction = (theUrl: string, accessToken?: string): Promise> => { 51 | if (theUrl.includes("commits")) { 52 | return getAxiosResponseForObject(authorRawResponse); 53 | } else if (theUrl.includes("annotatedtags")) { 54 | return getAxiosResponseForObject(manifestSyncTagResponse); 55 | } else if (theUrl.includes("pullrequests")) { 56 | return getAxiosResponseForObject(prRawResponse); 57 | } 58 | return getAxiosResponseForObject(syncTagRawResponse); 59 | }; 60 | 61 | const mockedEmptyResponse = (theUrl: string, accessToken?: string): Promise> => { 62 | if (theUrl.endsWith("annotatedtags")) { 63 | return getAxiosResponseForObject([]); 64 | } else if (theUrl.includes("pullrequests")) { 65 | return getAxiosResponseForObject(undefined); 66 | } 67 | return getAxiosResponseForObject([]); 68 | }; 69 | 70 | const mockedErrorResponse = (theUrl: string, accessToken?: string): Promise> => { 71 | if (theUrl.endsWith("annotatedtags")) { 72 | return getAxiosResponseForObject([]); 73 | } else if (theUrl.includes("pullrequests")) { 74 | throw new Error("Request failed with Network error"); 75 | } 76 | return getAxiosResponseForObject([]); 77 | }; 78 | 79 | describe("IAzureDevOpsRepo", () => { 80 | test("gets author correctly", async () => { 81 | jest.spyOn(HttpHelper, "httpGet").mockImplementationOnce(mockedFunction); 82 | const author = await getAuthor(repo, "commit"); 83 | expect(author).toBeDefined(); 84 | expect(author!.name).toBe("Samiya Akhtar"); 85 | expect(author!.url).toBeDefined(); 86 | expect(author!.username).toBe("saakhta@microsoft.com"); 87 | expect(author!.imageUrl).toBeTruthy(); 88 | }); 89 | }); 90 | 91 | describe("IAzureDevOpsRepo", () => { 92 | test("gets PR correctly", async () => { 93 | jest.spyOn(HttpHelper, "httpGet").mockImplementationOnce(mockedFunction); 94 | const pr = await getPullRequest(repo, "prid"); 95 | expect(pr).toBeDefined(); 96 | expect(pr.mergedBy).toBeDefined(); 97 | expect(pr!.mergedBy!.name).toBe("Samiya Akhtar"); 98 | expect(pr!.mergedBy!.username).toBe("saakhta@microsoft.com"); 99 | expect(pr!.mergedBy!.url).toBeDefined(); 100 | expect(pr!.mergedBy!.imageUrl).toBeDefined(); 101 | expect(pr!.url).toBeDefined(); 102 | expect(pr!.title).toBe( 103 | "Updating samiya.frontend image tag to master-20200317.12." 104 | ); 105 | expect(pr!.sourceBranch).toBe( 106 | "DEPLOY/samiya2019-samiya.frontend-master-20200317.12" 107 | ); 108 | expect(pr!.targetBranch).toBe("master"); 109 | expect(pr!.id).toBe(1354); 110 | expect(pr!.description).toBeDefined(); 111 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 112 | }); 113 | test("negative tests", async () => { 114 | jest.spyOn(HttpHelper, "httpGet").mockImplementation(mockedEmptyResponse); 115 | let flag = 0; 116 | try { 117 | expect(await getPullRequest(repo, "prid")).toThrow(); 118 | } catch (e) { 119 | flag = 1; 120 | } 121 | expect(flag).toBe(1); 122 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 123 | 124 | jest.spyOn(HttpHelper, "httpGet").mockImplementation(mockedErrorResponse); 125 | flag = 0; 126 | try { 127 | expect(await getPullRequest(repo, "prid")).toThrow(); 128 | } catch (e) { 129 | flag = 1; 130 | } 131 | expect(flag).toBe(1); 132 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 133 | }); 134 | }); 135 | 136 | describe("IAzureDevOpsRepo", () => { 137 | test("gets manifest sync tag correctly", async () => { 138 | jest.spyOn(HttpHelper, "httpGet").mockImplementation(mockedFunction); 139 | const tags = await getManifestSyncState(repo); 140 | expect(tags).toHaveLength(1); 141 | expect(tags[0].commit).toBe("ab4c9f1"); 142 | expect(tags[0].name).toBe("SYNC"); 143 | }); 144 | test("negative tests", async () => { 145 | jest.spyOn(HttpHelper, "httpGet").mockImplementation(mockedEmptyResponse); 146 | const tags = await getManifestSyncState(repo); 147 | expect(tags).toHaveLength(0); 148 | }); 149 | }); 150 | 151 | describe("IAzureDevOpsRepo", () => { 152 | test("gets releases URL correctly", async () => { 153 | const releaseUrl = getReleasesURL(repo); 154 | expect(releaseUrl).toBe("https://dev.azure.com/org/project/_git/repo/tags"); 155 | }); 156 | }); 157 | 158 | const getAxiosResponseForObject = (obj: any): Promise> => { 159 | return new Promise(resolve => { 160 | const response: AxiosResponse = { 161 | config: {}, 162 | data: obj, 163 | headers: "", 164 | status: 200, 165 | statusText: "" 166 | }; 167 | resolve(response); 168 | }); 169 | }; 170 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/IAzureDevOpsRepo.ts: -------------------------------------------------------------------------------- 1 | import { HttpHelper } from "../HttpHelper"; 2 | import { IAuthor } from "./Author"; 3 | import { IPullRequest } from "./IPullRequest"; 4 | import { ITag } from "./Tag"; 5 | 6 | const authorInfoURL = 7 | "https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/commits/{commitId}?api-version=4.1"; 8 | const manifestSyncTagsURL = 9 | "https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/refs?filter=tags&api-version=4.1"; 10 | const manifestSyncTagURL = 11 | "https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/annotatedtags/{objectId}?api-version=4.1-preview.1"; 12 | const pullRequestURL = 13 | "https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/pullrequests/{pullRequestId}?api-version=5.1"; 14 | 15 | export interface IAzureDevOpsRepo { 16 | org: string; 17 | project: string; 18 | repo: string; 19 | } 20 | 21 | export const getReleasesURL = (repository: IAzureDevOpsRepo): string => { 22 | return ( 23 | "https://dev.azure.com/" + 24 | repository.org + 25 | "/" + 26 | repository.project + 27 | "/_git/" + 28 | repository.repo + 29 | "/tags" 30 | ); 31 | }; 32 | 33 | export const getPullRequest = ( 34 | repository: IAzureDevOpsRepo, 35 | pullRequestId: string, 36 | accessToken?: string 37 | ): Promise => { 38 | return new Promise(async (resolve, reject) => { 39 | try { 40 | const data = await HttpHelper.httpGet( 41 | pullRequestURL 42 | .replace("{organization}", repository.org) 43 | .replace("{project}", repository.project) 44 | .replace("{repositoryId}", repository.repo) 45 | .replace("{pullRequestId}", pullRequestId), 46 | accessToken 47 | ); 48 | if (data.data) { 49 | const pr = data.data; 50 | resolve({ 51 | description: pr.description, 52 | id: pr.pullRequestId, 53 | mergedBy: pr.closedBy 54 | ? { 55 | imageUrl: pr.closedBy._links?.avatar?.href 56 | ? pr.closedBy._links?.avatar?.href 57 | : pr.closedBy.imageUrl, 58 | name: pr.closedBy.displayName, 59 | url: pr.url, 60 | username: pr.closedBy.uniqueName 61 | } 62 | : undefined, 63 | sourceBranch: pr.sourceRefName 64 | ? pr.sourceRefName.replace("refs/heads/", "") 65 | : "", 66 | targetBranch: pr.targetRefName 67 | ? pr.targetRefName.replace("refs/heads/", "") 68 | : "", 69 | title: pr.title, 70 | url: 71 | pr.repository && pr.repository.webUrl 72 | ? pr.repository.webUrl + "/pullrequest/" + pr.pullRequestId 73 | : pr.url 74 | }); 75 | } else { 76 | reject("No PR was found for " + pullRequestId); 77 | } 78 | } catch (e) { 79 | reject(e); 80 | } 81 | }); 82 | }; 83 | 84 | export const getManifestSyncState = async ( 85 | repository: IAzureDevOpsRepo, 86 | accessToken?: string 87 | ): Promise => { 88 | return new Promise(async (resolve, reject) => { 89 | try { 90 | const data = await HttpHelper.httpGet( 91 | manifestSyncTagsURL 92 | .replace("{organization}", repository.org) 93 | .replace("{project}", repository.project) 94 | .replace("{repositoryId}", repository.repo), 95 | accessToken 96 | ); 97 | 98 | if (data.status !== 200) { 99 | throw new Error(data.statusText); 100 | } 101 | 102 | const tags = data.data.value; 103 | const fluxTags: ITag[] = []; 104 | if (tags != null && tags.length > 0) { 105 | for (const tag of tags) { 106 | // Check all flux sync tags 107 | if (tag.name.includes("refs/tags/flux-")) { 108 | const syncStatus = await HttpHelper.httpGet( 109 | manifestSyncTagURL 110 | .replace("{organization}", repository.org) 111 | .replace("{project}", repository.project) 112 | .replace("{repositoryId}", repository.repo) 113 | .replace("{objectId}", tag.objectId), 114 | accessToken 115 | ); 116 | 117 | if (syncStatus != null && syncStatus.data && syncStatus.data.name) { 118 | const clusterName: string = syncStatus.data.name.replace( 119 | "flux-", 120 | "" 121 | ); 122 | const manifestSync = { 123 | commit: syncStatus.data.taggedObject?.objectId?.substring(0, 7), 124 | date: new Date(syncStatus.data.taggedBy?.date), 125 | name: clusterName.toUpperCase(), 126 | tagger: syncStatus.data.taggedBy?.name 127 | }; 128 | fluxTags.push(manifestSync); 129 | } 130 | } 131 | } 132 | resolve(fluxTags); 133 | return; 134 | } 135 | // No tags were found. 136 | resolve([]); 137 | } catch (err) { 138 | reject(err); 139 | } 140 | }); 141 | }; 142 | 143 | export const getAuthor = async ( 144 | repository: IAzureDevOpsRepo, 145 | commitId: string, 146 | accessToken?: string 147 | ) => { 148 | const data = await HttpHelper.httpGet( 149 | authorInfoURL 150 | .replace("{organization}", repository.org) 151 | .replace("{project}", repository.project) 152 | .replace("{repositoryId}", repository.repo) 153 | .replace("{commitId}", commitId), 154 | accessToken 155 | ); 156 | 157 | if (data.status !== 200) { 158 | throw new Error(data.statusText); 159 | } 160 | 161 | const commitInfo = data.data; 162 | if (commitInfo && commitInfo.author) { 163 | const author: IAuthor = { 164 | imageUrl: commitInfo.author.imageUrl, 165 | name: commitInfo.author.name, 166 | url: commitInfo.author.imageUrl, 167 | username: commitInfo.author.email 168 | }; 169 | return author; 170 | } 171 | }; 172 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/IGitHub.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | import { HttpHelper } from "../HttpHelper"; 5 | import { 6 | getAuthor, 7 | getManifestSyncState, 8 | getPullRequest, 9 | getReleasesURL, 10 | IGitHub 11 | } from "./IGitHub"; 12 | 13 | let authorRawResponse = {}; 14 | let syncTagRawResponse = {}; 15 | let manifestSyncTagResponse = {}; 16 | let manifestResponse1 = {}; 17 | let prRawResponse = {}; 18 | const mockDirectory = path.join("src", "repository", "mocks"); 19 | const repo: IGitHub = { 20 | reponame: "reponame", 21 | username: "username" 22 | }; 23 | 24 | beforeAll(() => { 25 | authorRawResponse = JSON.parse( 26 | fs.readFileSync( 27 | path.join(mockDirectory, "github-author-response.json"), 28 | "utf-8" 29 | ) 30 | ); 31 | syncTagRawResponse = JSON.parse( 32 | fs.readFileSync( 33 | path.join(mockDirectory, "github-sync-response.json"), 34 | "utf-8" 35 | ) 36 | ); 37 | manifestSyncTagResponse = JSON.parse( 38 | fs.readFileSync( 39 | path.join(mockDirectory, "github-manifest-sync-tag-response.json"), 40 | "utf-8" 41 | ) 42 | ); 43 | manifestResponse1 = JSON.parse( 44 | fs.readFileSync( 45 | path.join(mockDirectory, "github-sync-response-1.json"), 46 | "utf-8" 47 | ) 48 | ); 49 | prRawResponse = JSON.parse( 50 | fs.readFileSync( 51 | path.join(mockDirectory, "github-pr-response.json"), 52 | "utf-8" 53 | ) 54 | ); 55 | }); 56 | 57 | const mockedFunction = (theUrl: string, accessToken?: string): Promise> => { 58 | if (theUrl.includes("096c95228c786715b14b0269a722a3de887c01bd")) { 59 | return getAxiosResponseForObject(manifestResponse1); 60 | } else if (theUrl.includes("commits")) { 61 | return getAxiosResponseForObject(authorRawResponse); 62 | } else if (theUrl.endsWith("refs/tags")) { 63 | return getAxiosResponseForObject(syncTagRawResponse); 64 | } else if (theUrl.includes("pulls")) { 65 | return getAxiosResponseForObject(prRawResponse); 66 | } 67 | return getAxiosResponseForObject(manifestSyncTagResponse); 68 | }; 69 | 70 | const mockedEmptyResponse = (theUrl: string, accessToken?: string): Promise> => { 71 | if (theUrl.endsWith("refs/tags")) { 72 | return getAxiosResponseForObject([]); 73 | } else if (theUrl.includes("pulls")) { 74 | return getAxiosResponseForObject(undefined); 75 | } 76 | return getAxiosResponseForObject([]); 77 | }; 78 | 79 | const mockedErrorResponse = (theUrl: string, accessToken?: string): Promise> => { 80 | if (theUrl.endsWith("refs/tags")) { 81 | return getAxiosResponseForObject([]); 82 | } else if (theUrl.includes("pulls")) { 83 | throw new Error("Request failed with Network error"); 84 | } 85 | return getAxiosResponseForObject([]); 86 | }; 87 | 88 | describe("IGitHub", () => { 89 | test("gets author correctly", async () => { 90 | jest.spyOn(HttpHelper, "httpGet").mockImplementationOnce(mockedFunction); 91 | const author = await getAuthor(repo, "commit"); 92 | expect(author).toBeDefined(); 93 | expect(author!.name).toBe("Edaena Salinas"); 94 | expect(author!.url).toBeDefined(); 95 | expect(author!.username).toBe("edaena"); 96 | expect(author!.imageUrl).toBeTruthy(); 97 | }); 98 | }); 99 | 100 | describe("IGitHub", () => { 101 | test("gets PR correctly", async () => { 102 | jest.spyOn(HttpHelper, "httpGet").mockImplementationOnce(mockedFunction); 103 | const pr = await getPullRequest(repo, "prid"); 104 | expect(pr).toBeDefined(); 105 | expect(pr.mergedBy).toBeDefined(); 106 | expect(pr!.mergedBy!.name).toBe("bnookala"); 107 | expect(pr!.mergedBy!.username).toBe("bnookala"); 108 | expect(pr!.mergedBy!.url).toBeDefined(); 109 | expect(pr!.mergedBy!.imageUrl).toBeDefined(); 110 | expect(pr!.url).toBeDefined(); 111 | expect(pr!.title).toBe( 112 | "Updating all other pipelines to install helm2 prior to runnin steps" 113 | ); 114 | expect(pr!.sourceBranch).toBe("helm-2-pipelines"); 115 | expect(pr!.targetBranch).toBe("master"); 116 | expect(pr!.id).toBe(408); 117 | expect(pr!.description).toBeDefined(); 118 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 119 | }); 120 | test("negative tests", async () => { 121 | jest.spyOn(HttpHelper, "httpGet").mockImplementation(mockedEmptyResponse); 122 | let flag = 0; 123 | try { 124 | expect(await getPullRequest(repo, "prid")).toThrow(); 125 | } catch (e) { 126 | flag = 1; 127 | } 128 | expect(flag).toBe(1); 129 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 130 | 131 | jest.spyOn(HttpHelper, "httpGet").mockImplementation(mockedErrorResponse); 132 | flag = 0; 133 | try { 134 | expect(await getPullRequest(repo, "prid")).toThrow(); 135 | } catch (e) { 136 | flag = 1; 137 | } 138 | expect(flag).toBe(1); 139 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 140 | }); 141 | }); 142 | 143 | describe("IGitHub", () => { 144 | test("gets manifest sync tag correctly", async () => { 145 | jest.spyOn(HttpHelper, "httpGet").mockImplementation(mockedFunction); 146 | const tags = await getManifestSyncState(repo); 147 | expect(tags).toHaveLength(2); 148 | expect(tags[0].commit).toBe("57cb69b"); 149 | expect(tags[0].tagger).toBeDefined(); 150 | expect(tags[0].tagger).toBe("Weave Flux"); 151 | expect(tags[0].name).toBe("ALASKA"); 152 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 153 | }); 154 | test("negative tests", async () => { 155 | jest.spyOn(HttpHelper, "httpGet").mockImplementation(mockedEmptyResponse); 156 | const tags = await getManifestSyncState(repo); 157 | expect(tags).toHaveLength(0); 158 | }); 159 | }); 160 | 161 | describe("IGitHub", () => { 162 | test("gets releases URL correctly", async () => { 163 | const releaseUrl = getReleasesURL(repo); 164 | expect(releaseUrl).toBe("https://github.com/username/reponame/releases"); 165 | }); 166 | }); 167 | 168 | const getAxiosResponseForObject = (obj: any): Promise> => { 169 | return new Promise(resolve => { 170 | const response: AxiosResponse = { 171 | config: {}, 172 | data: obj, 173 | headers: "", 174 | status: 200, 175 | statusText: "" 176 | }; 177 | resolve(response); 178 | }); 179 | }; 180 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/IGitHub.ts: -------------------------------------------------------------------------------- 1 | import { HttpHelper } from "../HttpHelper"; 2 | import { IAuthor } from "./Author"; 3 | import { IPullRequest } from "./IPullRequest"; 4 | import { ITag } from "./Tag"; 5 | 6 | const manifestSyncTagsURL = 7 | "https://api.github.com/repos///git/refs/tags"; 8 | const authorInfoURL = 9 | "https://api.github.com/repos///commits/"; 10 | 11 | const prURL = 12 | "https://api.github.com/repos///pulls/"; 13 | 14 | export interface IGitHub { 15 | username: string; 16 | reponame: string; 17 | } 18 | 19 | export const getManifestSyncState = ( 20 | repository: IGitHub, 21 | accessToken?: string 22 | ): Promise => { 23 | return new Promise(async (resolve, reject) => { 24 | try { 25 | const allTags = await HttpHelper.httpGet( 26 | manifestSyncTagsURL 27 | .replace("", repository.username) 28 | .replace("", repository.reponame), 29 | accessToken 30 | ); 31 | 32 | if (allTags.status !== 200) { 33 | throw new Error(allTags.statusText); 34 | } 35 | 36 | const tags = allTags.data; 37 | if (tags != null && tags.length > 0) { 38 | const fluxTags: ITag[] = []; 39 | for (const fluxTag of tags) { 40 | const data = await HttpHelper.httpGet( 41 | fluxTag.url 42 | .replace("", repository.username) 43 | .replace("", repository.reponame), 44 | accessToken 45 | ); 46 | 47 | const tag = data.data; 48 | if (tag != null) { 49 | const syncStatus = await HttpHelper.httpGet( 50 | tag.object.url, 51 | accessToken 52 | ); 53 | 54 | if (syncStatus != null && syncStatus.data && syncStatus.data.tag) { 55 | const clusterName = syncStatus.data.tag.replace("flux-", ""); 56 | const manifestSync = { 57 | commit: syncStatus.data.object?.sha?.substring(0, 7), 58 | date: new Date(syncStatus.data.tagger?.date), 59 | message: syncStatus.data.message, 60 | name: clusterName.toUpperCase(), 61 | tagger: syncStatus.data.tagger?.name 62 | }; 63 | fluxTags.push(manifestSync); 64 | } 65 | } 66 | } 67 | resolve(fluxTags); 68 | return; 69 | } 70 | 71 | // No tags were found. 72 | resolve([]); 73 | } catch (err) { 74 | reject(err); 75 | } 76 | }); 77 | }; 78 | 79 | export const getReleasesURL = (repository: IGitHub): string => { 80 | return ( 81 | "https://github.com/" + 82 | repository.username + 83 | "/" + 84 | repository.reponame + 85 | "/releases" 86 | ); 87 | }; 88 | 89 | export const getPullRequest = ( 90 | repository: IGitHub, 91 | pullRequestId: string, 92 | accessToken?: string 93 | ): Promise => { 94 | return new Promise(async (resolve, reject) => { 95 | try { 96 | const data = await HttpHelper.httpGet( 97 | prURL 98 | .replace("", repository.username) 99 | .replace("", repository.reponame) 100 | .replace("", pullRequestId), 101 | accessToken 102 | ); 103 | if (data.data) { 104 | const pr = data.data; 105 | resolve({ 106 | description: pr.body, 107 | id: pr.number, 108 | mergedBy: pr.merged_by 109 | ? { 110 | imageUrl: pr.merged_by.avatar_url 111 | ? pr.merged_by.avatar_url 112 | : "", 113 | name: pr.merged_by.login ? pr.merged_by.login : "", 114 | url: pr.merged_by.url ? pr.merged_by.html_url : "", 115 | username: pr.merged_by.login ? pr.merged_by.login : "" 116 | } 117 | : undefined, 118 | sourceBranch: pr.head ? pr.head.ref : "", 119 | targetBranch: pr.base ? pr.base.ref : "", 120 | title: pr.title, 121 | url: pr.html_url ? pr.html_url : "" 122 | }); 123 | } else { 124 | reject("No PR was found for " + pullRequestId); 125 | } 126 | } catch (e) { 127 | reject(e); 128 | } 129 | }); 130 | }; 131 | 132 | export const getAuthor = async ( 133 | repository: IGitHub, 134 | commitId: string, 135 | accessToken?: string 136 | ) => { 137 | const data = await HttpHelper.httpGet( 138 | authorInfoURL 139 | .replace("", repository.username) 140 | .replace("", repository.reponame) 141 | .replace("", commitId), 142 | accessToken 143 | ); 144 | 145 | if (data.status !== 200) { 146 | throw new Error(data.statusText); 147 | } 148 | 149 | const authorInfo = data.data; 150 | if (authorInfo != null) { 151 | const author: IAuthor = { 152 | imageUrl: authorInfo.author ? authorInfo.author.avatar_url : "", 153 | name: authorInfo.commit.author.name, 154 | url: authorInfo.author ? authorInfo.author.html_url : "", 155 | username: authorInfo.committer ? authorInfo.committer.login : "" 156 | }; 157 | return author; 158 | } 159 | }; 160 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/IGitlabRepo.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | import { HttpHelper } from "../HttpHelper"; 5 | import { 6 | getAuthor, 7 | getManifestSyncState, 8 | getPullRequest, 9 | getReleasesURL, 10 | IGitlabRepo 11 | } from "./IGitlabRepo"; 12 | 13 | 14 | const mockDirectory = path.join("src", "repository", "mocks"); 15 | const repo: IGitlabRepo = { 16 | projectId: "42857398" 17 | }; 18 | 19 | const mockResponse = (resp: any) => { 20 | jest.spyOn(HttpHelper, "httpGet").mockImplementation( 21 | (theUrl: string, accessToken?: string): Promise> => { 22 | return new Promise(resolve => { 23 | const response: AxiosResponse = { 24 | config: {}, 25 | data: resp, 26 | headers: "", 27 | status: 200, 28 | statusText: "" 29 | }; 30 | resolve(response); 31 | }); 32 | } 33 | ); 34 | }; 35 | 36 | describe("IGitlabRepo", () => { 37 | test("gets author correctly", async () => { 38 | mockResponse(JSON.parse( 39 | fs.readFileSync(path.join(mockDirectory, "gitlab-author-response.json"), "utf-8") 40 | )); 41 | const author = await getAuthor(repo, "67de8af"); 42 | expect(author).toBeDefined(); 43 | expect(author!.name).toBe("samiya akhtar"); 44 | expect(author!.url).toBeDefined(); 45 | expect(author!.username).toBe("samiyaakhtar7@gmail.com"); 46 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 47 | }); 48 | test("gets PR correctly", async () => { 49 | mockResponse(JSON.parse( 50 | fs.readFileSync(path.join(mockDirectory, "gitlab-pr-response.json"), "utf-8") 51 | )); 52 | const pr = await getPullRequest(repo, "4"); 53 | expect(pr).toBeDefined(); 54 | expect(pr?.mergedBy).toBeDefined(); 55 | expect(pr!.mergedBy!.name).toBe("samiya akhtar"); 56 | expect(pr!.mergedBy!.username).toBe("samiyaakhtar"); 57 | expect(pr!.mergedBy!.url).toBeDefined(); 58 | expect(pr!.mergedBy!.imageUrl).toBeDefined(); 59 | expect(pr!.url).toBeDefined(); 60 | expect(pr!.title).toBe( 61 | "automated" 62 | ); 63 | expect(pr!.sourceBranch).toBe("DEPLOY/backend.208444041"); 64 | expect(pr!.targetBranch).toBe("master"); 65 | expect(pr!.id).toBe(5); 66 | expect(pr!.description).toBeDefined(); 67 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 68 | }); 69 | test("gets manifest sync tag correctly", async () => { 70 | mockResponse(JSON.parse( 71 | fs.readFileSync(path.join(mockDirectory, "gitlab-tags-response.json"), "utf-8") 72 | )); 73 | const tags = await getManifestSyncState(repo); 74 | expect(tags).toHaveLength(1); 75 | expect(tags[0].commit).toBe("a41c826"); 76 | expect(tags[0].tagger).toBeDefined(); 77 | expect(tags[0].tagger).toBe("Automated Account"); 78 | expect(tags[0].name).toBe("EAST-US"); 79 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 80 | }); 81 | test("gets releases url correctly", async () => { 82 | mockResponse(JSON.parse( 83 | fs.readFileSync(path.join(mockDirectory, "gitlab-releasesurl-response.json"), "utf-8") 84 | )); 85 | const url = await getReleasesURL(repo); 86 | expect(url).toBeDefined(); 87 | expect(url).toBe("https://gitlab.com/samiyaakhtar/hello-world-full-stack/-/tags") 88 | jest.spyOn(HttpHelper, "httpGet").mockClear(); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/IGitlabRepo.ts: -------------------------------------------------------------------------------- 1 | import { HttpHelper } from "../HttpHelper"; 2 | import { IAuthor } from "./Author"; 3 | import { IPullRequest } from "./IPullRequest"; 4 | import { ITag } from "./Tag"; 5 | 6 | const commitsApi = "https://gitlab.com/api/v4/projects/{projectId}/repository/commits/{commitId}"; 7 | const prApi = "https://gitlab.com/api/v4/projects/{projectId}/merge_requests/{merge_request_iid}"; 8 | const manifestSyncTagsURL = "https://gitlab.com/api/v4/projects/{projectId}/repository/tags"; 9 | const releasesURL = "https://gitlab.com/api/v4/projects/{projectId}"; 10 | export interface IGitlabRepo { 11 | projectId: string; 12 | } 13 | 14 | 15 | export const getManifestSyncState = async ( 16 | repository: IGitlabRepo, 17 | accessToken?: string 18 | ): Promise => { 19 | const allTags = await HttpHelper.httpGet( 20 | manifestSyncTagsURL 21 | .replace("{projectId}", repository.projectId), 22 | accessToken 23 | ); 24 | if (allTags.status !== 200) { 25 | throw new Error(allTags.statusText); 26 | } 27 | 28 | const tags = allTags.data; 29 | const fluxTags: ITag[] = []; 30 | if (tags != null && tags.length > 0) { 31 | for (const tag of tags) { 32 | const clusterName = tag.name.replace("flux-", ""); 33 | fluxTags.push({ 34 | commit: tag.commit && tag.commit.id ? tag.commit.id.substring(0, 7) : "", 35 | date: tag.commit ? new Date(tag.commit.authored_date) : new Date(), 36 | message: tag.message, 37 | name: clusterName.toUpperCase(), 38 | tagger: tag.commit ? tag.commit.author_name : "" 39 | }) 40 | } 41 | } 42 | return fluxTags; 43 | } 44 | 45 | 46 | export const getReleasesURL = async (repository: IGitlabRepo, accessToken?: string): Promise => { 47 | const projectInfo = await HttpHelper.httpGet( 48 | releasesURL 49 | .replace("{projectId}", repository.projectId), 50 | accessToken 51 | ); 52 | return projectInfo.data.web_url + "/-/tags"; 53 | }; 54 | 55 | 56 | export const getPullRequest = async ( 57 | repository: IGitlabRepo, 58 | pullRequestId: string, 59 | accessToken?: string 60 | ): Promise => { 61 | return new Promise(async (resolve, reject) => { 62 | try { 63 | const data = await HttpHelper.httpGet( 64 | prApi 65 | .replace("{projectId}", repository.projectId) 66 | .replace("{merge_request_iid}", pullRequestId), 67 | accessToken 68 | ); 69 | if (data.data) { 70 | const pr = data.data; 71 | resolve({ 72 | description: pr.description, 73 | id: pr.iid, 74 | mergedBy: pr.merged_by 75 | ? { 76 | imageUrl: pr.merged_by.avatar_url 77 | ? pr.merged_by.avatar_url 78 | : "", 79 | name: pr.merged_by.name ? pr.merged_by.name : "", 80 | url: pr.merged_by.web_url ? pr.merged_by.web_url : "", 81 | username: pr.merged_by.username ? pr.merged_by.username : "" 82 | } 83 | : undefined, 84 | sourceBranch: pr.source_branch, 85 | targetBranch: pr.target_branch, 86 | title: pr.title, 87 | url: pr.web_url 88 | }); 89 | } else { 90 | reject("No PR was found for " + pullRequestId); 91 | } 92 | } catch (e) { 93 | reject(e); 94 | } 95 | }); 96 | } 97 | 98 | 99 | export const getAuthor = async ( 100 | repository: IGitlabRepo, 101 | commitId: string, 102 | accessToken?: string 103 | ) => { 104 | const data = await HttpHelper.httpGet( 105 | commitsApi 106 | .replace("{projectId}", repository.projectId) 107 | .replace("{commitId}", commitId), 108 | accessToken 109 | ); 110 | 111 | if (data.status !== 200) { 112 | throw new Error(data.statusText); 113 | } 114 | 115 | const authorInfo = data.data; 116 | if (authorInfo != null) { 117 | const avatar = await HttpHelper.httpGet(`https://gitlab.com/api/v4/avatar?email=${authorInfo.author_email}&size=32`); 118 | const author: IAuthor = { 119 | imageUrl: avatar.data != null ? avatar.data.avatar_url : "", 120 | name: authorInfo.author_name, 121 | url: authorInfo.web_url, 122 | username: authorInfo.author_email 123 | }; 124 | return author; 125 | } 126 | return undefined; 127 | } 128 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/IPullRequest.ts: -------------------------------------------------------------------------------- 1 | import { IAuthor } from "./Author"; 2 | 3 | export interface IPullRequest { 4 | id: number; 5 | title: string; 6 | sourceBranch: string; 7 | targetBranch: string; 8 | description: string; 9 | mergedBy?: IAuthor; 10 | url: string; 11 | } 12 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/Tag.ts: -------------------------------------------------------------------------------- 1 | export interface ITag { 2 | commit: string; 3 | date: Date; 4 | tagger?: string; 5 | message?: string; 6 | name: string; 7 | } 8 | 9 | export interface IClusterSync { 10 | releasesURL?: string; 11 | tags?: ITag[]; 12 | } 13 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Author"; 2 | export * from "./Tag"; 3 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/azdo-author-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "treeId": "41b9e31335ee0259c20c5404d5df57e6acd62ac0", 3 | "commitId": "1bbcd91c3e6e08d0a61eb3bd70a1a2fe1c9987f4", 4 | "author": { 5 | "name": "Samiya Akhtar", 6 | "email": "saakhta@microsoft.com", 7 | "date": "2019-10-17T05:35:38Z", 8 | "imageUrl": "https://dev.azure.com/epicstuff/_apis/GraphProfile/MemberAvatars/aad.NDcwNGNkNGUtMjA0Yi03MDcxLWJiYWEtOTQzY2Y5ZmE1NTYz" 9 | }, 10 | "committer": { 11 | "name": "Samiya Akhtar", 12 | "email": "saakhta@microsoft.com", 13 | "date": "2019-10-17T05:35:38Z", 14 | "imageUrl": "https://dev.azure.com/epicstuff/_apis/GraphProfile/MemberAvatars/aad.NDcwNGNkNGUtMjA0Yi03MDcxLWJiYWEtOTQzY2Y5ZmE1NTYz" 15 | }, 16 | "comment": "Updated azure-pipelines.yml", 17 | "parents": ["dafdbd15067ac341dcf3f787797c7d761c28c7c4"], 18 | "url": "https://dev.azure.com/epicstuff/e7236bd9-a6f9-4554-8dce-ad81ae94faf6/_apis/git/repositories/128dce21-1847-4fe3-9481-63f0c21b3c52/commits/1bbcd91c3e6e08d0a61eb3bd70a1a2fe1c9987f4", 19 | "remoteUrl": "https://dev.azure.com/epicstuff/hellobedrockprivate/_git/hello-bedrock-azure-private/commit/1bbcd91c3e6e08d0a61eb3bd70a1a2fe1c9987f4", 20 | "_links": { 21 | "self": { 22 | "href": "https://dev.azure.com/epicstuff/e7236bd9-a6f9-4554-8dce-ad81ae94faf6/_apis/git/repositories/128dce21-1847-4fe3-9481-63f0c21b3c52/commits/1bbcd91c3e6e08d0a61eb3bd70a1a2fe1c9987f4" 23 | }, 24 | "repository": { 25 | "href": "https://dev.azure.com/epicstuff/e7236bd9-a6f9-4554-8dce-ad81ae94faf6/_apis/git/repositories/128dce21-1847-4fe3-9481-63f0c21b3c52" 26 | }, 27 | "web": { 28 | "href": "https://dev.azure.com/epicstuff/hellobedrockprivate/_git/hello-bedrock-azure-private/commit/1bbcd91c3e6e08d0a61eb3bd70a1a2fe1c9987f4" 29 | }, 30 | "changes": { 31 | "href": "https://dev.azure.com/epicstuff/e7236bd9-a6f9-4554-8dce-ad81ae94faf6/_apis/git/repositories/128dce21-1847-4fe3-9481-63f0c21b3c52/commits/1bbcd91c3e6e08d0a61eb3bd70a1a2fe1c9987f4/changes" 32 | } 33 | }, 34 | "push": { 35 | "pushedBy": { 36 | "displayName": "Samiya Akhtar", 37 | "url": "https://spsprodcus1.vssps.visualstudio.com/A5ba215af-08b5-44cc-8587-9f8b80f7799a/_apis/Identities/e8900b94-217f-4a51-9d86-6bbf5d82b6fb", 38 | "_links": { 39 | "avatar": { 40 | "href": "https://dev.azure.com/epicstuff/_apis/GraphProfile/MemberAvatars/aad.NDcwNGNkNGUtMjA0Yi03MDcxLWJiYWEtOTQzY2Y5ZmE1NTYz" 41 | } 42 | }, 43 | "id": "e8900b94-217f-4a51-9d86-6bbf5d82b6fb", 44 | "uniqueName": "saakhta@microsoft.com", 45 | "imageUrl": "https://dev.azure.com/epicstuff/_api/_common/identityImage?id=e8900b94-217f-4a51-9d86-6bbf5d82b6fb", 46 | "descriptor": "aad.NDcwNGNkNGUtMjA0Yi03MDcxLWJiYWEtOTQzY2Y5ZmE1NTYz" 47 | }, 48 | "pushId": 1150, 49 | "date": "2019-10-17T05:35:38.5605462Z" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/azdo-manifest-sync-tag-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flux-sync", 3 | "objectId": "0396d996eda4b2528dbda88dd8ab9c0bb6c27674", 4 | "taggedObject": { 5 | "objectId": "ab4c9f1ccb67a82256763e32fba1e70c897bd82a", 6 | "objectType": "commit" 7 | }, 8 | "taggedBy": { 9 | "name": "Weave Flux", 10 | "email": "support@weave.works", 11 | "date": "2019-10-31T19:17:58" 12 | }, 13 | "message": "Sync pointer\n", 14 | "url": "https://dev.azure.com/epicstuff/e7236bd9-a6f9-4554-8dce-ad81ae94faf6/_apis/git/repositories/a4bba4cf-76d3-4aee-b456-e0953db8e8d3/annotatedTags/0396d996eda4b2528dbda88dd8ab9c0bb6c27674" 15 | } 16 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/azdo-sync-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "value": [ 3 | { 4 | "name": "refs/tags/flux-sync", 5 | "objectId": "0396d996eda4b2528dbda88dd8ab9c0bb6c27674", 6 | "creator": { 7 | "displayName": "Samiya Akhtar", 8 | "url": "https://spsprodcus1.vssps.visualstudio.com/A5ba215af-08b5-44cc-8587-9f8b80f7799a/_apis/Identities/e8900b94-217f-4a51-9d86-6bbf5d82b6fb", 9 | "_links": { 10 | "avatar": { 11 | "href": "https://dev.azure.com/epicstuff/_apis/GraphProfile/MemberAvatars/aad.NDcwNGNkNGUtMjA0Yi03MDcxLWJiYWEtOTQzY2Y5ZmE1NTYz" 12 | } 13 | }, 14 | "id": "e8900b94-217f-4a51-9d86-6bbf5d82b6fb", 15 | "uniqueName": "saakhta@microsoft.com", 16 | "imageUrl": "https://dev.azure.com/epicstuff/_api/_common/identityImage?id=e8900b94-217f-4a51-9d86-6bbf5d82b6fb", 17 | "descriptor": "aad.NDcwNGNkNGUtMjA0Yi03MDcxLWJiYWEtOTQzY2Y5ZmE1NTYz" 18 | }, 19 | "url": "https://dev.azure.com/epicstuff/e7236bd9-a6f9-4554-8dce-ad81ae94faf6/_apis/git/repositories/a4bba4cf-76d3-4aee-b456-e0953db8e8d3/refs?filter=tags%2Fflux-sync" 20 | } 21 | ], 22 | "count": 1 23 | } 24 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/github-author-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "sha": "f19cb81f889d8d29dd21d860df04967fdf5af567", 3 | "node_id": "MDY6Q29tbWl0MjE2NjUyODgyOmYxOWNiODFmODg5ZDhkMjlkZDIxZDg2MGRmMDQ5NjdmZGY1YWY1Njc=", 4 | "commit": { 5 | "author": { 6 | "name": "Edaena Salinas", 7 | "email": "edaena@users.noreply.github.com", 8 | "date": "2019-10-28T21:54:37Z" 9 | }, 10 | "committer": { 11 | "name": "Edaena Salinas", 12 | "email": "edaena@users.noreply.github.com", 13 | "date": "2019-10-28T21:54:37Z" 14 | }, 15 | "message": "Update azure-pipelines.yml for Azure Pipelines", 16 | "tree": { 17 | "sha": "f4c0fc3a42efba4ba9eaf75a8e55a5d911650397", 18 | "url": "https://api.github.com/repos/edaena/spartan-app/git/trees/f4c0fc3a42efba4ba9eaf75a8e55a5d911650397" 19 | }, 20 | "url": "https://api.github.com/repos/edaena/spartan-app/git/commits/f19cb81f889d8d29dd21d860df04967fdf5af567", 21 | "comment_count": 0, 22 | "verification": { 23 | "verified": false, 24 | "reason": "unsigned", 25 | "signature": null, 26 | "payload": null 27 | } 28 | }, 29 | "url": "https://api.github.com/repos/edaena/spartan-app/commits/f19cb81f889d8d29dd21d860df04967fdf5af567", 30 | "html_url": "https://github.com/edaena/spartan-app/commit/f19cb81f889d8d29dd21d860df04967fdf5af567", 31 | "comments_url": "https://api.github.com/repos/edaena/spartan-app/commits/f19cb81f889d8d29dd21d860df04967fdf5af567/comments", 32 | "author": { 33 | "login": "edaena", 34 | "id": 1280985, 35 | "node_id": "MDQ6VXNlcjEyODA5ODU=", 36 | "avatar_url": "https://avatars0.githubusercontent.com/u/1280985?v=4", 37 | "gravatar_id": "", 38 | "url": "https://api.github.com/users/edaena", 39 | "html_url": "https://github.com/edaena", 40 | "followers_url": "https://api.github.com/users/edaena/followers", 41 | "following_url": "https://api.github.com/users/edaena/following{/other_user}", 42 | "gists_url": "https://api.github.com/users/edaena/gists{/gist_id}", 43 | "starred_url": "https://api.github.com/users/edaena/starred{/owner}{/repo}", 44 | "subscriptions_url": "https://api.github.com/users/edaena/subscriptions", 45 | "organizations_url": "https://api.github.com/users/edaena/orgs", 46 | "repos_url": "https://api.github.com/users/edaena/repos", 47 | "events_url": "https://api.github.com/users/edaena/events{/privacy}", 48 | "received_events_url": "https://api.github.com/users/edaena/received_events", 49 | "type": "User", 50 | "site_admin": false 51 | }, 52 | "committer": { 53 | "login": "edaena", 54 | "id": 1280985, 55 | "node_id": "MDQ6VXNlcjEyODA5ODU=", 56 | "avatar_url": "https://avatars0.githubusercontent.com/u/1280985?v=4", 57 | "gravatar_id": "", 58 | "url": "https://api.github.com/users/edaena", 59 | "html_url": "https://github.com/edaena", 60 | "followers_url": "https://api.github.com/users/edaena/followers", 61 | "following_url": "https://api.github.com/users/edaena/following{/other_user}", 62 | "gists_url": "https://api.github.com/users/edaena/gists{/gist_id}", 63 | "starred_url": "https://api.github.com/users/edaena/starred{/owner}{/repo}", 64 | "subscriptions_url": "https://api.github.com/users/edaena/subscriptions", 65 | "organizations_url": "https://api.github.com/users/edaena/orgs", 66 | "repos_url": "https://api.github.com/users/edaena/repos", 67 | "events_url": "https://api.github.com/users/edaena/events{/privacy}", 68 | "received_events_url": "https://api.github.com/users/edaena/received_events", 69 | "type": "User", 70 | "site_admin": false 71 | }, 72 | "parents": [ 73 | { 74 | "sha": "9001199dd921aa8d6b2c30fe196e0433e20a0e91", 75 | "url": "https://api.github.com/repos/edaena/spartan-app/commits/9001199dd921aa8d6b2c30fe196e0433e20a0e91", 76 | "html_url": "https://github.com/edaena/spartan-app/commit/9001199dd921aa8d6b2c30fe196e0433e20a0e91" 77 | } 78 | ], 79 | "stats": { 80 | "total": 2, 81 | "additions": 1, 82 | "deletions": 1 83 | }, 84 | "files": [ 85 | { 86 | "sha": "7a8efa0b4f343e92a523f1ecb57dcfbdac186e2f", 87 | "filename": "azure-pipelines.yml", 88 | "status": "modified", 89 | "additions": 1, 90 | "deletions": 1, 91 | "changes": 2, 92 | "blob_url": "https://github.com/edaena/spartan-app/blob/f19cb81f889d8d29dd21d860df04967fdf5af567/azure-pipelines.yml", 93 | "raw_url": "https://github.com/edaena/spartan-app/raw/f19cb81f889d8d29dd21d860df04967fdf5af567/azure-pipelines.yml", 94 | "contents_url": "https://api.github.com/repos/edaena/spartan-app/contents/azure-pipelines.yml?ref=f19cb81f889d8d29dd21d860df04967fdf5af567", 95 | "patch": "@@ -100,7 +100,7 @@ stages:\n echo \"latest_commit=$latest_commit\"\n \n # Download update storage script\n- curl https://raw.githubusercontent.com/samiyaakhtar/spk/686-simply-pipelines/scripts/update_introspection.sh > script.sh\n+ curl https://raw.githubusercontent.com/edaena/spk/master/scripts/update_introspection.sh > script.sh\n chmod +x script.sh\n ./script.sh $(ACCOUNT_NAME) $(ACCOUNT_KEY) $(TABLE_NAME) $(PARTITION_KEY) imageTag \"$(Build.Repository.Name):$(build.BuildNumber)\" p2 $(Build.BuildId) hldCommitId $latest_commit env \"DEV\" isMultiStage true\n env:" 96 | } 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/github-manifest-sync-tag-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "node_id": "MDM6VGFnMTkzODU0MjIzOjFiMTQ2ZDk5NzAwODM0YTNjMTdiNDdmMjBlMmE5ZGIzMTdiYjAwMzI=", 3 | "sha": "1b146d99700834a3c17b47f20e2a9db317bb0032", 4 | "url": "https://api.github.com/repos/samiyaakhtar/hello-bedrock-manifest/git/tags/1b146d99700834a3c17b47f20e2a9db317bb0032", 5 | "tagger": { 6 | "name": "Weave Flux", 7 | "email": "support@weave.works", 8 | "date": "2019-10-31T18:14:51Z" 9 | }, 10 | "object": { 11 | "sha": "096c95228c786715b14b0269a722a3de887c01bd", 12 | "type": "commit", 13 | "url": "https://api.github.com/repos/samiyaakhtar/hello-bedrock-manifest/git/commits/096c95228c786715b14b0269a722a3de887c01bd" 14 | }, 15 | "tag": "flux-sync", 16 | "message": "Sync pointer\n", 17 | "verification": { 18 | "verified": false, 19 | "reason": "unsigned", 20 | "signature": null, 21 | "payload": null 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/github-sync-response-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "node_id": "MDM6VGFnMTkzODU0MjIzOjdkNTdmOTNiY2I1NzJjOGNiYjAyZmU3ZmQxYzRkM2YxNDM3NDZiMWQ=", 3 | "sha": "7d57f93bcb572c8cbb02fe7fd1c4d3f143746b1d", 4 | "url": "https://api.github.com/repos/samiyaakhtar/hello-bedrock-manifest/git/tags/7d57f93bcb572c8cbb02fe7fd1c4d3f143746b1d", 5 | "tagger": { 6 | "name": "Weave Flux", 7 | "email": "support@weave.works", 8 | "date": "2019-11-20T15:34:19Z" 9 | }, 10 | "object": { 11 | "sha": "57cb69b0594e45b5f3e2d5e505b42f5412349f76", 12 | "type": "commit", 13 | "url": "https://api.github.com/repos/samiyaakhtar/hello-bedrock-manifest/git/commits/57cb69b0594e45b5f3e2d5e505b42f5412349f76" 14 | }, 15 | "tag": "flux-alaska", 16 | "message": "Sync pointer\n", 17 | "verification": { 18 | "verified": false, 19 | "reason": "unsigned", 20 | "signature": null, 21 | "payload": null 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/github-sync-response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ref": "refs/tags/flux-alaska", 4 | "node_id": "MDM6UmVmMTkzODU0MjIzOmZsdXgtYWxhc2th", 5 | "url": "https://api.github.com/repos/samiyaakhtar/hello-bedrock-manifest/git/refs/tags/flux-alaska", 6 | "object": { 7 | "sha": "7d57f93bcb572c8cbb02fe7fd1c4d3f143746b1d", 8 | "type": "tag", 9 | "url": "https://api.github.com/repos/samiyaakhtar/hello-bedrock-manifest/git/tags/7d57f93bcb572c8cbb02fe7fd1c4d3f143746b1d" 10 | } 11 | }, 12 | { 13 | "ref": "refs/tags/flux-sync", 14 | "node_id": "MDM6UmVmMTkzODU0MjIzOmZsdXgtc3luYw==", 15 | "url": "https://api.github.com/repos/samiyaakhtar/hello-bedrock-manifest/git/refs/tags/flux-sync", 16 | "object": { 17 | "sha": "cc4c2f4557efdb94ca379f24c319258d564fc4cf", 18 | "type": "tag", 19 | "url": "https://api.github.com/repos/samiyaakhtar/hello-bedrock-manifest/git/tags/cc4c2f4557efdb94ca379f24c319258d564fc4cf" 20 | } 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/gitlab-author-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "ad3aa569d1293a1aaafcd8fc4e2607f5d76de81e", 3 | "short_id": "ad3aa569", 4 | "created_at": "2020-10-28T20:52:59.000+00:00", 5 | "parent_ids": [ 6 | "de399bc427cec3e0ddf67c8f59f4f0a7fc36893b" 7 | ], 8 | "title": "Update .gitlab-ci.yml", 9 | "message": "Update .gitlab-ci.yml", 10 | "author_name": "samiya akhtar", 11 | "author_email": "samiyaakhtar7@gmail.com", 12 | "authored_date": "2020-10-28T20:52:59.000+00:00", 13 | "committer_name": "samiya akhtar", 14 | "committer_email": "samiyaakhtar7@gmail.com", 15 | "committed_date": "2020-10-28T20:52:59.000+00:00", 16 | "web_url": "https://gitlab.com/samiyaakhtar/hello-world-full-stack/-/commit/ad3aa569d1293a1aaafcd8fc4e2607f5d76de81e", 17 | "stats": { 18 | "additions": 4, 19 | "deletions": 12, 20 | "total": 16 21 | }, 22 | "status": "success", 23 | "project_id": 21761957, 24 | "last_pipeline": { 25 | "id": 208927877, 26 | "sha": "ad3aa569d1293a1aaafcd8fc4e2607f5d76de81e", 27 | "ref": "master", 28 | "status": "success", 29 | "created_at": "2020-10-28T20:52:59.913Z", 30 | "updated_at": "2020-10-28T20:59:09.000Z", 31 | "web_url": "https://gitlab.com/samiyaakhtar/hello-world-full-stack/-/pipelines/208927877" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/gitlab-pr-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 75910710, 3 | "iid": 5, 4 | "project_id": 22039475, 5 | "title": "automated", 6 | "description": null, 7 | "state": "merged", 8 | "created_at": "2020-10-27T22:05:54.879Z", 9 | "updated_at": "2020-10-27T22:14:17.466Z", 10 | "merged_by": { 11 | "id": 1384451, 12 | "name": "samiya akhtar", 13 | "username": "samiyaakhtar", 14 | "state": "active", 15 | "avatar_url": "https://secure.gravatar.com/avatar/890514c6e4e1d88993e8d6e65383cd57?s=80\u0026d=identicon", 16 | "web_url": "https://gitlab.com/samiyaakhtar" 17 | }, 18 | "merged_at": "2020-10-27T22:14:17.524Z", 19 | "closed_by": null, 20 | "closed_at": null, 21 | "target_branch": "master", 22 | "source_branch": "DEPLOY/backend.208444041", 23 | "user_notes_count": 0, 24 | "upvotes": 0, 25 | "downvotes": 0, 26 | "author": { 27 | "id": 1384451, 28 | "name": "samiya akhtar", 29 | "username": "samiyaakhtar", 30 | "state": "active", 31 | "avatar_url": "https://secure.gravatar.com/avatar/890514c6e4e1d88993e8d6e65383cd57?s=80\u0026d=identicon", 32 | "web_url": "https://gitlab.com/samiyaakhtar" 33 | }, 34 | "assignees": [], 35 | "assignee": null, 36 | "source_project_id": 22039475, 37 | "target_project_id": 22039475, 38 | "labels": [], 39 | "work_in_progress": false, 40 | "milestone": null, 41 | "merge_when_pipeline_succeeds": false, 42 | "merge_status": "can_be_merged", 43 | "sha": "2fc86d558407dda9221792b1386691e5981f97b3", 44 | "merge_commit_sha": "e3db103b6c27a0105655855af845069532dcea9a", 45 | "squash_commit_sha": null, 46 | "discussion_locked": null, 47 | "should_remove_source_branch": null, 48 | "force_remove_source_branch": null, 49 | "reference": "!5", 50 | "references": { 51 | "short": "!5", 52 | "relative": "!5", 53 | "full": "samiyaakhtar/hello-world-full-stack-hld!5" 54 | }, 55 | "web_url": "https://gitlab.com/samiyaakhtar/hello-world-full-stack-hld/-/merge_requests/5", 56 | "time_stats": { 57 | "time_estimate": 0, 58 | "total_time_spent": 0, 59 | "human_time_estimate": null, 60 | "human_total_time_spent": null 61 | }, 62 | "squash": false, 63 | "task_completion_status": { 64 | "count": 0, 65 | "completed_count": 0 66 | }, 67 | "has_conflicts": false, 68 | "blocking_discussions_resolved": true, 69 | "approvals_before_merge": null, 70 | "subscribed": true, 71 | "changes_count": "1", 72 | "latest_build_started_at": null, 73 | "latest_build_finished_at": null, 74 | "first_deployed_to_production_at": null, 75 | "pipeline": null, 76 | "head_pipeline": null, 77 | "diff_refs": { 78 | "base_sha": "febf2202c8480afc97c5af32b2ca216d62c4f2b2", 79 | "head_sha": "2fc86d558407dda9221792b1386691e5981f97b3", 80 | "start_sha": "febf2202c8480afc97c5af32b2ca216d62c4f2b2" 81 | }, 82 | "merge_error": null, 83 | "first_contribution": false, 84 | "user": { 85 | "can_merge": true 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/gitlab-releasesurl-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 21761957, 3 | "description": "A hello world project running on Bedrock and Spektate", 4 | "name": "hello-world-full-stack", 5 | "name_with_namespace": "samiya akhtar / hello-world-full-stack", 6 | "path": "hello-world-full-stack", 7 | "path_with_namespace": "samiyaakhtar/hello-world-full-stack", 8 | "created_at": "2020-10-13T21:13:57.414Z", 9 | "default_branch": "master", 10 | "tag_list": [], 11 | "ssh_url_to_repo": "git@gitlab.com:samiyaakhtar/hello-world-full-stack.git", 12 | "http_url_to_repo": "https://gitlab.com/samiyaakhtar/hello-world-full-stack.git", 13 | "web_url": "https://gitlab.com/samiyaakhtar/hello-world-full-stack", 14 | "readme_url": "https://gitlab.com/samiyaakhtar/hello-world-full-stack/-/blob/master/README.md", 15 | "avatar_url": null, 16 | "forks_count": 0, 17 | "star_count": 0, 18 | "last_activity_at": "2020-10-28T21:56:32.655Z", 19 | "namespace": { 20 | "id": 1671156, 21 | "name": "samiya akhtar", 22 | "path": "samiyaakhtar", 23 | "kind": "user", 24 | "full_path": "samiyaakhtar", 25 | "parent_id": null, 26 | "avatar_url": "https://secure.gravatar.com/avatar/890514c6e4e1d88993e8d6e65383cd57?s=80\u0026d=identicon", 27 | "web_url": "https://gitlab.com/samiyaakhtar" 28 | }, 29 | "_links": { 30 | "self": "https://gitlab.com/api/v4/projects/21761957", 31 | "issues": "https://gitlab.com/api/v4/projects/21761957/issues", 32 | "merge_requests": "https://gitlab.com/api/v4/projects/21761957/merge_requests", 33 | "repo_branches": "https://gitlab.com/api/v4/projects/21761957/repository/branches", 34 | "labels": "https://gitlab.com/api/v4/projects/21761957/labels", 35 | "events": "https://gitlab.com/api/v4/projects/21761957/events", 36 | "members": "https://gitlab.com/api/v4/projects/21761957/members" 37 | }, 38 | "packages_enabled": true, 39 | "empty_repo": false, 40 | "archived": false, 41 | "visibility": "public", 42 | "owner": { 43 | "id": 1384451, 44 | "name": "samiya akhtar", 45 | "username": "samiyaakhtar", 46 | "state": "active", 47 | "avatar_url": "https://secure.gravatar.com/avatar/890514c6e4e1d88993e8d6e65383cd57?s=80\u0026d=identicon", 48 | "web_url": "https://gitlab.com/samiyaakhtar" 49 | }, 50 | "resolve_outdated_diff_discussions": false, 51 | "container_registry_enabled": true, 52 | "container_expiration_policy": { 53 | "cadence": "1d", 54 | "enabled": true, 55 | "keep_n": 10, 56 | "older_than": "90d", 57 | "name_regex": null, 58 | "name_regex_keep": null, 59 | "next_run_at": "2020-10-20T07:41:20.342Z" 60 | }, 61 | "issues_enabled": true, 62 | "merge_requests_enabled": true, 63 | "wiki_enabled": true, 64 | "jobs_enabled": true, 65 | "snippets_enabled": true, 66 | "service_desk_enabled": true, 67 | "service_desk_address": "incoming+samiyaakhtar-hello-world-full-stack-21761957-issue-@incoming.gitlab.com", 68 | "can_create_merge_request_in": true, 69 | "issues_access_level": "enabled", 70 | "repository_access_level": "enabled", 71 | "merge_requests_access_level": "enabled", 72 | "forking_access_level": "enabled", 73 | "wiki_access_level": "enabled", 74 | "builds_access_level": "enabled", 75 | "snippets_access_level": "enabled", 76 | "pages_access_level": "enabled", 77 | "emails_disabled": null, 78 | "shared_runners_enabled": true, 79 | "lfs_enabled": true, 80 | "creator_id": 1384451, 81 | "import_status": "none", 82 | "import_error": null, 83 | "open_issues_count": 0, 84 | "runners_token": "xEqjqnVLwUF-CjfeXWT2", 85 | "ci_default_git_depth": 50, 86 | "ci_forward_deployment_enabled": true, 87 | "public_jobs": true, 88 | "build_git_strategy": "fetch", 89 | "build_timeout": 3600, 90 | "auto_cancel_pending_pipelines": "enabled", 91 | "build_coverage_regex": null, 92 | "ci_config_path": "", 93 | "shared_with_groups": [], 94 | "only_allow_merge_if_pipeline_succeeds": false, 95 | "allow_merge_on_skipped_pipeline": null, 96 | "request_access_enabled": true, 97 | "only_allow_merge_if_all_discussions_are_resolved": false, 98 | "remove_source_branch_after_merge": true, 99 | "printing_merge_request_link_enabled": true, 100 | "merge_method": "merge", 101 | "suggestion_commit_message": null, 102 | "auto_devops_enabled": false, 103 | "auto_devops_deploy_strategy": "continuous", 104 | "autoclose_referenced_issues": true, 105 | "approvals_before_merge": 0, 106 | "mirror": false, 107 | "external_authorization_classification_label": "", 108 | "marked_for_deletion_at": null, 109 | "marked_for_deletion_on": null, 110 | "compliance_frameworks": [], 111 | "permissions": { 112 | "project_access": { 113 | "access_level": 40, 114 | "notification_level": 0 115 | }, 116 | "group_access": null 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /packages/spektate/src/repository/mocks/gitlab-tags-response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "flux-east-us", 4 | "message": "", 5 | "target": "a41c826038ae7052f81510fe02896769abe17427", 6 | "commit": { 7 | "id": "a41c826038ae7052f81510fe02896769abe17427", 8 | "short_id": "a41c8260", 9 | "created_at": "2020-10-27T23:19:29.000+00:00", 10 | "parent_ids": [ 11 | "384100b44b8ccd24dbd6b95cee46f586c43fdee8" 12 | ], 13 | "title": "Updated k8s manifest files post commit:", 14 | "message": "Updated k8s manifest files post commit:\n", 15 | "author_name": "Automated Account", 16 | "author_email": "admin@azuredevops.com", 17 | "authored_date": "2020-10-27T23:19:29.000+00:00", 18 | "committer_name": "Automated Account", 19 | "committer_email": "admin@azuredevops.com", 20 | "committed_date": "2020-10-27T23:19:29.000+00:00", 21 | "web_url": "https://gitlab.com/samiyaakhtar/hello-world-full-stack-manifest/-/commit/a41c826038ae7052f81510fe02896769abe17427" 22 | }, 23 | "release": null, 24 | "protected": false 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /packages/spektate/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "outDir": "./lib", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "strict": true, 9 | "esModuleInterop": true 10 | }, 11 | "typeAcquisition": { 12 | "include": ["jest"] 13 | }, 14 | "exclude": ["node_modules", "lib"] 15 | } 16 | -------------------------------------------------------------------------------- /packages/spektate/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "no-console": false 7 | }, 8 | "rulesDirectory": [] 9 | } 10 | -------------------------------------------------------------------------------- /packages/spektate/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | entry: "./src/index.ts", 5 | target: "node", 6 | mode: "production", 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.tsx?$/, 11 | use: "ts-loader", 12 | exclude: /node_modules/ 13 | } 14 | ] 15 | }, 16 | resolve: { 17 | extensions: [".tsx", ".ts", ".js"] 18 | }, 19 | output: { 20 | filename: "spektate.js", 21 | path: path.resolve(__dirname, "dist") 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /pipeline-scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | asn1crypto==0.24.0 2 | azure-common==1.1.23 3 | azure-cosmosdb-nspkg==2.0.2 4 | azure-cosmosdb-table==1.0.5 5 | azure-nspkg==3.0.2 6 | azure-storage==0.36.0 7 | azure-storage-common==1.4.2 8 | certifi==2019.6.16 9 | cffi==1.12.3 10 | chardet==3.0.4 11 | cryptography==3.3.2 12 | idna==2.8 13 | pycparser==2.19 14 | python-dateutil==2.8.0 15 | requests==2.22.0 16 | six==1.12.0 17 | urllib3==1.26.5 -------------------------------------------------------------------------------- /pipeline-scripts/update_pipeline.py: -------------------------------------------------------------------------------- 1 | from azure.cosmosdb.table.tableservice import TableService 2 | from azure.cosmosdb.table.models import Entity 3 | import sys 4 | import uuid 5 | 6 | def generate_row_key(): 7 | return str(uuid.uuid4()).split('-')[-1] 8 | 9 | # Performs a look up based on filter_name:filter_value for the pipeline to update its details 10 | def update_pipeline(account_name, account_key, table_name, partition_name, filter_name, filter_value, name1, value1, name2=None, value2=None, name3=None, value3=None, name4=None, value4=None): 11 | table_service = TableService(account_name=account_name, account_key=account_key) 12 | entities = table_service.query_entities(table_name, filter=filter_name + " eq '"+ filter_value + "'") 13 | 14 | count = 0 15 | for entity in entities: 16 | count = count + 1 17 | add = False 18 | if name1 in entity and entity[name1] != value1.lower(): 19 | add = True 20 | entity[name1] = value1.lower() 21 | 22 | if name2 != None and value2 != None: 23 | if name2 in entity and entity[name2] != value2.lower(): 24 | add = True 25 | entity[name2] = value2.lower() 26 | 27 | if name3 != None and value3 != None: 28 | if name3 in entity and entity[name3] != value3.lower(): 29 | add = True 30 | entity[name3] = value3.lower() 31 | 32 | if name4 != None and value4 != None: 33 | if name4 in entity and entity[name4] != value4.lower(): 34 | add = True 35 | entity[name4] = value4.lower() 36 | 37 | if add == False: 38 | table_service.update_entity(table_name, entity) 39 | print("Updating existing entry") 40 | else: 41 | guid = generate_row_key() 42 | entity["RowKey"] = guid 43 | table_service.insert_entity(table_name, entity) 44 | print("Adding new entry since one already existed") 45 | print(entity) 46 | break 47 | 48 | if count == 0: 49 | add_pipeline(account_name, account_key, table_name, partition_name, filter_name, filter_value, name1, value1, name2, value2, name3, value3) 50 | print("Done") 51 | 52 | def add_pipeline(account_name, account_key, table_name, partition_name, filter_name, filter_value, name1, value1, name2=None, value2=None, name3=None, value3=None): 53 | print("Adding a new entry") 54 | new_entry = {} 55 | new_entry["RowKey"] = generate_row_key() 56 | new_entry["PartitionKey"] = partition_name 57 | new_entry[filter_name] = filter_value 58 | new_entry[name1] = value1.lower() 59 | if name2 != None and value2 != None: 60 | new_entry[name2] = value2.lower() 61 | if name3 != None and value3 != None: 62 | new_entry[name3] = value3.lower() 63 | print(new_entry) 64 | table_service = TableService(account_name=account_name, account_key=account_key) 65 | table_service.insert_entity(table_name, new_entry) 66 | 67 | 68 | def list_all_entities(account_name, account_key, table_name): 69 | table_service = TableService(account_name=account_name, account_key=account_key) 70 | entities = table_service.query_entities(table_name) 71 | for entity in entities: 72 | print(entity) 73 | 74 | if __name__ == "__main__": 75 | print(len(sys.argv)) 76 | if len(sys.argv) == 9: 77 | update_pipeline(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7], sys.argv[8]) 78 | elif len(sys.argv) == 11: 79 | update_pipeline(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7], sys.argv[8], sys.argv[9], sys.argv[10]) 80 | elif len(sys.argv) == 13: 81 | update_pipeline(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7], sys.argv[8], sys.argv[9], sys.argv[10], sys.argv[11], sys.argv[12]) 82 | elif len(sys.argv) == 15: 83 | update_pipeline(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7], sys.argv[8], sys.argv[9], sys.argv[10], sys.argv[11], sys.argv[12], sys.argv[13], sys.argv[14]) 84 | elif len(sys.argv) == 4: 85 | list_all_entities(sys.argv[1], sys.argv[2], sys.argv[3]) 86 | --------------------------------------------------------------------------------