├── .nvmrc ├── .prettierignore ├── src ├── static │ ├── __mocks__ │ │ └── styleMock.js │ ├── bitbucket │ │ ├── components │ │ │ ├── styles │ │ │ │ ├── checkboxStyles.ts │ │ │ │ ├── listStyles.ts │ │ │ │ └── loadingRectangleStyles.ts │ │ │ ├── Errors.tsx │ │ │ ├── types.ts │ │ │ ├── Warnings.tsx │ │ │ ├── Queue.stories.tsx │ │ │ ├── __tests__ │ │ │ │ └── App.test.tsx │ │ │ ├── Message.stories.tsx │ │ │ └── Queue.tsx │ │ ├── index.tsx │ │ ├── utils │ │ │ ├── getWidgetSettings.ts │ │ │ └── RequestProxy.ts │ │ └── index.html │ ├── current-state │ │ ├── index.tsx │ │ ├── components │ │ │ ├── types.ts │ │ │ ├── Panel.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── Logo.tsx │ │ │ ├── Section.tsx │ │ │ ├── CurrentState.tsx │ │ │ ├── tabs │ │ │ │ ├── TabContent.tsx │ │ │ │ ├── QueueTab.tsx │ │ │ │ ├── MergingTab.tsx │ │ │ │ ├── HistoryTab.tsx │ │ │ │ ├── Messenger.tsx │ │ │ │ ├── UsersList.tsx │ │ │ │ ├── PriorityBranchList.tsx │ │ │ │ └── index.tsx │ │ │ ├── Header.tsx │ │ │ ├── Badge.tsx │ │ │ ├── User.tsx │ │ │ ├── RunningBuilds.tsx │ │ │ ├── Lozenge.tsx │ │ │ ├── QueueItemsList.tsx │ │ │ ├── PermissionControl.tsx │ │ │ ├── WithAPIData.tsx │ │ │ ├── App.tsx │ │ │ └── InlineEdit.tsx │ │ ├── utils │ │ │ └── LandRequestUtils.ts │ │ └── index.html │ └── index.html ├── lib │ ├── utils │ │ ├── __mocks__ │ │ │ ├── redis-client.ts │ │ │ └── locker.ts │ │ ├── redis-client.ts │ │ ├── helper-functions.ts │ │ ├── __tests__ │ │ │ └── helper-function.test.ts │ │ └── locker.ts │ ├── __mocks__ │ │ ├── PermissionService.ts │ │ ├── AccountService.ts │ │ ├── Config.ts │ │ ├── StateService.ts │ │ └── Runner.ts │ ├── Events.ts │ ├── Logger.ts │ ├── History.ts │ ├── Config.ts │ ├── AccountService.ts │ ├── Queue.ts │ ├── PermissionService.ts │ └── SpeculationEngine.ts ├── db │ ├── migrations │ │ ├── 11__dependsOnPrIds.ts │ │ ├── 02__dependsOnColumn.ts │ │ ├── 01__targetBranchColumn.ts │ │ ├── 05__sourceBranchColumn.ts │ │ ├── 09__impactColumn.ts │ │ ├── 06__priorityColumn.ts │ │ ├── 10__speculationEngineAdminSettings.ts │ │ ├── 09__updateDependsOnColumn.ts │ │ ├── 07__accountIdColumns.ts │ │ ├── 08__mergeStrategyColumn.ts │ │ ├── 03__pauseStateTable.ts │ │ └── 04__bannerMessageStateTable.ts │ ├── __mocks__ │ │ └── index.ts │ └── MigrationService.ts ├── bitbucket │ ├── __mocks__ │ │ ├── BitbucketClient.ts │ │ ├── BitbucketPipelinesAPI.ts │ │ └── BitbucketAPI.ts │ ├── __tests__ │ │ ├── BitbucketClient.test.ts │ │ └── BitbucketPipelinesAPI.test.ts │ ├── BitbucketAuthenticator.ts │ ├── BitbucketMerger.ts │ ├── descriptor.ts │ ├── types.d.ts │ ├── BitbucketPipelinesAPI.ts │ └── BitbucketClient.ts ├── routes │ ├── bitbucket │ │ ├── index.ts │ │ ├── webhook │ │ │ └── index.ts │ │ └── lifecycle │ │ │ └── index.ts │ ├── auth │ │ └── index.ts │ ├── index.ts │ └── middleware.ts ├── auth │ └── bitbucket.ts ├── index.ts └── types.ts ├── .prettierrc ├── .vscode └── settings.json ├── TESTING.md ├── .dockerignore ├── .gitignore ├── .storybook ├── preview.js └── main.js ├── __mocks__ └── express.ts ├── tunnel-config.template.yml ├── tsconfig.json ├── tsconfig.base.json ├── docker-compose.yml ├── jest.config.js ├── Dockerfile ├── .circleci └── config.yml ├── LICENSE ├── .github └── workflows │ └── docker.yml ├── ToDo.md ├── tools └── tunnel.sh ├── webpack.config.js ├── typings └── ambient.d.ts ├── config.example.js ├── package.json ├── README.md └── CONTRIBUTING.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.16.1 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.json 2 | -------------------------------------------------------------------------------- /src/static/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/utils/__mocks__/redis-client.ts: -------------------------------------------------------------------------------- 1 | export const client = { 2 | get: jest.fn(), 3 | set: jest.fn(), 4 | }; 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } 5 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Integration Testing for Landkid 2 | 3 | Integration testing via cypress has now moved to https://go.atlassian.com/af-landkid-deployment-repo 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .cache-folder 2 | .circleci 3 | .vscode 4 | .git 5 | lib 6 | public_out 7 | scripts 8 | *.log 9 | node_modules 10 | test 11 | config.* 12 | db.sqlite 13 | -------------------------------------------------------------------------------- /src/static/bitbucket/components/styles/checkboxStyles.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'emotion'; 2 | 3 | const checkboxStyles = css({ 4 | marginTop: '10px', 5 | }); 6 | 7 | export default checkboxStyles; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | dist/ 4 | coverage/ 5 | landkid-queue.* 6 | log.txt 7 | .DS_Store 8 | .cache-loader 9 | db.sqlite 10 | env 11 | env.sh 12 | .logs 13 | config.js 14 | rules/ 15 | -------------------------------------------------------------------------------- /src/static/bitbucket/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import App from './components/App'; 3 | 4 | let container = document.getElementById('app'); 5 | if (container) { 6 | ReactDOM.render(, container); 7 | } 8 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: '^on[A-Z].*' }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/static/current-state/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import { App } from './components/App'; 3 | 4 | let container = document.getElementById('app'); 5 | if (container) { 6 | ReactDOM.render(, container); 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/utils/redis-client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis'; 2 | import { config } from '../Config'; 3 | 4 | export const client = createClient({ 5 | port: config.deployment.redis.port, 6 | host: config.deployment.redis.endpoint, 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/utils/__mocks__/locker.ts: -------------------------------------------------------------------------------- 1 | jest.mock('../redis-client'); 2 | 3 | export const withLock = jest.fn( 4 | (resource: string, fn: (lockId: Date) => Promise, fallback: T, ttl: number = 60000) => { 5 | return fn(new Date(123)); 6 | }, 7 | ); 8 | -------------------------------------------------------------------------------- /src/static/current-state/components/types.ts: -------------------------------------------------------------------------------- 1 | export type LozengeAppearance = 2 | | 'default' 3 | | 'success' 4 | | 'removed' 5 | | 'moved' 6 | | 'new' 7 | | 'inprogress'; 8 | 9 | export type BadgeAppearance = 'default' | 'primary' | 'important' | 'added'; 10 | -------------------------------------------------------------------------------- /__mocks__/express.ts: -------------------------------------------------------------------------------- 1 | const MockedExpressModule: any = jest.genMockFromModule('express'); 2 | 3 | const Express = jest.fn().mockImplementation(() => { 4 | const express = MockedExpressModule.application; 5 | 6 | return express; 7 | }); 8 | 9 | export default Express; 10 | -------------------------------------------------------------------------------- /src/static/bitbucket/components/styles/listStyles.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'emotion'; 2 | 3 | const listStyles = css({ 4 | '&, & ul': { 5 | paddingLeft: '20px', 6 | '&:first-child': { 7 | marginTop: '5px', 8 | }, 9 | }, 10 | }); 11 | 12 | export default listStyles; 13 | -------------------------------------------------------------------------------- /tunnel-config.template.yml: -------------------------------------------------------------------------------- 1 | # This file is used by tools/tunnel.sh to generate a private tunnel-config.yml file 2 | tunnel: 3 | credentials-file: 4 | ingress: 5 | - hostname: .public.atlastunnel.com 6 | service: http://localhost:3000 7 | - service: http_status:404 8 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 3 | addons: [ 4 | '@storybook/addon-links', 5 | '@storybook/addon-essentials', 6 | '@storybook/addon-interactions', 7 | ], 8 | framework: '@storybook/react', 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | }, 7 | "include": [ 8 | "./src", 9 | "./typings" 10 | ], 11 | "exclude": [ 12 | "node_modules", 13 | "tests", 14 | "**/*.stories.tsx" 15 | ], 16 | } -------------------------------------------------------------------------------- /src/lib/__mocks__/PermissionService.ts: -------------------------------------------------------------------------------- 1 | const mockedPermissionService: any = jest.genMockFromModule('../PermissionService'); 2 | 3 | export const permissionService = mockedPermissionService.permissionService; 4 | permissionService.getUsersPermissions.mockResolvedValueOnce([]); 5 | permissionService.getPermissionForUser.mockResolvedValueOnce('read'); 6 | -------------------------------------------------------------------------------- /src/static/current-state/components/Panel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | 4 | let styles = css({ 5 | background: 'var(--y50-color)', 6 | borderRadius: '3px', 7 | margin: '4px 0px', 8 | padding: '8px', 9 | }); 10 | 11 | export const Panel: React.FunctionComponent = ({ children }) => ( 12 |
{children}
13 | ); 14 | -------------------------------------------------------------------------------- /src/static/current-state/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | 4 | let emptyStyles = css({ 5 | fontSize: '1.42857143em', 6 | color: 'var(--secondary-text-color)', 7 | padding: '72px 0 0 0', 8 | textAlign: 'center', 9 | }); 10 | 11 | export const EmptyState: React.FunctionComponent = ({ children }) => ( 12 |
{children}
13 | ); 14 | -------------------------------------------------------------------------------- /src/static/current-state/utils/LandRequestUtils.ts: -------------------------------------------------------------------------------- 1 | export const getGroupedByTargetBranch = (updates: IStatusUpdate[]) => { 2 | const grouped: { [branch: string]: IStatusUpdate[] } = {}; 3 | updates.forEach((item) => { 4 | const targetBranch = item.request.pullRequest.targetBranch || item.requestId; 5 | grouped[targetBranch] = grouped[targetBranch] || []; 6 | grouped[targetBranch].push(item); 7 | }); 8 | return grouped; 9 | }; 10 | -------------------------------------------------------------------------------- /src/lib/__mocks__/AccountService.ts: -------------------------------------------------------------------------------- 1 | jest.mock('../utils/redis-client'); 2 | jest.mock('../utils/locker'); 3 | 4 | const MockedAccountServiceModule: any = jest.genMockFromModule('../AccountService'); 5 | 6 | export const AccountService = jest.fn().mockImplementation((...args) => { 7 | const accountService = new MockedAccountServiceModule.AccountService(...args); 8 | // Properties are not auto mocked by jest 9 | 10 | return accountService; 11 | }); 12 | -------------------------------------------------------------------------------- /src/static/current-state/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | 4 | let styles = css({ 5 | textTransform: 'uppercase', 6 | fontWeight: 400, 7 | '& .logo__secondary': { 8 | color: 'var(--secondary-text-color)', 9 | }, 10 | flex: 1, 11 | }); 12 | 13 | export const Logo: React.FunctionComponent = () => ( 14 |

15 | Landkid Status 16 |

17 | ); 18 | -------------------------------------------------------------------------------- /src/static/bitbucket/components/Errors.tsx: -------------------------------------------------------------------------------- 1 | import listStyles from './styles/listStyles'; 2 | 3 | type ErrorProps = { 4 | errors: string[]; 5 | }; 6 | 7 | const Errors = ({ errors }: ErrorProps) => 8 | errors.length > 0 ? ( 9 | <> 10 |
    11 | {errors.map((error) => ( 12 |
  • 13 | ))} 14 |
15 | 16 | ) : null; 17 | 18 | export default Errors; 19 | -------------------------------------------------------------------------------- /src/db/migrations/11__dependsOnPrIds.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: async function (query: QueryInterface, Sequelize: DataTypes) { 5 | return query.addColumn('LandRequest', 'dependsOnPrIds', { 6 | type: Sequelize.STRING({ length: 1000 }), // set the maximum length to 1000 7 | allowNull: true, 8 | }); 9 | }, 10 | async down() { 11 | throw new Error('NO DROP FUNCTION FOR THIS MIGRATION'); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/static/bitbucket/components/types.ts: -------------------------------------------------------------------------------- 1 | import { RunnerState } from '../../../types'; 2 | 3 | export type Status = 4 | | 'cannot-land' 5 | | 'queued' 6 | | 'running' 7 | | 'will-queue-when-ready' 8 | | 'can-land' 9 | | 'awaiting-merge' 10 | | 'merging' 11 | | 'pr-closed' 12 | | 'user-denied-access' 13 | | 'unknown-error'; 14 | 15 | export type LoadStatus = 'loaded' | 'not-loaded' | 'loading' | 'refreshing' | 'queuing'; 16 | export type QueueResponse = Pick; 17 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "lib": [ 6 | "es6", 7 | "dom", 8 | "es7" 9 | ], 10 | "sourceMap": true, 11 | "jsx": "react-jsx", 12 | "experimentalDecorators": true, 13 | "strictNullChecks": true, 14 | "noUnusedLocals": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "esModuleInterop": true, 18 | "allowSyntheticDefaultImports": true, 19 | "skipLibCheck": true 20 | }, 21 | } -------------------------------------------------------------------------------- /src/bitbucket/__mocks__/BitbucketClient.ts: -------------------------------------------------------------------------------- 1 | import { BitbucketAPI } from '../BitbucketAPI'; 2 | 3 | jest.mock('../BitbucketAPI'); 4 | 5 | const MockedClient: any = jest.genMockFromModule('../BitbucketClient'); 6 | 7 | export const BitbucketClient = jest.fn().mockImplementation((...args) => { 8 | const client = new MockedClient.BitbucketClient(...args); 9 | client.getUser.mockImplementation(() => ({})); 10 | client.createLandBuild.mockImplementation(() => 1); 11 | // Properties are not auto mocked by jest 12 | client.bitbucket = new BitbucketAPI({} as any); 13 | 14 | return client; 15 | }); 16 | -------------------------------------------------------------------------------- /src/static/current-state/components/Section.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | 4 | let styles = css({ 5 | marginTop: '45px', 6 | }); 7 | 8 | let importantStyles = css({ 9 | marginTop: '81px', 10 | }); 11 | 12 | export type Props = { 13 | important?: boolean; 14 | last?: boolean; 15 | }; 16 | 17 | export const Section: React.FunctionComponent = ({ children, important, last }) => ( 18 |
22 | {children} 23 |
24 | ); 25 | -------------------------------------------------------------------------------- /src/db/migrations/02__dependsOnColumn.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: function(query: QueryInterface, Sequelize: DataTypes) { 5 | return query.describeTable('LandRequest').then((table: any) => { 6 | if (table.dependsOn) return; 7 | return query.addColumn('LandRequest', 'dependsOn', { 8 | type: Sequelize.STRING, 9 | allowNull: true, 10 | }); 11 | }); 12 | }, 13 | // VIOLATES FOREIGN KEY CONSTRAINT 14 | down: function() { 15 | throw new Error('NO DROP FUNCTION FOR THIS MIGRATION'); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/db/migrations/01__targetBranchColumn.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: function(query: QueryInterface, Sequelize: DataTypes) { 5 | return query.describeTable('PullRequest').then((table: any) => { 6 | if (table.targetBranch) return; 7 | return query.addColumn('PullRequest', 'targetBranch', { 8 | type: Sequelize.STRING, 9 | allowNull: true, 10 | }); 11 | }); 12 | }, 13 | // VIOLATES FOREIGN KEY CONSTRAINT 14 | down: function() { 15 | throw new Error('NO DROP FUNCTION FOR THIS MIGRATION'); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/db/migrations/05__sourceBranchColumn.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: function(query: QueryInterface, Sequelize: DataTypes) { 5 | return query.describeTable('PullRequest').then((table: any) => { 6 | if (table.sourceBranch) return; 7 | return query.addColumn('PullRequest', 'sourceBranch', { 8 | type: Sequelize.STRING, 9 | allowNull: true, 10 | }); 11 | }); 12 | }, 13 | // VIOLATES FOREIGN KEY CONSTRAINT 14 | down: function() { 15 | throw new Error('NO DROP FUNCTION FOR THIS MIGRATION'); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/Events.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { config } from '../lib/Config'; 4 | import { Logger } from '../lib/Logger'; 5 | import { EventData } from '../types'; 6 | 7 | export const eventEmitter = new EventEmitter(); 8 | 9 | export const initializeEventListeners = () => { 10 | if (config.eventListeners) { 11 | config.eventListeners.forEach(({ event, listener }) => { 12 | eventEmitter.addListener(event, (data?: EventData) => { 13 | Logger.info(`Emitting event ${event}`, { data }); 14 | listener(data || {}, { Logger }); 15 | }); 16 | }); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/bitbucket/__mocks__/BitbucketPipelinesAPI.ts: -------------------------------------------------------------------------------- 1 | const MockedApi: any = jest.genMockFromModule('../BitbucketPipelinesAPI'); 2 | 3 | export const BitbucketPipelinesAPI = jest.fn().mockImplementation((...args) => { 4 | const api = new MockedApi.BitbucketPipelinesAPI(...args); 5 | 6 | // Properties are not auto mocked by jest 7 | // TODO: Convert class to use standard class methods so they are auto mocked 8 | api.processStatusWebhook = jest.fn(); 9 | api.createLandBuild = jest.fn(); 10 | api.stopLandBuild = jest.fn(); 11 | api.getPipelines = jest.fn(); 12 | api.getLandBuild = jest.fn(); 13 | 14 | return api; 15 | }); 16 | -------------------------------------------------------------------------------- /src/db/migrations/09__impactColumn.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: function (query: QueryInterface, Sequelize: DataTypes) { 5 | return query.describeTable('LandRequest').then((table: any) => { 6 | if (table.impact) return; 7 | return query.addColumn('LandRequest', 'impact', { 8 | type: Sequelize.INTEGER, 9 | allowNull: true, 10 | defaultValue: 0, 11 | }); 12 | }); 13 | }, 14 | // VIOLATES FOREIGN KEY CONSTRAINT 15 | down: function () { 16 | throw new Error('NO DROP FUNCTION FOR THIS MIGRATION'); 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/db/migrations/06__priorityColumn.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: function (query: QueryInterface, Sequelize: DataTypes) { 5 | return query.describeTable('LandRequest').then((table: any) => { 6 | if (table.priority) return; 7 | return query.addColumn('LandRequest', 'priority', { 8 | type: Sequelize.INTEGER, 9 | defaultValue: 0, 10 | allowNull: true, 11 | }); 12 | }); 13 | }, 14 | // VIOLATES FOREIGN KEY CONSTRAINT 15 | down: function () { 16 | throw new Error('NO DROP FUNCTION FOR THIS MIGRATION'); 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/db/migrations/10__speculationEngineAdminSettings.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | async up(queryInterface: QueryInterface, Sequelize: DataTypes) { 5 | const table: any = await queryInterface.describeTable('AdminSettings'); 6 | if (table.speculationEngineEnabled) return; 7 | return queryInterface.addColumn('AdminSettings', 'speculationEngineEnabled', { 8 | type: Sequelize.BOOLEAN, 9 | defaultValue: false, 10 | allowNull: false, 11 | }); 12 | }, 13 | 14 | async down() { 15 | throw new Error('NO DROP FUNCTION FOR THIS MIGRATION'); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/routes/bitbucket/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { BitbucketClient } from '../../bitbucket/BitbucketClient'; 4 | import { Runner } from '../../lib/Runner'; 5 | import { proxyRoutes } from './proxy'; 6 | import { webhookRoutes } from './webhook'; 7 | import { lifecycleRoutes } from './lifecycle'; 8 | 9 | export function bitbucketRoutes(runner: Runner, client: BitbucketClient) { 10 | const router = express(); 11 | 12 | router.use('/lifecycle', lifecycleRoutes(runner)); 13 | router.use('/proxy', proxyRoutes(runner, client)); 14 | router.use('/webhook', webhookRoutes(runner, client)); 15 | 16 | return router; 17 | } 18 | -------------------------------------------------------------------------------- /src/db/migrations/09__updateDependsOnColumn.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: async function (query: QueryInterface, Sequelize: DataTypes) { 5 | return query.changeColumn('LandRequest', 'dependsOn', { 6 | type: Sequelize.STRING({ length: 1000 }), // set the maximum length to 1000 7 | allowNull: true, 8 | }); 9 | }, 10 | down: async function (query: QueryInterface, Sequelize: DataTypes) { 11 | return query.changeColumn('LandRequest', 'dependsOn', { 12 | type: Sequelize.STRING(), // set the maximum length back to default 255 13 | allowNull: true, 14 | }); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/static/current-state/components/CurrentState.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Section } from './Section'; 3 | import { Panel } from './Panel'; 4 | import { RunnerState } from '../../../types'; 5 | 6 | export type Props = RunnerState; 7 | 8 | export const CurrentState: React.FunctionComponent = (props) => { 9 | const { pauseState } = props; 10 | 11 | const renderPausedPanel = () => ( 12 | 13 | Builds are currently paused 14 |
15 | {pauseState ? pauseState.reason || 'No reason was provided' : null} 16 |
17 | ); 18 | 19 | return
{pauseState ? renderPausedPanel() : ''}
; 20 | }; 21 | -------------------------------------------------------------------------------- /src/static/current-state/components/tabs/TabContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | 4 | let tabStyles = css({ 5 | marginTop: '27px', 6 | position: 'relative', 7 | borderTop: '1px solid var(--n20-color)', 8 | 9 | '&:before': { 10 | position: 'absolute', 11 | display: 'block', 12 | content: '""', 13 | width: '1px', 14 | height: '27px', 15 | background: 'var(--n20-color)', 16 | top: '-27px', 17 | left: '50%', 18 | marginLeft: '-1px', 19 | }, 20 | }); 21 | 22 | export const TabContent: React.FunctionComponent = (props) => { 23 | const { children } = props; 24 | return
{children}
; 25 | }; 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | redis2: 4 | image: 'redis' 5 | ports: 6 | - '6379:6379' 7 | postgres: 8 | image: 'postgres' 9 | ports: 10 | - '5434:5432' 11 | environment: 12 | POSTGRES_HOST_AUTH_METHOD: 'trust' 13 | statsd-mock-sidecar: 14 | image: docker.atl-paas.net/sox/observability/statsd-docker:latest 15 | ports: 16 | - '8125:8125/udp' 17 | environment: 18 | STATSD_BUSINESS_UNIT: 'Fabric' 19 | STATSD_RESOURCE_OWNER: 'lbatchelor' 20 | STATSD_SERVICE_NAME: 'atlassian-frontend-landkid' 21 | STATSD_HOSTNAME: 'platform-statsd' 22 | STATSD_USER_TAGS: "'service_name:atlassian-frontend-landkid', 'aws_region:local'" -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: [ 3 | { 4 | preset: 'ts-jest', 5 | displayName: 'Node tests', 6 | testEnvironment: 'node', 7 | testMatch: [ 8 | '/src/(auth|bitbucket|db|lib|routes)/**/__tests__/*.test.ts', 9 | '/src/(auth|bitbucket|db|lib|routes)/**/test.ts', 10 | ], 11 | }, 12 | { 13 | preset: 'ts-jest', 14 | displayName: 'UI tests', 15 | testEnvironment: 'jest-environment-jsdom', 16 | testMatch: ['**/static/**/__tests__/*.test.{tx,tsx}'], 17 | moduleNameMapper: { 18 | '@atlaskit/css-reset': '/src/static/__mocks__/styleMock.js', 19 | }, 20 | }, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /src/lib/utils/helper-functions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Helper functions 3 | */ 4 | 5 | // validates if a given source branch name is a priority branch 6 | export const validatePriorityBranch = ( 7 | priorityBranches: IPriorityBranch[], 8 | sourceBranch: string, 9 | ): boolean => { 10 | return priorityBranches.some((branch) => { 11 | if (branch.branchName === sourceBranch) return true; 12 | //check if branch name matches on a priority branch as an ANT pattern 13 | const antPatternMatcher = branch.branchName.split('*'); 14 | if (antPatternMatcher.length > 1) { 15 | const patternRegex = new RegExp(`^${antPatternMatcher[0]}`); 16 | if (patternRegex.test(sourceBranch)) return true; 17 | } 18 | return false; 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/static/bitbucket/components/Warnings.tsx: -------------------------------------------------------------------------------- 1 | import listStyles from './styles/listStyles'; 2 | 3 | type WarningProps = { 4 | warnings: string[]; 5 | }; 6 | 7 | import { css } from 'emotion'; 8 | import { N300 } from '@atlaskit/theme/colors'; 9 | 10 | const subtextStyles = css({ 11 | fontSize: '12px', 12 | color: N300, 13 | }); 14 | 15 | const Warnings = ({ warnings }: WarningProps) => 16 | warnings.length > 0 ? ( 17 | <> 18 |

19 | Warnings:

(these will not prevent landing)
20 |

21 |
    22 | {warnings.map((warning) => ( 23 |
  • 24 | ))} 25 |
26 | 27 | ) : null; 28 | 29 | export default Warnings; 30 | -------------------------------------------------------------------------------- /src/routes/bitbucket/webhook/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { Runner } from '../../../lib/Runner'; 4 | import { BitbucketClient } from '../../../bitbucket/BitbucketClient'; 5 | import { wrap, authenticateIncomingBBCall } from '../../middleware'; 6 | 7 | export function webhookRoutes(runner: Runner, client: BitbucketClient) { 8 | const router = express(); 9 | 10 | router.use(authenticateIncomingBBCall); 11 | 12 | router.post( 13 | '/status-updated', 14 | wrap(async (req, res) => { 15 | res.sendStatus(200); 16 | // status event will be null if we don't care about it 17 | const statusEvent = client.processStatusWebhook(req.body); 18 | if (!statusEvent) return; 19 | runner.onStatusUpdate(statusEvent); 20 | }), 21 | ); 22 | 23 | return router; 24 | } 25 | -------------------------------------------------------------------------------- /src/static/bitbucket/utils/getWidgetSettings.ts: -------------------------------------------------------------------------------- 1 | import { WidgetSettings } from '../../../types'; 2 | import { proxyRequestBare } from './RequestProxy'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | const defaultSettings = { 6 | refreshInterval: 10000, 7 | refreshOnlyWhenInViewport: false, 8 | enableSquashMerge: false, 9 | }; 10 | 11 | export default function useWidgetSettings(): WidgetSettings { 12 | const [widgetSettings, setWidgetSettings] = useState(defaultSettings); 13 | 14 | useEffect(() => { 15 | proxyRequestBare('/settings', 'POST') 16 | .then((settings) => { 17 | setWidgetSettings({ ...defaultSettings, ...settings }); 18 | }) 19 | .catch((err) => { 20 | console.error(err); 21 | }); 22 | }, []); 23 | 24 | return widgetSettings; 25 | } 26 | -------------------------------------------------------------------------------- /src/db/migrations/07__accountIdColumns.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: async function (query: QueryInterface, Sequelize: DataTypes) { 5 | await query.describeTable('LandRequest').then((table: any) => { 6 | if (table.triggererAccountId) return; 7 | return query.addColumn('LandRequest', 'triggererAccountId', { 8 | type: Sequelize.STRING, 9 | allowNull: true, 10 | }); 11 | }); 12 | return query.describeTable('PullRequest').then((table: any) => { 13 | if (table.authorAccountId) return; 14 | return query.addColumn('PullRequest', 'authorAccountId', { 15 | type: Sequelize.STRING, 16 | allowNull: true, 17 | }); 18 | }); 19 | }, 20 | // VIOLATES FOREIGN KEY CONSTRAINT 21 | down: function () { 22 | throw new Error('NO DROP FUNCTION FOR THIS MIGRATION'); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/static/bitbucket/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 14 | 15 | 16 |
17 |
18 | Loading Landkid.... 19 |
20 |
21 | 22 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/static/current-state/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Logo } from './Logo'; 3 | import { css } from 'emotion'; 4 | 5 | const headerStyles = css({ 6 | display: 'flex', 7 | alignItems: 'center', 8 | }); 9 | 10 | const userInfoStyles = css({ 11 | display: 'flex', 12 | alignItems: 'center', 13 | padding: 8, 14 | borderRadius: 6, 15 | '&:hover': { 16 | background: 'var(--n20-color)', 17 | }, 18 | }); 19 | 20 | const userNameStyles = css({ 21 | fontWeight: 'bold', 22 | }); 23 | 24 | interface HeaderProps { 25 | user?: ISessionUser; 26 | } 27 | 28 | export const Header: React.FunctionComponent = ({ user }) => ( 29 |
30 | 31 | {user ? ( 32 |
33 | {user.displayName} 34 |
35 | ) : null} 36 |
37 | ); 38 | -------------------------------------------------------------------------------- /src/static/bitbucket/components/Queue.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 2 | 3 | import { QueueBase } from './Queue'; 4 | 5 | export default { 6 | title: 'Queue', 7 | component: QueueBase, 8 | argTypes: { 9 | loadingState: { 10 | control: { type: 'select' }, 11 | options: ['not-loaded', 'loading', 'refreshing', 'error', 'loaded'], 12 | }, 13 | }, 14 | } as ComponentMeta; 15 | 16 | const Template: ComponentStory = (args) => ; 17 | 18 | export const Configurable = Template.bind({}); 19 | 20 | Configurable.args = { 21 | pullRequestId: 1, 22 | currentState: { 23 | queue: [{ request: { pullRequestId: 1 } }, { request: { pullRequestId: 2 } }], 24 | waitingToQueue: [ 25 | { request: { pullRequestId: 3 } }, 26 | { request: { pullRequestId: 4 } }, 27 | { request: { pullRequestId: 5 } }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/bitbucket/__mocks__/BitbucketAPI.ts: -------------------------------------------------------------------------------- 1 | const MockedApi: any = jest.genMockFromModule('../BitbucketAPI'); 2 | 3 | export const BitbucketAPI = jest.fn().mockImplementation((...args) => { 4 | const api = new MockedApi.BitbucketAPI(...args); 5 | // Properties are not auto mocked by jest 6 | // TODO: Convert class to use standard class methods so they are auto mocked 7 | api.mergePullRequest = jest.fn(); 8 | api.cancelMergePolling = jest.fn(); 9 | api.getPullRequest = jest.fn(); 10 | api.pullRequestHasConflicts = jest.fn(); 11 | api.getPullRequestBuildStatuses = jest.fn(); 12 | api.getPullRequestPriority = jest.fn(); 13 | api.getPRImpact = jest.fn(); 14 | api.getUser = jest.fn(); 15 | api.getRepository = jest.fn(); 16 | 17 | return api; 18 | }); 19 | // Mock static properties 20 | (BitbucketAPI as any).SUCCESS = 'success'; 21 | (BitbucketAPI as any).FAILED = 'failed'; 22 | (BitbucketAPI as any).ABORTED = 'aborted'; 23 | (BitbucketAPI as any).TIMEOUT = 'timeout'; 24 | -------------------------------------------------------------------------------- /src/lib/Logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import { isMatch } from 'micromatch'; 3 | 4 | process.stdout.isTTY = true; 5 | 6 | const ProdLogger = winston.createLogger({ 7 | level: 'http', 8 | format: winston.format.json(), 9 | transports: [new winston.transports.Console()], 10 | }); 11 | 12 | const DevLogger = winston.createLogger({ 13 | level: 'verbose', 14 | format: winston.format.combine( 15 | winston.format((log) => 16 | process.env.LOG_NAMESPACES && !isMatch(log.namespace || '', process.env.LOG_NAMESPACES) 17 | ? false 18 | : log, 19 | )(), 20 | winston.format.colorize(), 21 | winston.format.printf( 22 | ({ level, message, namespace, ...info }) => 23 | `${level}: ${namespace ? `[${namespace}] ` : ''}${message} ${JSON.stringify(info)}`, 24 | ), 25 | ), 26 | transports: [new winston.transports.Console()], 27 | }); 28 | 29 | export const Logger = process.env.NODE_ENV === 'production' ? ProdLogger : DevLogger; 30 | -------------------------------------------------------------------------------- /src/lib/History.ts: -------------------------------------------------------------------------------- 1 | import { LandRequestStatus, LandRequest, PullRequest } from '../db'; 2 | 3 | const PAGE_LEN = 20; 4 | 5 | export class LandRequestHistory { 6 | public getHistory = async (page: number): Promise => { 7 | const actualPage = page - 1; 8 | // First we need to know which landrequests have changed recently 9 | const latestLandRequestStatuses = await LandRequestStatus.findAndCountAll({ 10 | where: { 11 | isLatest: true, 12 | state: ['success', 'fail', 'aborted'], 13 | }, 14 | order: [['date', 'DESC']], 15 | limit: PAGE_LEN, 16 | offset: actualPage * PAGE_LEN, 17 | include: [ 18 | { 19 | model: LandRequest, 20 | include: [PullRequest], 21 | }, 22 | ], 23 | }); 24 | 25 | return { 26 | history: latestLandRequestStatuses.rows, 27 | count: latestLandRequestStatuses.count, 28 | pageLen: PAGE_LEN, 29 | }; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/__mocks__/Config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../../types'; 2 | 3 | export const hasConfig = true; 4 | 5 | export const config: Config = { 6 | port: 8080, 7 | baseUrl: '', 8 | landkidAdmins: [], 9 | repoConfig: { 10 | repoOwner: 'test', 11 | repoName: 'test-repo', 12 | }, 13 | widgetSettings: { 14 | refreshInterval: 60, 15 | refreshOnlyWhenInViewport: true, 16 | enableSquashMerge: false, 17 | }, 18 | prSettings: { 19 | requiredApprovals: 1, 20 | canApproveOwnPullRequest: false, 21 | requireClosedTasks: true, 22 | requireGreenBuild: true, 23 | allowLandWhenAble: true, 24 | }, 25 | deployment: { 26 | secret: 'foo', 27 | redis: { 28 | endpoint: '', 29 | port: 8081, 30 | }, 31 | oAuth: { 32 | key: 'foo', 33 | secret: 'bar', 34 | }, 35 | }, 36 | maxConcurrentBuilds: 2, 37 | permissionsMessage: '', 38 | mergeSettings: {}, 39 | queueSettings: { 40 | speculationEngineEnabled: false, 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/static/bitbucket/components/styles/loadingRectangleStyles.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'emotion'; 2 | import { gridSize } from '@atlaskit/theme/constants'; 3 | import { keyframes } from '@emotion/core'; 4 | 5 | const shimmer = keyframes` 6 | 0% { 7 | background-position: -300px 0; 8 | } 9 | 100% { 10 | background-position: 1000px 0; 11 | } 12 | `; 13 | 14 | const loadingRectangleStyles = css` 15 | display: inline-block; 16 | vertical-align: middle; 17 | position: relative; 18 | height: 0.8rem; 19 | margin: ${gridSize()}px 0 ${gridSize()}px; 20 | width: 100%; 21 | border-radius: 2px; 22 | animation-duration: 1.2s; 23 | animation-fill-mode: forwards; 24 | animation-iteration-count: infinite; 25 | animation-name: ${shimmer}; 26 | animation-timing-function: linear; 27 | background-color: #c9d7ea; 28 | background-image: linear-gradient(to right, #c9d7ea 10%, #d1dff3 20%, #c9d7ea 30%); 29 | background-repeat: no-repeat; 30 | `; 31 | 32 | export default loadingRectangleStyles; 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.16.1-alpine@sha256:bf6c61feabc1a1bd565065016abe77fa378500ec75efa67f5b04e5e5c4d447cd 2 | 3 | WORKDIR /opt/service 4 | 5 | # update all OS dependencies to prevent vuln's 6 | RUN apk update && apk upgrade apk-tools 7 | 8 | # Copy PJ, changes should invalidate entire image 9 | COPY package.json yarn.lock /opt/service/ 10 | 11 | ## Install dependencies 12 | RUN yarn --cache-folder ../ycache 13 | 14 | # Copy commong typings 15 | COPY typings /opt/service/typings 16 | 17 | # Copy TS configs 18 | COPY tsconfig* /opt/service/ 19 | 20 | # Build backend 21 | COPY src /opt/service/src 22 | 23 | # Build Frontend 24 | COPY webpack.*.js README.md /opt/service/ 25 | 26 | COPY tools /opt/service/tools 27 | 28 | # Build 29 | RUN NODE_ENV=production yarn build 30 | 31 | # Retain only dependencies 32 | RUN yarn --production --cache-folder ../ycache && rm -rf ../ycache 33 | 34 | ## Cleanup folders 35 | RUN rm -rf src && rm -rf tools && rm -rf typings 36 | 37 | ENV NODE_ENV=production 38 | 39 | EXPOSE 8080 40 | 41 | ENTRYPOINT ["npm", "run", "start", "--"] 42 | -------------------------------------------------------------------------------- /src/db/migrations/08__mergeStrategyColumn.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface } from 'sequelize'; 2 | 3 | export default { 4 | up: function (query: QueryInterface) { 5 | return query.describeTable('LandRequest').then((table: any) => { 6 | if (table.mergeStrategy) return; 7 | 8 | return query.sequelize 9 | .query('DROP TYPE IF EXISTS "enum_LandRequest_mergeStrategy";') 10 | .then(() => 11 | query.sequelize.query( 12 | "CREATE TYPE \"enum_LandRequest_mergeStrategy\" AS ENUM('squash', 'merge-commit');", 13 | ), 14 | ) 15 | .then(() => 16 | query.sequelize.query( 17 | 'ALTER TABLE "LandRequest" ADD COLUMN "mergeStrategy" "enum_LandRequest_mergeStrategy";', 18 | ), 19 | ); 20 | }); 21 | }, 22 | down: function (query: QueryInterface) { 23 | return query.sequelize 24 | .query('DROP TYPE IF EXISTS "enum_LandRequest_mergeStrategy";') 25 | .then(() => query.sequelize.query('ALTER TABLE "LandRequest" DROP COLUMN "mergeStrategy";')); 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/static/current-state/components/tabs/QueueTab.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TabContent } from './TabContent'; 3 | import { QueueItemsList } from '../QueueItemsList'; 4 | import { EmptyState } from '../EmptyState'; 5 | 6 | export type QueueTabProps = { 7 | bitbucketBaseUrl: string; 8 | loggedInUser: ISessionUser; 9 | queue: IStatusUpdate[]; 10 | permissionsMessage: string; 11 | refreshData: () => void; 12 | }; 13 | 14 | export const QueueTab: React.FunctionComponent = (props) => { 15 | const { bitbucketBaseUrl, loggedInUser, queue, permissionsMessage, refreshData } = props; 16 | return ( 17 |
18 | ( 23 | 24 | 25 | {loggedInUser.permission === 'read' ? permissionsMessage : 'Queue is empty...'} 26 | 27 | 28 | )} 29 | refreshData={refreshData} 30 | /> 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/lib/utils/__tests__/helper-function.test.ts: -------------------------------------------------------------------------------- 1 | import { validatePriorityBranch } from '../helper-functions'; 2 | 3 | describe('helper functions', () => { 4 | const priorityBranchList: IPriorityBranch[] = [ 5 | { 6 | id: 'test-id', 7 | branchName: 'test/*', 8 | adminAaid: 'test-aaid', 9 | date: '2023-01-25T04:28:07.817Z' as unknown as Date, 10 | }, 11 | { 12 | id: 'test-id2', 13 | branchName: 'test-branch', 14 | adminAaid: 'test-aaid', 15 | date: '2023-01-25T04:28:07.817Z' as unknown as Date, 16 | }, 17 | ]; 18 | test('returns true when source branch matches ANT pattern in priority list', () => { 19 | expect(validatePriorityBranch(priorityBranchList, 'test/test-branch')).toBe(true); 20 | }); 21 | test('returns true when source branch matches priority list', () => { 22 | expect(validatePriorityBranch(priorityBranchList, 'test-branch')).toBe(true); 23 | }); 24 | test('returns false when source branch does not match in the priority list', () => { 25 | expect(validatePriorityBranch(priorityBranchList, 'test-branch2')).toBe(false); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | docker: 6 | - image: cimg/node:18.16.1 7 | working_directory: ~/repo 8 | steps: 9 | - checkout 10 | # - restore_cache: 11 | # keys: 12 | # - v1-dependencies-{{ checksum "yarn.lock" }} 13 | # - v1-dependencies- 14 | - run: yarn 15 | - save_cache: 16 | paths: 17 | - node_modules 18 | key: v1-dependencies-{{ checksum "yarn.lock" }} 19 | - run: yarn build 20 | 21 | unit_test: 22 | docker: 23 | - image: cimg/node:18.16.0 24 | working_directory: ~/repo 25 | steps: 26 | - checkout 27 | # - restore_cache: 28 | # keys: 29 | # - v1-dependencies-{{ checksum "yarn.lock" }} 30 | # - v1-dependencies- 31 | - run: yarn 32 | - save_cache: 33 | paths: 34 | - node_modules 35 | key: v1-dependencies-{{ checksum "yarn.lock" }} 36 | - run: yarn test:unit 37 | 38 | workflows: 39 | version: 2 40 | build: 41 | jobs: 42 | - build 43 | unit_test: 44 | jobs: 45 | - unit_test 46 | -------------------------------------------------------------------------------- /src/lib/utils/locker.ts: -------------------------------------------------------------------------------- 1 | import RedLock from 'redlock'; 2 | import { client } from './redis-client'; 3 | import { Logger } from '../Logger'; 4 | 5 | const redlock = new RedLock([client]); 6 | 7 | export const withLock = async ( 8 | resource: string, 9 | fn: (lockId: Date) => Promise, 10 | fallback: T, 11 | ttl: number = 60000, 12 | ) => { 13 | let lock: RedLock.Lock; 14 | let lockId: Date; 15 | try { 16 | lock = await redlock.lock(resource, ttl); 17 | lockId = new Date(); 18 | Logger.info(`Locked "${resource}"`, { 19 | namespace: 'lib:utils:locker:withLock', 20 | lockId, 21 | }); 22 | } catch { 23 | return fallback; 24 | } 25 | let result: T; 26 | try { 27 | result = await fn(lockId); 28 | } catch (err) { 29 | Logger.error(`Error failed while in lock for "${resource}"`, { 30 | namespace: 'lib:utils:locker:withLock', 31 | errString: String(err), 32 | errStack: String(err.stack), 33 | }); 34 | } 35 | await lock.unlock(); 36 | Logger.info(`Unlocked "${resource}"`, { 37 | namespace: 'lib:utils:locker:withLock', 38 | lockId, 39 | }); 40 | return result!; 41 | }; 42 | -------------------------------------------------------------------------------- /src/lib/Config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { Config } from '../types'; 4 | import { Logger } from './Logger'; 5 | 6 | const getConfig = (): Config | null => { 7 | try { 8 | const config = require(path.resolve(process.cwd(), 'config.js')) as Config; 9 | 10 | // Some parts of the config that are commonly forgotten deserve a louder, more descriptive message 11 | let configValid = true; 12 | 13 | if (!config.deployment.oAuth.secret || !config.deployment.oAuth.key) { 14 | Logger.error('deployment.oAuth.secret or deployment.oAuth.key are missing', { 15 | namespace: 'lib:config:getConfig', 16 | }); 17 | configValid = false; 18 | } 19 | 20 | if (!config.permissionsMessage) { 21 | config.permissionsMessage = 'Contact an admin for permission to view this information'; 22 | } 23 | 24 | if (!configValid) { 25 | throw new Error('Config is invalid'); 26 | } 27 | 28 | return config; 29 | } catch (e) { 30 | console.log('Error whilst loading config', e); 31 | return null; 32 | } 33 | }; 34 | 35 | export const config = getConfig()!; 36 | 37 | export const hasConfig = config !== null; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-present James Kyle , Luke Batchelor 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/routes/auth/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import passport from 'passport'; 3 | import { permissionService } from '../../lib/PermissionService'; 4 | import { wrap } from '../middleware'; 5 | import { Logger } from '../../lib/Logger'; 6 | import { Config } from '../../types'; 7 | 8 | export function authRoutes(config: Config) { 9 | const router = express(); 10 | 11 | const authStrategies = config.deployment.enableBasicAuth ? ['basic', 'bitbucket'] : 'bitbucket'; 12 | 13 | router.get('/', passport.authenticate(authStrategies), (req, res) => { 14 | res.redirect('/current-state'); 15 | }); 16 | 17 | router.get( 18 | '/callback', 19 | passport.authenticate('bitbucket', { failureRedirect: '/auth' }), 20 | (req, res) => { 21 | res.redirect('/current-state'); 22 | }, 23 | ); 24 | 25 | router.get( 26 | '/whoami', 27 | wrap(async (req, res) => { 28 | Logger.verbose('Requesting whoami', { namespace: 'routes:auth:whoami', user: req.user }); 29 | res.json({ 30 | loggedIn: !!req.user, 31 | user: req.user, 32 | permission: req.user ? await permissionService.getPermissionForUser(req.user.aaid) : 'read', 33 | }); 34 | }), 35 | ); 36 | 37 | return router; 38 | } 39 | -------------------------------------------------------------------------------- /src/static/current-state/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | 4 | import { BadgeAppearance } from './types'; 5 | 6 | let styles = css({ 7 | display: 'inline-block', 8 | fontSize: '12px', 9 | fontWeight: 'normal', 10 | lineHeight: 1, 11 | minWidth: '1px', 12 | textAlign: 'center', 13 | borderRadius: '2em', 14 | padding: '0.166667em 0.5em', 15 | }); 16 | 17 | let appearance = { 18 | default: { 19 | backgroundColor: 'var(--n30-color)', 20 | color: 'var(--n800-color)', 21 | }, 22 | primary: { 23 | backgroundColor: 'var(--b400-color)', 24 | color: 'var(--n0-color)', 25 | }, 26 | important: { 27 | backgroundColor: 'var(--r300-color)', 28 | color: 'var(--n0-color)', 29 | }, 30 | added: { 31 | backgroundColor: 'var(--g50-color)', 32 | color: 'var(--g500-color)', 33 | }, 34 | }; 35 | 36 | export type Props = { 37 | appearance?: BadgeAppearance; 38 | }; 39 | 40 | export const Badge: React.FunctionComponent = (props) => { 41 | let selectedAppearance = props.appearance ? appearance[props.appearance] : appearance.default; 42 | return ( 43 | 44 | {props.children} 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/lib/__mocks__/StateService.ts: -------------------------------------------------------------------------------- 1 | const MockedStateServiceModule: any = jest.genMockFromModule('../StateService'); 2 | 3 | export const StateService = MockedStateServiceModule.StateService; 4 | 5 | StateService.updateMaxConcurrentBuild.mockResolvedValue(true); 6 | StateService.getMaxConcurrentBuilds.mockResolvedValue(2); 7 | StateService.getPauseState.mockResolvedValue(null); 8 | StateService.getBannerMessageState.mockResolvedValue(null); 9 | StateService.getPriorityBranches.mockResolvedValue([ 10 | { 11 | id: 'test-id', 12 | branchName: 'test-branch/*', 13 | adminAaid: 'test-aaid', 14 | date: '2023-01-25T04:28:07.817Z', 15 | }, 16 | ]); 17 | StateService.getAdminSettings.mockResolvedValue({ 18 | speculationEngineEnabled: false, 19 | mergeBlockingEnabled: false, 20 | }); 21 | StateService.getState.mockResolvedValue({ 22 | bannerMessageState: null, 23 | pauseState: null, 24 | maxConcurrentBuilds: 2, 25 | daysSinceLastFailure: 10, 26 | priorityBranchList: [ 27 | { 28 | id: 'test-id', 29 | branchName: 'test-branch/*', 30 | adminAaid: 'test-aaid', 31 | date: '2023-01-25T04:28:07.817Z', 32 | }, 33 | ], 34 | }); 35 | StateService.addPriorityBranch.mockResolvedValue(true); 36 | StateService.removePriorityBranch.mockResolvedValue(true); 37 | StateService.updateAdminSettings.mockResolvedValue(true); 38 | -------------------------------------------------------------------------------- /src/lib/__mocks__/Runner.ts: -------------------------------------------------------------------------------- 1 | jest.mock('../utils/locker'); 2 | 3 | const MockedRunnerModule: any = jest.genMockFromModule('../Runner'); 4 | 5 | export const Runner = jest.fn().mockImplementation((...args) => { 6 | const runner = new MockedRunnerModule.Runner(...args); 7 | // Properties are not auto mocked by jest 8 | runner.getMaxConcurrentBuilds = jest.fn(); 9 | runner.getQueue = jest.fn(); 10 | runner.getRunning = jest.fn(); 11 | runner.getWaitingAndQueued = jest.fn(); 12 | runner.moveFromQueueToRunning = jest.fn(); 13 | runner.moveFromAwaitingMerge = jest.fn(); 14 | runner.failDueToDependency = jest.fn(); 15 | runner.onStatusUpdate = jest.fn(); 16 | runner.cancelRunningBuild = jest.fn(); 17 | runner.enqueue = jest.fn(); 18 | runner.addToWaitingToLand = jest.fn(); 19 | runner.moveFromWaitingToQueued = jest.fn(); 20 | runner.removeLandRequestFromQueue = jest.fn(); 21 | runner.updateLandRequestPriority = jest.fn(); 22 | runner.checkWaitingLandRequests = jest.fn(); 23 | runner.getStatusesForLandRequests = jest.fn(); 24 | runner.getLandRequestStateByPRId = jest.fn(); 25 | runner.getHistory = jest.fn(); 26 | runner.getInstallationIfExists = jest.fn(); 27 | runner.deleteInstallation = jest.fn(); 28 | runner.clearHistory = jest.fn(); 29 | runner.clearLandWhenAbleQueue = jest.fn(); 30 | runner.getState = jest.fn(); 31 | 32 | return runner; 33 | }); 34 | -------------------------------------------------------------------------------- /src/static/current-state/components/tabs/MergingTab.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getGroupedByTargetBranch } from '../../utils/LandRequestUtils'; 3 | import { EmptyState } from '../EmptyState'; 4 | import { QueueItemsList } from '../QueueItemsList'; 5 | import { TabContent } from './TabContent'; 6 | 7 | export type Props = { 8 | bitbucketBaseUrl: string; 9 | loggedInUser: ISessionUser; 10 | merging: IStatusUpdate[]; 11 | permissionsMessage: string; 12 | refreshData: () => void; 13 | }; 14 | 15 | export const MergingTab: React.FunctionComponent = (props) => { 16 | const { bitbucketBaseUrl, merging, refreshData } = props; 17 | if (merging.length === 0) { 18 | return ( 19 | 20 | Merging queue is empty... 21 | 22 | ); 23 | } 24 | 25 | const groupedByTargetBranch: { [branch: string]: IStatusUpdate[] } = 26 | getGroupedByTargetBranch(merging); 27 | return ( 28 | <> 29 | {Object.keys(groupedByTargetBranch).map((branch) => ( 30 |
31 | 38 |
39 | ))} 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/static/current-state/components/User.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = { 4 | aaid: string; 5 | placeholder?: React.StatelessComponent; 6 | children: (user: ISessionUser) => React.ReactChild; 7 | }; 8 | 9 | export type State = { 10 | info?: ISessionUser; 11 | }; 12 | 13 | export class User extends React.Component { 14 | state: State = {}; 15 | 16 | componentDidMount() { 17 | this.fetch(this.props.aaid); 18 | } 19 | 20 | componentWillUpdate(newProps: Props) { 21 | if (newProps.aaid !== this.props.aaid) { 22 | this.fetch(this.props.aaid); 23 | } 24 | } 25 | 26 | private key = (aaid: string) => `user-info:${aaid}`; 27 | 28 | private async fetch(aaid: string) { 29 | const cached = localStorage.getItem(this.key(aaid)); 30 | if (cached) { 31 | return this.setState({ 32 | info: JSON.parse(cached), 33 | }); 34 | } 35 | const response = await fetch(`/api/user/${aaid}`); 36 | const fresh = await response.json(); 37 | localStorage.setItem(this.key(aaid), JSON.stringify(fresh)); 38 | this.setState({ 39 | info: fresh, 40 | }); 41 | } 42 | 43 | render() { 44 | const Placeholder = this.props.placeholder; 45 | if (!this.state.info) { 46 | if (Placeholder) return ; 47 | return null; 48 | } 49 | 50 | return this.props.children(this.state.info); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/static/current-state/components/RunningBuilds.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Section } from './Section'; 3 | import { QueueItemsList } from './QueueItemsList'; 4 | import { getGroupedByTargetBranch } from '../utils/LandRequestUtils'; 5 | 6 | export type Props = { 7 | queue: IStatusUpdate[]; 8 | bitbucketBaseUrl: string; 9 | refreshData: () => void; 10 | }; 11 | 12 | function findRunning(updates: IStatusUpdate[]) { 13 | return updates.filter(({ state }) => state === 'running'); 14 | } 15 | 16 | export const RunningBuilds: React.FunctionComponent = (props) => { 17 | const running = findRunning(props.queue); 18 | 19 | if (running.length === 0) { 20 | return React.createElement( 21 | 'marquee', 22 | { style: { fontSize: '24px', color: 'lightskyblue' } }, 23 | 'No currently running builds', 24 | ); 25 | } 26 | 27 | const groupedByTargetBranch: { [branch: string]: IStatusUpdate[] } = 28 | getGroupedByTargetBranch(running); 29 | 30 | return ( 31 |
32 |

Running Builds

33 | {Object.keys(groupedByTargetBranch).map((branch) => ( 34 |
35 | 41 |
42 | ))} 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/static/bitbucket/utils/RequestProxy.ts: -------------------------------------------------------------------------------- 1 | interface BBRequest { 2 | (opts: { 3 | url: string; 4 | type?: string; 5 | data?: string; 6 | contentType?: string; 7 | success: (resp: T) => void; 8 | error: (err: any) => void; 9 | }): void; 10 | } 11 | 12 | const AP = (window as any).AP as { 13 | require: (name: 'proxyRequest', fn: (req: BBRequest) => void) => void; 14 | }; 15 | 16 | export function proxyRequest(url: string, type: string, data?: object): Promise { 17 | return new Promise((resolve, reject) => { 18 | const qs = new URLSearchParams(window.location.search); 19 | const repoId = qs.get('repoId'); 20 | const pullRequestId = qs.get('pullRequestId'); 21 | const contentType = 'application/json'; 22 | 23 | AP.require('proxyRequest', (req) => { 24 | req({ 25 | url: `${url}/${repoId}/${pullRequestId}`, 26 | type, 27 | data: JSON.stringify(data), 28 | contentType, 29 | success: (resp) => resolve(resp as any), 30 | error: (err) => reject(err), 31 | }); 32 | }); 33 | }); 34 | } 35 | 36 | export function proxyRequestBare(url: string, type: string): Promise { 37 | return new Promise((resolve, reject) => { 38 | AP.require('proxyRequest', (req) => { 39 | req({ 40 | url: url, 41 | type, 42 | success: (resp) => resolve(resp as any), 43 | error: (err) => reject(err), 44 | }); 45 | }); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/static/bitbucket/components/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen } from '@testing-library/react'; 2 | import App from '../App'; 3 | import { proxyRequest, proxyRequestBare } from '../../utils/RequestProxy'; 4 | 5 | jest.mock('../../utils/RequestProxy'); 6 | 7 | describe('App', () => { 8 | let originalLocation = window.location; 9 | 10 | beforeEach(() => { 11 | // @ts-ignore 12 | delete window.location; 13 | 14 | // IntersectionObserver isn't available in test environment 15 | const mockIntersectionObserver = jest.fn(); 16 | mockIntersectionObserver.mockReturnValue({ 17 | observe: () => null, 18 | unobserve: () => null, 19 | disconnect: () => null, 20 | }); 21 | window.IntersectionObserver = mockIntersectionObserver; 22 | }); 23 | 24 | afterEach(() => { 25 | window.location = originalLocation; 26 | }); 27 | 28 | test('renders not read to land state', async () => { 29 | // @ts-ignore 30 | window.location = { search: '?state=OPEN' }; 31 | 32 | (proxyRequest as jest.Mock).mockResolvedValue({ 33 | canLand: false, 34 | canLandWhenAble: true, 35 | errors: ['All tasks must be resolved'], 36 | warnings: [], 37 | bannerMessage: null, 38 | }); 39 | (proxyRequestBare as jest.Mock).mockResolvedValue({}); 40 | 41 | act(() => { 42 | render(); 43 | }); 44 | 45 | expect(await screen.findByText('Not ready to land')).toBeDefined(); 46 | expect(await screen.findByText('All tasks must be resolved')).toBeDefined(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/db/migrations/03__pauseStateTable.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: function(query: QueryInterface, Sequelize: DataTypes) { 5 | return query.dropTable('PauseStateTransition').then(() => 6 | query.createTable('PauseState', { 7 | pauserAaid: { 8 | type: Sequelize.STRING, 9 | primaryKey: true, 10 | allowNull: false, 11 | }, 12 | reason: { 13 | type: Sequelize.STRING({ length: 2000 }), 14 | allowNull: true, 15 | }, 16 | date: { 17 | type: Sequelize.DATE, 18 | allowNull: false, 19 | defaultValue: Sequelize.NOW, 20 | }, 21 | }), 22 | ); 23 | }, 24 | down: function(query: QueryInterface, Sequelize: DataTypes) { 25 | return query.dropTable('PauseState').then(() => 26 | query.createTable('PauseStateTransition', { 27 | id: { 28 | type: Sequelize.UUID, 29 | primaryKey: true, 30 | defaultValue: Sequelize.UUIDV4, 31 | }, 32 | pauserAaid: { 33 | type: Sequelize.STRING, 34 | allowNull: false, 35 | }, 36 | paused: { 37 | type: Sequelize.BOOLEAN, 38 | allowNull: false, 39 | }, 40 | reason: { 41 | type: Sequelize.STRING({ length: 2000 }), 42 | allowNull: true, 43 | }, 44 | date: { 45 | type: Sequelize.DATE, 46 | allowNull: false, 47 | defaultValue: Sequelize.NOW, 48 | }, 49 | }), 50 | ); 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /src/db/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | const MockedDb: any = jest.genMockFromModule('../index'); 2 | 3 | export const Installation = MockedDb.Installation; 4 | export const LandRequest = jest.fn((props) => { 5 | const req = new MockedDb.LandRequest(props); 6 | req.save.mockImplementation(() => ({ id: '1' })); 7 | Object.assign(req, { 8 | ...props, 9 | // Jest does not auto mock properties that aren't on the prototype 10 | getStatus: jest.fn(), 11 | setStatus: jest.fn(), 12 | getDependencies: jest.fn(() => []), 13 | getFailedDependencies: jest.fn(() => []), 14 | updatePriority: jest.fn(), 15 | incrementPriority: jest.fn(), 16 | decrementPriority: jest.fn(), 17 | getQueuedDate: jest.fn(() => new Date('2020-01-01')), 18 | updateImpact: jest.fn(), 19 | }); 20 | return req; 21 | }); 22 | export const LandRequestStatus = jest.fn((props) => { 23 | const req = new MockedDb.LandRequestStatus(props); 24 | Object.assign(req, { ...props }); 25 | return req; 26 | }); 27 | export const PullRequest = jest.fn((props) => { 28 | const req = new MockedDb.PullRequest(props); 29 | Object.assign(req, { ...props }); 30 | return req; 31 | }); 32 | export const Permission = MockedDb.Permission; 33 | export const UserNote = MockedDb.UserNote; 34 | export const PauseState = MockedDb.PauseState; 35 | export const BannerMessageState = MockedDb.BannerMessageState; 36 | export const ConcurrentBuildState = MockedDb.ConcurrentBuildState; 37 | export const PriorityBranch = MockedDb.PriorityBranch; 38 | export const AdminSettings = MockedDb.AdminSettings; 39 | export const initializeSequelize = MockedDb.initializeSequelize; 40 | -------------------------------------------------------------------------------- /src/static/current-state/components/Lozenge.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | 4 | import { LozengeAppearance } from './types'; 5 | 6 | let styles = css({ 7 | boxSizing: 'border-box', 8 | display: 'inline-block', 9 | fontSize: '11px', 10 | fontWeight: 700, 11 | lineHeight: '1', 12 | maxWidth: '180px', 13 | textTransform: 'uppercase', 14 | verticalAlign: 'baseline', 15 | whiteSpace: 'nowrap', 16 | borderRadius: '3px', 17 | padding: '2px 4px 3px', 18 | overflow: 'hidden', 19 | textOverflow: 'ellipsis', 20 | }); 21 | 22 | let appearance = { 23 | default: { 24 | backgroundColor: 'var(--n20-color)', 25 | color: 'var(--n500-color)', 26 | }, 27 | success: { 28 | backgroundColor: 'var(--g50-color)', 29 | color: 'var(--g500-color)', 30 | }, 31 | removed: { 32 | backgroundColor: 'var(--r50-color)', 33 | color: 'var(--r500-color)', 34 | }, 35 | moved: { 36 | backgroundColor: 'var(--y50-color)', 37 | color: 'var(--n600-color)', 38 | }, 39 | new: { 40 | backgroundColor: 'var(--p50-color)', 41 | color: 'var(--p500-color)', 42 | }, 43 | inprogress: { 44 | backgroundColor: 'var(--b50-color)', 45 | color: 'var(--b500-color)', 46 | }, 47 | }; 48 | 49 | export type Props = { 50 | appearance?: LozengeAppearance; 51 | title?: string; 52 | }; 53 | 54 | export const Lozenge: React.FunctionComponent = (props) => { 55 | let selectedApperance = props.appearance ? appearance[props.appearance] : appearance.default; 56 | 57 | return ( 58 | 59 | {props.children} 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | workflow_dispatch: 7 | jobs: 8 | publish: 9 | name: Publish Image 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | permissions: 15 | contents: read 16 | id-token: write 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - uses: CultureHQ/actions-yarn@v1.0.1 27 | - run: yarn 28 | - run: yarn build 29 | - name: Get Artifact Publish Token 30 | id: publish-token 31 | uses: atlassian-labs/artifact-publish-token@v1.0.1 32 | with: 33 | output-modes: environment 34 | - name: Publish artifact 35 | run: | 36 | export REPO=atlassianlabs/landkid 37 | export TAG=`if [ "${GITHUB_REF##*/}" == "master" ]; then echo "latest"; else echo $GITHUB_REF ; fi` 38 | echo "${ARTIFACTORY_API_KEY}" | docker login docker-public.packages.atlassian.com -u "${ARTIFACTORY_USERNAME}" --password-stdin 39 | docker build -f Dockerfile -t $REPO:$GITHUB_SHA . 40 | docker tag $REPO:$GITHUB_SHA docker-public.packages.atlassian.com/$REPO:$TAG 41 | docker tag $REPO:$GITHUB_SHA docker-public.packages.atlassian.com/$REPO:action-${{github.run_number}} 42 | docker push docker-public.packages.atlassian.com/$REPO:$TAG 43 | docker push docker-public.packages.atlassian.com/$REPO:action-${{github.run_number}} 44 | -------------------------------------------------------------------------------- /src/lib/AccountService.ts: -------------------------------------------------------------------------------- 1 | import { client } from './utils/redis-client'; 2 | 3 | import { promisify } from 'util'; 4 | import { withLock } from './utils/locker'; 5 | import { BitbucketClient } from '../bitbucket/BitbucketClient'; 6 | 7 | const getAsync: (key: string) => Promise = promisify(client.get).bind(client); 8 | const setAsync: (key: string, value: string) => Promise = promisify(client.set).bind(client); 9 | 10 | let instance: AccountService | null = null; 11 | 12 | export class AccountService { 13 | static KEY_PREFIX = 'account-service-v1'; 14 | 15 | static get(client: BitbucketClient) { 16 | if (!instance) { 17 | instance = new AccountService(client); 18 | } 19 | return instance; 20 | } 21 | 22 | constructor(private client: BitbucketClient) {} 23 | 24 | private key = (suffix: string) => `${AccountService.KEY_PREFIX}:${suffix}`; 25 | private resource = (suffix: string) => `resource:${this.key(suffix)}`; 26 | 27 | private loadAccountInfo = (aaid: string): Promise => { 28 | return this.client.bitbucket.getUser(aaid); 29 | }; 30 | 31 | public getAccountInfo = async (aaid: string, retry = 5): Promise => { 32 | if (retry === 0) return null; 33 | 34 | const info = await withLock( 35 | this.resource(aaid), 36 | async (): Promise => { 37 | const cached = await getAsync(this.key(aaid)); 38 | if (!cached) { 39 | const accountInfo = await this.loadAccountInfo(aaid); 40 | await setAsync(this.key(aaid), JSON.stringify(accountInfo)); 41 | return accountInfo; 42 | } 43 | 44 | return JSON.parse(cached); 45 | }, 46 | undefined, 47 | ); 48 | return info || this.getAccountInfo(aaid, retry - 1); 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/static/current-state/components/QueueItemsList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from 'emotion'; 3 | import { QueueItem, QueueItemJoined } from './QueueItem'; 4 | import { EmptyState } from './EmptyState'; 5 | 6 | let fadingStyles = css({ 7 | '& .queue-item-joined:nth-child(1) .queue-item': { 8 | opacity: 0.7, 9 | }, 10 | '& .queue-item-joined:nth-child(2) .queue-item': { 11 | opacity: 0.5, 12 | }, 13 | '& .queue-item-joined:nth-child(n+3) .queue-item': { 14 | opacity: 0.3, 15 | }, 16 | }); 17 | 18 | export type QueueItemsListProps = { 19 | queue: Array; 20 | fading?: boolean; 21 | renderEmpty?: () => JSX.Element; 22 | running?: boolean; 23 | bitbucketBaseUrl: string; 24 | refreshData: () => void; 25 | }; 26 | 27 | export const QueueItemsList: React.FunctionComponent = (props) => { 28 | const { queue, fading, renderEmpty, running, refreshData } = props; 29 | const filteredQueue = queue.filter((item) => running || item.state === 'queued'); 30 | if (!filteredQueue.length) { 31 | return renderEmpty ? renderEmpty() : Empty...; 32 | } 33 | 34 | return ( 35 |
36 | {filteredQueue.map((item, index) => 37 | running && index === 0 ? ( 38 | 45 | ) : ( 46 | 53 | ), 54 | )} 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /ToDo.md: -------------------------------------------------------------------------------- 1 | # TO Do 2 | 3 | ## Sam 4 | 5 | * [x] Auth 6 | * [x] bb addon auth 7 | * [x] verify loaded in bb and not external 8 | * [x] verify QSH to read AAID 9 | * [x] User login on front end (oAuth 2.0) 10 | * [ ] Admin panel 11 | * [x] Pauser aaid 12 | * [x] User permissions 13 | * [x] frontend checks 14 | * [x] backend checks 15 | * [x] Clean up and require auth for /api endpoints 16 | * [x] Remove abstractions 17 | * [x] Server entry point - borked 18 | * [x] webpack dev server 19 | * [x] passing in config 20 | * [x] scripts for dev + prod 21 | * [x] AAID to User Info endpoint 22 | * [ ] validate secret from bb on installation 23 | * [x] Linting 24 | * [x] CI 25 | * [ ] Move BB requests to using app config (to avoid rate limiting) 26 | 27 | ## Luke 28 | 29 | * [x] History 30 | * [x] Basic history 31 | * [x] Pagination 32 | * [x] AllowedToLand check is broken 33 | * [x] Productionised config 34 | * [x] addon frontend in react 35 | * [x] Allow refresh/back on bb addon 36 | * [ ] add websockets :D :D :D 37 | * [x] Move the static files route 38 | * [x] Clean up serving of ac.json 39 | * [x] Remove cancel release 40 | * [x] Fix binary file and files arr in package.json 41 | * [ ] Add # routes to select tabs 42 | * [x] Load oAuth from config 43 | * [x] Better error messages from config 44 | * [x] Better error messages from APIs 45 | * [ ] Validate commit hasnt changed before merge 46 | * [ ] Logout 47 | * [ ] Remove UUIDs from config 48 | * [x] Don't require passing in repo-uuid 49 | * [ ] Don't require admins to be AAID 50 | * [-] Don't require hacking ac.json to personal 51 | * [x] Add pausing to admin ui 52 | * [x] show LANDKID_DEPLOYMENT in webpanel 53 | * [ ] document deployment process 54 | * [ ] Create nicer landing page 55 | * [ ] Log stack traces on error (https://github.com/bithavoc/express-winston) 56 | 57 | ## Someone 58 | 59 | * [x] Ensure deploy ain't broken 60 | -------------------------------------------------------------------------------- /src/static/current-state/components/PermissionControl.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = { 4 | user: ISessionUser; 5 | userPermission: IPermissionMode; 6 | loggedInUser: ISessionUser; 7 | }; 8 | 9 | export type State = { 10 | actualPermission: IPermissionMode; 11 | loading: boolean; 12 | }; 13 | 14 | export class PermissionControl extends React.Component { 15 | constructor(props: Props) { 16 | super(props); 17 | this.state = { 18 | actualPermission: props.userPermission, 19 | loading: false, 20 | }; 21 | } 22 | 23 | onPermissionChange: React.ChangeEventHandler = (e) => { 24 | const mode = e.target.value as IPermissionMode; 25 | this.setState({ loading: true }, () => { 26 | fetch(`/api/permission/${this.props.user.aaid}`, { 27 | method: 'PATCH', 28 | headers: new Headers({ 'Content-Type': 'application/json' }), 29 | body: JSON.stringify({ mode }), 30 | }).then(() => { 31 | this.setState({ 32 | loading: false, 33 | actualPermission: mode, 34 | }); 35 | }); 36 | }); 37 | }; 38 | 39 | render() { 40 | const { loggedInUser } = this.props; 41 | const { actualPermission, loading } = this.state; 42 | return ( 43 | 44 | 58 | {loading &&
🤔...
} 59 |
60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/db/migrations/04__bannerMessageStateTable.ts: -------------------------------------------------------------------------------- 1 | import { QueryInterface, DataTypes } from 'sequelize'; 2 | 3 | export default { 4 | up: function(query: QueryInterface, Sequelize: DataTypes) { 5 | return query.dropTable('MessageStateTransition').then(() => 6 | query.createTable('BannerMessageState', { 7 | senderAaid: { 8 | type: Sequelize.STRING, 9 | primaryKey: true, 10 | allowNull: false, 11 | }, 12 | message: { 13 | type: Sequelize.STRING({ length: 2000 }), 14 | allowNull: false, 15 | }, 16 | messageType: { 17 | type: Sequelize.ENUM({ values: ['default', 'warning', 'error'] }), 18 | allowNull: false, 19 | }, 20 | date: { 21 | type: Sequelize.DATE, 22 | allowNull: false, 23 | defaultValue: Sequelize.NOW, 24 | }, 25 | }), 26 | ); 27 | }, 28 | down: function(query: QueryInterface, Sequelize: DataTypes) { 29 | return query.dropTable('BannerMessageState').then(() => 30 | query.createTable('MessageStateTransition', { 31 | id: { 32 | type: Sequelize.UUID, 33 | primaryKey: true, 34 | defaultValue: Sequelize.UUIDV4, 35 | }, 36 | senderAaid: { 37 | type: Sequelize.STRING, 38 | allowNull: false, 39 | }, 40 | messageExists: { 41 | type: Sequelize.BOOLEAN, 42 | allowNull: false, 43 | }, 44 | message: { 45 | type: Sequelize.STRING({ length: 2000 }), 46 | allowNull: true, 47 | }, 48 | messageType: { 49 | type: Sequelize.ENUM({ values: ['default', 'warning', 'error'] }), 50 | allowNull: true, 51 | }, 52 | date: { 53 | type: Sequelize.DATE, 54 | allowNull: false, 55 | defaultValue: Sequelize.NOW, 56 | }, 57 | }), 58 | ); 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /src/bitbucket/__tests__/BitbucketClient.test.ts: -------------------------------------------------------------------------------- 1 | import { BitbucketClient } from '../BitbucketClient'; 2 | 3 | jest.mock('../../lib/Config'); 4 | jest.mock('../BitbucketPipelinesAPI'); 5 | jest.mock('../BitbucketAPI'); 6 | 7 | const mockConfig = { 8 | repoConfig: { repoName: 'repo', repoOwner: 'owner' }, 9 | mergeSettings: { 10 | mergeBlocking: { 11 | enabled: true, 12 | builds: [ 13 | { 14 | targetBranch: 'master', 15 | pipelineFilterFn: (pipelines: any[]) => 16 | pipelines.filter(({ state }) => state === 'IN_PROGRESS'), 17 | }, 18 | ], 19 | }, 20 | }, 21 | }; 22 | 23 | describe('BitbucketClient', () => { 24 | let client: BitbucketClient; 25 | beforeEach(() => { 26 | client = new BitbucketClient(mockConfig as any); 27 | }); 28 | 29 | afterEach(() => { 30 | jest.restoreAllMocks(); 31 | }); 32 | 33 | describe('isBlockingBuildRunning', () => { 34 | test('should return notRunning if targetBranch is not configured', async () => { 35 | const { running } = await client.isBlockingBuildRunning('develop'); 36 | expect(running).toBe(false); 37 | }); 38 | 39 | test('should return notRunning if blocking build is not running', async () => { 40 | jest.spyOn((client as any).pipelines, 'getPipelines').mockResolvedValueOnce({ 41 | values: [ 42 | { 43 | state: 'COMPLETED', 44 | }, 45 | ], 46 | } as any); 47 | 48 | const { running } = await client.isBlockingBuildRunning('master'); 49 | expect(running).toBe(false); 50 | }); 51 | 52 | test('should return running if blocking build is running', async () => { 53 | jest.spyOn((client as any).pipelines, 'getPipelines').mockResolvedValueOnce({ 54 | values: [ 55 | { 56 | state: 'IN_PROGRESS', 57 | }, 58 | ], 59 | } as any); 60 | 61 | const { running } = await client.isBlockingBuildRunning('master'); 62 | expect(running).toBe(true); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/bitbucket/BitbucketAuthenticator.ts: -------------------------------------------------------------------------------- 1 | import { encode, createQueryStringHash } from 'atlassian-jwt'; 2 | import type { Request } from 'atlassian-jwt'; 3 | import { AxiosRequestConfig } from 'axios'; 4 | 5 | import { Installation } from '../db'; 6 | import { getAppKey } from './descriptor'; 7 | import { Logger } from '../lib/Logger'; 8 | 9 | class BitbucketAuthenticator { 10 | private getBasicAuthHeaders = () => { 11 | // If we aren't installed, requests should be unauthenticated 12 | return {}; 13 | }; 14 | 15 | private getJWTAuthHeaders = (request: Request, install: Installation) => { 16 | const token = encode( 17 | { 18 | iss: getAppKey(), 19 | iat: Date.now(), 20 | exp: Date.now() + 60000, 21 | qsh: createQueryStringHash(request), 22 | sub: install.clientKey, 23 | }, 24 | install.sharedSecret, 25 | ); 26 | Logger.info('Generated JWT', { namespace: 'bitbucket:authenticator:getJWTAuthHeaders' }); 27 | return { 28 | Authorization: `JWT ${token}`, 29 | }; 30 | }; 31 | 32 | getAuthConfig = async ( 33 | request: Request, 34 | baseConfig?: AxiosRequestConfig, 35 | ): Promise => { 36 | const install = await Installation.findOne(); 37 | let authHeaders: any; 38 | if (!install) { 39 | Logger.info('no install, using basic auth', { 40 | namespace: 'bitbucket:authenticator:getJWTAuthHeaders', 41 | }); 42 | authHeaders = this.getBasicAuthHeaders(); 43 | } else { 44 | Logger.info('found install, using JWT auth', { 45 | namespace: 'bitbucket:authenticator:getJWTAuthHeaders', 46 | }); 47 | authHeaders = this.getJWTAuthHeaders(request, install); 48 | } 49 | 50 | return { 51 | ...baseConfig, 52 | headers: { ...(baseConfig ? baseConfig.headers || {} : {}), ...authHeaders }, 53 | }; 54 | }; 55 | } 56 | 57 | export const bitbucketAuthenticator = new BitbucketAuthenticator(); 58 | 59 | export const axiosPostConfig: AxiosRequestConfig = { 60 | headers: { 61 | 'Content-Type': 'application/json', 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/static/current-state/components/WithAPIData.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Section } from './Section'; 3 | 4 | const DEFAULT_POLLING_INTERVAL = 15 * 1000; // 15 sec 5 | 6 | const defaultLoading = () =>
Loading...
; 7 | 8 | export type Props = { 9 | endpoint: string; 10 | poll?: number | boolean; 11 | renderError?: (err: Error, refresh: () => void) => React.ReactNode; 12 | renderLoading?: () => React.ReactNode; 13 | render?: (data: D, refresh: () => void) => React.ReactNode; 14 | }; 15 | 16 | export type State = { 17 | error: Error | null; 18 | data: any; 19 | poll: number; 20 | }; 21 | 22 | export class WithAPIData extends React.Component, State> { 23 | interval: NodeJS.Timeout | null = null; 24 | state: State = { 25 | error: null, 26 | data: null, 27 | poll: this.props.poll === true ? DEFAULT_POLLING_INTERVAL : this.props.poll || 0, 28 | }; 29 | 30 | fetchData() { 31 | fetch(`/${this.props.endpoint}`) 32 | .then((res) => res.json()) 33 | .then((data) => this.setState({ data })) 34 | .catch((error) => this.setState({ error })); 35 | } 36 | 37 | componentDidMount() { 38 | this.fetchData(); 39 | 40 | if (this.state.poll > 0) { 41 | this.interval = setInterval(this.fetchData.bind(this), this.state.poll); 42 | } 43 | } 44 | 45 | componentWillUnmount() { 46 | if (this.interval) { 47 | clearInterval(this.interval); 48 | } 49 | } 50 | 51 | componentWillReceiveProps(newProps: any) { 52 | if (newProps.endpoint !== this.props.endpoint) { 53 | this.setState( 54 | { 55 | data: null, 56 | }, 57 | this.fetchData, 58 | ); 59 | } 60 | } 61 | 62 | render() { 63 | let { error, data } = this.state; 64 | let { renderError, render, renderLoading } = this.props; 65 | 66 | if (error) { 67 | return renderError ? renderError(error, this.fetchData.bind(this)) : null; 68 | } else if (data) { 69 | return render ? render(data, this.fetchData.bind(this)) : null; 70 | } 71 | 72 | return renderLoading ? renderLoading() : defaultLoading(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tools/tunnel.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function deleteTunnel { 4 | echo -e "${GREEN}Deleting tunnel and attached hostnames${ENDCOLOUR}" 5 | rm -f ${CREDFILE} 6 | atlas slauth curl -a atlastunnel-issuer -e prod -- \ 7 | "https://atlastunnel-issuer.ap-southeast-2.prod.atl-paas.net/delete?tunnel=${TUNNEL_NAME}" 8 | } 9 | 10 | # This will clean everything up after the script exits 11 | trap deleteTunnel INT TERM 12 | 13 | GREEN="\033[1;32m" 14 | RED="\033[1;31m" 15 | ENDCOLOUR="\033[0m" 16 | BOLD="\033[0;1m" 17 | 18 | # Check for required command line args 19 | if [ $# != 2 ]; then 20 | echo -e "${BOLD}Usage: yarn tunnel ${ENDCOLOUR}" 21 | exit 1 22 | fi 23 | 24 | TUNNEL_NAME="$1" 25 | HOST_NAME="$2" 26 | 27 | TMPFILE=$(mktemp -t tmp) 28 | CLOUDFLARED_DIR=$(cd ~/.cloudflared && pwd) 29 | 30 | # Generate cloudflared tunnel 31 | echo -e "${GREEN}Creating tunnel: ${BOLD}${TUNNEL_NAME}${ENDCOLOUR}" 32 | atlas slauth curl -a atlastunnel-issuer -e prod -- \ 33 | "https://atlastunnel-issuer.ap-southeast-2.prod.atl-paas.net/generate?tunnel=${TUNNEL_NAME}" \ 34 | > ${TMPFILE} 35 | 36 | # Move credentials (provided by generate step) into credentials file 37 | TUNNEL_ID=$(jq -r .TunnelID ${TMPFILE}) 38 | CREDFILE="${CLOUDFLARED_DIR}/${TUNNEL_ID}.json" 39 | cp "${TMPFILE}" "${CREDFILE}" 40 | rm -f ${TMPFILE} 41 | 42 | echo -e "${GREEN}Created file with tunnel credentials: ${BOLD}${CREDFILE}${ENDCOLOUR}" 43 | 44 | # Attach hostname to tunnel 45 | echo -e "${GREEN}Attaching hostname: ${BOLD}${HOST_NAME}${ENDCOLOUR}" 46 | atlas slauth curl -a atlastunnel-issuer -e prod -- \ 47 | "https://atlastunnel-issuer.ap-southeast-2.prod.atl-paas.net/attach?tunnel=${TUNNEL_NAME}&hostname=${HOST_NAME}" 48 | 49 | # Generate tunnel config 50 | cp tunnel-config.template.yml ~/.cloudflared/config.yml 51 | sed -i ".backup" "s::${TUNNEL_ID}:gi" ~/.cloudflared/config.yml 52 | sed -i ".backup" "s::${CREDFILE}:gi" ~/.cloudflared/config.yml 53 | sed -i ".backup" "s::${HOST_NAME}:gi" ~/.cloudflared/config.yml 54 | 55 | echo -e "${GREEN}Created tunnel config${ENDCOLOUR}" 56 | 57 | cloudflared tunnel --config ~/.cloudflared/config.yml ingress validate 58 | cloudflared tunnel --config ~/.cloudflared/config.yml run ${TUNNEL_ID} 59 | -------------------------------------------------------------------------------- /src/auth/bitbucket.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import passport from 'passport'; 3 | import { BasicStrategy } from 'passport-http'; 4 | import { Strategy, VerifyCallback } from 'passport-oauth2'; 5 | import { Logger } from '../lib/Logger'; 6 | import { config } from '../lib/Config'; 7 | import { OAuthConfig } from '../types'; 8 | 9 | async function verifyBitbucketUser( 10 | authHeader: string, 11 | verified: (err?: Error | null, user?: Express.User) => void, 12 | ) { 13 | let userInfo: ISessionUser; 14 | try { 15 | const userResponse = await axios.get('https://api.bitbucket.org/2.0/user', { 16 | headers: { 17 | Authorization: authHeader, 18 | }, 19 | }); 20 | 21 | userInfo = { 22 | aaid: userResponse.data.uuid, 23 | username: userResponse.data.username, 24 | displayName: userResponse.data.display_name, 25 | accountId: userResponse.data.account_id, 26 | }; 27 | 28 | Logger.info('User logged in', { 29 | namespace: 'auth:bitbucket', 30 | aaid: userInfo.aaid, 31 | }); 32 | } catch (err) { 33 | return verified(err); 34 | } 35 | 36 | verified(null, userInfo); 37 | } 38 | 39 | export function initializePassport(oAuthConfig: OAuthConfig) { 40 | passport.serializeUser((user, done) => { 41 | done(null, JSON.stringify(user)); 42 | }); 43 | 44 | passport.deserializeUser((serialized, done) => { 45 | done(null, JSON.parse(serialized)); 46 | }); 47 | 48 | // Bitbucket OAuth2 49 | passport.use( 50 | 'bitbucket', 51 | new Strategy( 52 | { 53 | authorizationURL: 'https://bitbucket.org/site/oauth2/authorize', 54 | tokenURL: 'https://bitbucket.org/site/oauth2/access_token', 55 | callbackURL: `${config.baseUrl}/auth/callback`, 56 | clientID: oAuthConfig.key, 57 | clientSecret: oAuthConfig.secret, 58 | }, 59 | (accessToken: string, refreshToken: string, profile: any, verified: VerifyCallback) => { 60 | verifyBitbucketUser(`Bearer ${accessToken}`, verified); 61 | }, 62 | ), 63 | ); 64 | 65 | // Allow programmatic Bitbucket login for tests 66 | passport.use( 67 | 'basic', 68 | new BasicStrategy((username, password, done) => { 69 | const token = Buffer.from(`${username}:${password}`).toString('base64'); 70 | verifyBitbucketUser(`Basic ${token}`, done); 71 | }), 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/routes/bitbucket/lifecycle/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { authenticateIncomingBBCall, wrap } from '../../middleware'; 3 | import { Installation } from '../../../db'; 4 | import { Logger } from '../../../lib/Logger'; 5 | import { Runner } from '../../../lib/Runner'; 6 | 7 | export function lifecycleRoutes(runner: Runner) { 8 | const router = express(); 9 | 10 | router.post( 11 | '/installed', 12 | wrap(async (req, res) => { 13 | Logger.verbose('Requesting creation of installation entry', { 14 | namespace: 'routes:bitbucket:lifecycle:installed', 15 | }); 16 | const install = await Installation.findOne(); 17 | if (install) { 18 | Logger.error('Attempted to install over an existing installation', { 19 | namespace: 'routes:bitbucket:lifecyle:installed', 20 | }); 21 | return res.status(400).json({ 22 | error: 'Attempted to install over an existing installation', 23 | }); 24 | } 25 | 26 | if (!req.body.clientKey || !req.body.sharedSecret || req.body.productType !== 'bitbucket') { 27 | return res.status(400).json({ 28 | error: 'Invalid installation webhook', 29 | }); 30 | } 31 | 32 | // TODO: Validate the clientKey and sharedSecret before persisting by 33 | // attempting to call a bitbucket API 34 | 35 | await Installation.create({ 36 | id: 'the-one-and-only', 37 | clientKey: req.body.clientKey, 38 | sharedSecret: req.body.sharedSecret, 39 | }); 40 | Logger.info('Created installation entry', { 41 | namespace: 'routes:bitbucket:lifecycle:installed', 42 | }); 43 | 44 | res.send('OK'); 45 | }), 46 | ); 47 | 48 | router.post( 49 | '/uninstalled', 50 | authenticateIncomingBBCall, 51 | wrap(async (req, res) => { 52 | await Installation.destroy({ 53 | where: { id: 'the-one-and-only' }, 54 | }); 55 | 56 | const [queued, waiting] = await Promise.all([ 57 | runner.queue.getQueue(), 58 | runner.queue.getStatusesForWaitingRequests(), 59 | ]); 60 | for (const status of [...queued, ...waiting]) { 61 | await status.request.setStatus('aborted', 'BitBucket Addon was Uninstalled...'); 62 | } 63 | res.send('Bye'); 64 | }), 65 | ); 66 | 67 | return router; 68 | } 69 | -------------------------------------------------------------------------------- /src/static/current-state/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RunnerState } from '../../../types'; 3 | import { WithAPIData } from './WithAPIData'; 4 | import { CurrentState } from './CurrentState'; 5 | import { RunningBuilds } from './RunningBuilds'; 6 | import { Tabs } from './tabs'; 7 | import { Header } from './Header'; 8 | 9 | export const App: React.FunctionComponent = () => ( 10 |
11 | 12 | endpoint="auth/whoami" 13 | renderLoading={() =>
} 14 | render={(userInfo) => { 15 | if (userInfo.loggedIn) { 16 | const loggedInUser = { ...userInfo.user!, permission: userInfo.permission }; 17 | return ( 18 | 19 |
20 | 21 | poll={true} 22 | endpoint="api/current-state" 23 | render={(data, refresh) => ( 24 |
25 | 26 | 31 | 46 |
47 | )} 48 | /> 49 | 50 | ); 51 | } 52 | 53 | window.location.href = '/auth'; 54 | 55 | return

Signing In...

; 56 | }} 57 | /> 58 |
59 | ); 60 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import path from 'path'; 3 | 4 | import { config } from '../lib/Config'; 5 | import { Runner } from '../lib/Runner'; 6 | import { BitbucketClient } from '../bitbucket/BitbucketClient'; 7 | import { Logger } from '../lib/Logger'; 8 | import { apiRoutes } from './api'; 9 | import { bitbucketRoutes } from './bitbucket'; 10 | import { makeDescriptor } from '../bitbucket/descriptor'; 11 | import { authRoutes } from './auth'; 12 | import mime from 'mime'; 13 | 14 | const mimeCacheMap: Record = { 15 | 'text/html': 'public, max-age=60', // 1 minute 16 | 'text/css': 'public, max-age=31536000', // 1 year 17 | 'application/javascript': 'public, max-age=31536000', // 1 year 18 | }; 19 | 20 | function setStaticCacheControl(res: express.Response, path: string) { 21 | const mimeType = mime.getType(path); 22 | 23 | if (mimeType && mimeCacheMap[mimeType]) { 24 | res.setHeader('Cache-Control', mimeCacheMap[mimeType]); 25 | } else { 26 | res.setHeader('Cache-Control', 'public, max-age=0'); 27 | } 28 | } 29 | 30 | export async function routes(server: express.Application, client: BitbucketClient, runner: Runner) { 31 | const router = express(); 32 | 33 | let repoUuid = config.repoConfig.uuid; 34 | if (!repoUuid) { 35 | Logger.info('==== Fetching repository uuid from Bitbucket ===='); 36 | Logger.info('You can skip this step by putting uuid config.repoConfig'); 37 | repoUuid = await client.getRepoUuid(); 38 | } 39 | 40 | const bitbucketAddonDescriptor = makeDescriptor(); 41 | 42 | router.get('/healthcheck', (req, res) => { 43 | res.sendStatus(200); 44 | }); 45 | 46 | router.get('/ac', (req, res) => { 47 | res.header('Access-Control-Allow-Origin', '*').json(bitbucketAddonDescriptor); 48 | }); 49 | router.use('/api', apiRoutes(runner, client, config)); 50 | router.use('/auth', authRoutes(config)); 51 | router.use('/bitbucket', bitbucketRoutes(runner, client)); 52 | 53 | if (process.env.NODE_ENV === 'production') { 54 | router.use( 55 | express.static(path.join(__dirname, '..', 'static'), { 56 | setHeaders: setStaticCacheControl, 57 | }), 58 | ); 59 | } 60 | 61 | router.use(((err, _, res, next) => { 62 | if (err) { 63 | Logger.error('Unhandled Express Error', { err }); 64 | res.status(500).send({ 65 | error: err.message, 66 | stack: err.stack, 67 | }); 68 | } else { 69 | next(); 70 | } 71 | }) as express.ErrorRequestHandler); 72 | 73 | server.use(router); 74 | } 75 | -------------------------------------------------------------------------------- /src/bitbucket/__tests__/BitbucketPipelinesAPI.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { BitbucketPipelinesAPI } from '../BitbucketPipelinesAPI'; 3 | import { bitbucketAuthenticator } from '../BitbucketAuthenticator'; 4 | import { Logger } from '../../lib/Logger'; 5 | 6 | jest.mock('axios'); 7 | const mockedAxios = axios as unknown as jest.Mocked; 8 | 9 | const bitbucketPipelineAPI = new BitbucketPipelinesAPI({ 10 | repoName: 'repo', 11 | repoOwner: 'owner', 12 | }); 13 | 14 | describe('BitbucketPipelinesAPI', () => { 15 | beforeEach(() => { 16 | jest.resetAllMocks(); 17 | jest.spyOn(bitbucketAuthenticator, 'getAuthConfig').mockResolvedValue({}); 18 | }); 19 | 20 | test(`getLandBuild > should return land build data`, async () => { 21 | const response = { 22 | data: { 23 | state: { 24 | result: { 25 | name: 'FAILED', 26 | }, 27 | }, 28 | }, 29 | }; 30 | mockedAxios.get.mockResolvedValue(response); 31 | 32 | expect(await bitbucketPipelineAPI.getLandBuild(123)).toBe(response.data); 33 | expect(mockedAxios.get).toBeCalledWith( 34 | 'https://api.bitbucket.org/2.0/repositories/owner/repo/pipelines/123', 35 | {}, 36 | ); 37 | }); 38 | 39 | describe('getPipelines', () => { 40 | let loggerSpy: jest.SpyInstance; 41 | beforeEach(() => { 42 | loggerSpy = jest.spyOn(Logger, 'error'); 43 | }); 44 | test('should return successful response without retries', async () => { 45 | mockedAxios.get.mockResolvedValueOnce({ data: 'data' }); 46 | const response = await bitbucketPipelineAPI.getPipelines({ 47 | pagelen: 30, 48 | }); 49 | expect(response).toBe('data'); 50 | }); 51 | 52 | test('should return successful response with retries', async () => { 53 | mockedAxios.get 54 | .mockRejectedValueOnce(new Error('error')) 55 | .mockResolvedValueOnce({ data: 'data' }); 56 | const response = await bitbucketPipelineAPI.getPipelines({ 57 | pagelen: 30, 58 | }); 59 | expect(loggerSpy).toBeCalledTimes(1); 60 | expect(response).toBe('data'); 61 | }); 62 | 63 | test('should fail after all retries', async () => { 64 | mockedAxios.get 65 | .mockRejectedValueOnce(new Error('error')) 66 | .mockRejectedValueOnce(new Error('error')); 67 | const response = await bitbucketPipelineAPI.getPipelines( 68 | { 69 | pagelen: 30, 70 | }, 71 | 2, 72 | ); 73 | expect(loggerSpy).toBeCalledTimes(2); 74 | expect(response).toBeUndefined(); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/db/MigrationService.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { Sequelize } from 'sequelize'; 3 | import Umzug from 'umzug'; 4 | import { Config } from '../types'; 5 | 6 | export class MigrationService { 7 | migrator: Umzug.Umzug; 8 | 9 | constructor(sequelize: any) { 10 | this.migrator = new Umzug({ 11 | storage: 'sequelize', 12 | storageOptions: { sequelize }, 13 | migrations: { 14 | params: [sequelize.getQueryInterface(), sequelize.constructor], 15 | path: resolve(__dirname, 'migrations'), 16 | pattern: /^\d{2}__[^ /]+?\.(j|t)s$/, 17 | }, 18 | }); 19 | this.migrator.on('migrating', this.logEvent('migrating')); 20 | this.migrator.on('migrated', this.logEvent('migrated')); 21 | this.migrator.on('reverting', this.logEvent('reverting')); 22 | this.migrator.on('reverted', this.logEvent('reverted')); 23 | } 24 | 25 | private logEvent(event: string) { 26 | return (name: string) => console.log(`${name} ${event}`); 27 | } 28 | 29 | async logStatus() { 30 | this.migrator 31 | .pending() 32 | .then((pending) => { 33 | console.log( 34 | 'PENDING:', 35 | JSON.stringify( 36 | pending.map((p) => p.file), 37 | null, 38 | 2, 39 | ), 40 | ); 41 | return this.migrator.executed(); 42 | }) 43 | .then((executed) => { 44 | console.log( 45 | 'EXECUTED:', 46 | JSON.stringify( 47 | executed.map((e) => e.file), 48 | null, 49 | 2, 50 | ), 51 | ); 52 | }); 53 | } 54 | 55 | up() { 56 | return this.migrator.up(); 57 | } 58 | 59 | down() { 60 | return this.migrator.down(); 61 | } 62 | } 63 | 64 | if (require.main === module) { 65 | const config = require(resolve(process.cwd(), 'config.js')) as Config; 66 | 67 | // Connect to DB 68 | const sequelize = new Sequelize( 69 | config.sequelize || { 70 | dialect: 'sqlite', 71 | storage: resolve(__dirname, '../../db.sqlite'), 72 | logging: false, 73 | operatorsAliases: false, 74 | }, 75 | ); 76 | 77 | const migrator = new MigrationService(sequelize); 78 | 79 | const cmd = (process.argv[2] || '').trim(); 80 | 81 | switch (cmd) { 82 | case 'status': 83 | migrator.logStatus(); 84 | break; 85 | 86 | case 'up': 87 | case 'migrate': 88 | migrator.up(); 89 | break; 90 | 91 | case 'down': 92 | case 'revert': 93 | migrator.down(); 94 | break; 95 | 96 | default: 97 | console.log('Usage:\n $ yarn migrate '); 98 | process.exit(1); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | // This only really matters when building files, not in production 6 | const outputPath = process.env.OUTPUT_PATH || '.'; 7 | const SERVER_PORT = process.env.SERVER_PORT || '8080'; 8 | const DEV_SERVER_PORT = process.env.DEV_SERVER_PORT || '3000'; 9 | 10 | module.exports = { 11 | entry: { 12 | bitbucket: path.resolve(__dirname, './src/static/bitbucket'), 13 | 'current-state': path.resolve(__dirname, './src/static/current-state'), 14 | }, 15 | output: { 16 | path: path.resolve(outputPath), 17 | publicPath: '/', 18 | filename: '[name]/bundle.[chunkhash].js', 19 | }, 20 | mode: 'development', 21 | ignoreWarnings: [(warning) => true], 22 | devServer: { 23 | compress: true, 24 | historyApiFallback: true, 25 | // hot: true, 26 | port: Number(DEV_SERVER_PORT), 27 | proxy: { 28 | '/api': `http://localhost:${SERVER_PORT}`, 29 | '/auth': `http://localhost:${SERVER_PORT}`, 30 | '/bitbucket': `http://localhost:${SERVER_PORT}`, 31 | '/ac': `http://localhost:${SERVER_PORT}`, 32 | }, 33 | client: { 34 | webSocketURL: fs.existsSync('./config.js') 35 | ? require('./config').baseUrl.replace('', '') 36 | : undefined, 37 | }, 38 | }, 39 | module: { 40 | rules: [ 41 | { 42 | test: /\.css$/i, 43 | use: ['style-loader', 'css-loader'], 44 | }, 45 | { 46 | test: /\.tsx?/, 47 | use: [ 48 | { 49 | loader: require.resolve('cache-loader'), 50 | options: { 51 | cacheDirectory: path.resolve(__dirname, 'node_modules', '.build-cache', 'ts'), 52 | }, 53 | }, 54 | { 55 | loader: require.resolve('ts-loader'), 56 | options: { 57 | transpileOnly: true, 58 | }, 59 | }, 60 | ], 61 | }, 62 | ], 63 | }, 64 | resolve: { 65 | extensions: ['.js', '.json', '.ts', '.tsx'], 66 | }, 67 | plugins: [ 68 | new HtmlWebpackPlugin({ 69 | filename: 'bitbucket/index.html', 70 | // only inject the code from the 'bitbucket' entry/chunk 71 | chunks: ['bitbucket'], 72 | template: path.resolve(__dirname, './src/static/bitbucket/index.html'), 73 | }), 74 | new HtmlWebpackPlugin({ 75 | filename: 'current-state/index.html', 76 | // only inject the code from the 'current-state' entry/chunk 77 | chunks: ['current-state'], 78 | template: path.resolve(__dirname, './src/static/current-state/index.html'), 79 | }), 80 | new HtmlWebpackPlugin({ 81 | filename: 'index.html', 82 | // should't need any chunks for the home page 83 | chunks: [], 84 | template: path.resolve(__dirname, './src/static/index.html'), 85 | }), 86 | ], 87 | }; 88 | -------------------------------------------------------------------------------- /src/static/current-state/components/tabs/HistoryTab.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Pagination from '@atlaskit/pagination'; 3 | 4 | import { TabContent } from './TabContent'; 5 | import { EmptyState } from '../EmptyState'; 6 | import { QueueItemJoined } from '../QueueItem'; 7 | import { WithAPIData } from '../WithAPIData'; 8 | 9 | // export type HistoryItemsListProps = { 10 | // history: Array; 11 | // renderEmpty?: () => JSX.Element; 12 | // bitbucketBaseUrl: string; 13 | // items: number; 14 | // pageLen: number; 15 | // onPageChange: (page: number) => void; 16 | // }; 17 | 18 | /** 19 | ( 26 | 27 | History is empty... 28 | 29 | )} 30 | /> 31 | */ 32 | 33 | export type HistoryTabProps = { 34 | bitbucketBaseUrl: string; 35 | loggedInUser: ISessionUser; 36 | permissionsMessage: string; 37 | }; 38 | 39 | type HistoryState = { 40 | page: number; 41 | }; 42 | 43 | export class HistoryTab extends React.Component { 44 | state = { 45 | page: 1, 46 | }; 47 | 48 | onPageChange = (page: number) => { 49 | this.setState({ 50 | page: page, 51 | }); 52 | }; 53 | 54 | render() { 55 | return ( 56 | 57 | poll={false} 58 | endpoint={`api/history?page=${this.state.page}`} 59 | render={(historyResponse, refresh) => { 60 | const { history, pageLen, count } = historyResponse; 61 | if (history === undefined || !history.length) { 62 | return ( 63 | 64 | 65 | {this.props.loggedInUser.permission === 'read' 66 | ? this.props.permissionsMessage 67 | : 'Empty...'} 68 | 69 | 70 | ); 71 | } 72 | 73 | const pages = Math.floor(count / pageLen); 74 | 75 | return ( 76 |
77 | {history.map((item) => ( 78 | 85 | ))} 86 |
87 | 88 |
89 |
90 | ); 91 | }} 92 | /> 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/bitbucket/BitbucketMerger.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { fromMethodAndUrl, fromMethodAndPathAndBody } from 'atlassian-jwt'; 3 | import delay from 'delay'; 4 | 5 | import { bitbucketAuthenticator, axiosPostConfig } from './BitbucketAuthenticator'; 6 | 7 | export class BitbucketMerger { 8 | private mergePollIntervals = new Map(); 9 | private MAX_POLL_ATTEMPTS = 120; // 30 mins 10 | private POLL_DELAY = 15 * 1000; // 15 seconds 11 | 12 | constructor(private baseUrl: string) {} 13 | 14 | attemptMerge = async ( 15 | prId: number, 16 | message: string, 17 | mergeStrategy: IMergeStrategy = 'merge-commit', 18 | ) => { 19 | const endpoint = `${this.baseUrl}/pullrequests/${prId}/merge`; 20 | 21 | const mergeStrategyMapper: { [key in IMergeStrategy]: string } = { 22 | 'merge-commit': 'merge_commit', 23 | squash: 'squash', 24 | }; 25 | 26 | const body = { 27 | close_source_branch: true, 28 | message, 29 | merge_strategy: mergeStrategyMapper[mergeStrategy], 30 | }; 31 | 32 | return axios.post( 33 | endpoint, 34 | JSON.stringify(body), 35 | await bitbucketAuthenticator.getAuthConfig(fromMethodAndPathAndBody('post', endpoint, body), { 36 | ...axiosPostConfig, 37 | validateStatus: () => true, 38 | }), 39 | ); 40 | }; 41 | 42 | private pollMergeStatus = async (pollUrl: string) => { 43 | const res = await axios.get( 44 | pollUrl, 45 | await bitbucketAuthenticator.getAuthConfig(fromMethodAndUrl('get', pollUrl)), 46 | ); 47 | return res.data; 48 | }; 49 | 50 | /** 51 | * We continue polling as long as we receive the PENDING response 52 | * Return upon success, failure, exceeding max attempts, or manual cancel 53 | */ 54 | triggerMergePolling = async (prId: number, pollUrl: string) => { 55 | this.mergePollIntervals.set(prId, true); 56 | let result; 57 | for (let i = 0; ; i++) { 58 | if (i >= this.MAX_POLL_ATTEMPTS) { 59 | result = { 60 | task_status: 'TIMEOUT' as const, 61 | }; 62 | break; 63 | } 64 | if (!this.mergePollIntervals.has(prId)) { 65 | result = { 66 | task_status: 'ABORTED' as const, 67 | }; 68 | break; 69 | } 70 | try { 71 | const pollResult = await this.pollMergeStatus(pollUrl); 72 | if (pollResult.task_status === 'SUCCESS') { 73 | result = pollResult; 74 | break; 75 | } 76 | } catch (err) { 77 | result = { 78 | task_status: 'FAILED' as const, 79 | response: err.response, 80 | }; 81 | break; 82 | } 83 | await delay(this.POLL_DELAY); 84 | } 85 | // Cleanup the map entry 86 | this.cancelMergePolling(prId); 87 | return result; 88 | }; 89 | 90 | cancelMergePolling = (prId: number) => { 91 | this.mergePollIntervals.delete(prId); 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import redis from 'redis'; 4 | import connectRedis from 'connect-redis'; 5 | import express from 'express'; 6 | import expressSession from 'express-session'; 7 | import expressWinston from 'express-winston'; 8 | import passport from 'passport'; 9 | import bodyParser from 'body-parser'; 10 | 11 | import { initializeSequelize, MigrationService } from './db'; 12 | import { BitbucketClient } from './bitbucket/BitbucketClient'; 13 | import { config, hasConfig } from './lib/Config'; 14 | import { LandRequestQueue } from './lib/Queue'; 15 | import { Runner } from './lib/Runner'; 16 | import { routes } from './routes'; 17 | import { initializePassport } from './auth/bitbucket'; 18 | import { LandRequestHistory } from './lib/History'; 19 | import { Logger } from './lib/Logger'; 20 | import { initializeEventListeners, eventEmitter } from './lib/Events'; 21 | 22 | const RedisStore = connectRedis(expressSession); 23 | 24 | async function main() { 25 | if (!hasConfig) { 26 | throw new Error('Could not find config.js file, see the README for instructions'); 27 | } 28 | 29 | const sequelize = await initializeSequelize(); 30 | const migrator = new MigrationService(sequelize); 31 | await migrator.up(); 32 | 33 | initializePassport(config.deployment.oAuth); 34 | 35 | const server = express(); 36 | 37 | const redisClient = redis.createClient({ 38 | host: config.deployment.redis.endpoint, 39 | port: config.deployment.redis.port, 40 | }); 41 | 42 | server.use( 43 | expressWinston.logger({ 44 | meta: false, 45 | winstonInstance: Logger, 46 | colorize: process.env.NODE_ENV !== 'production', 47 | level: 'http', 48 | }), 49 | ); 50 | server.use(bodyParser.json()); 51 | server.use( 52 | expressSession({ 53 | name: 'landkid.sid', 54 | secret: config.deployment.secret, 55 | saveUninitialized: true, 56 | store: new RedisStore({ 57 | client: redisClient, 58 | }), 59 | }), 60 | ); 61 | server.use(passport.initialize()); 62 | server.use(passport.session()); 63 | 64 | const client = new BitbucketClient(config); 65 | const queue = new LandRequestQueue(); 66 | const history = new LandRequestHistory(); 67 | const runner = new Runner(queue, history, client, config); 68 | 69 | initializeEventListeners(); 70 | 71 | await routes(server, client, runner); 72 | 73 | eventEmitter.emit('STARTUP'); 74 | // TODO: lookup all admins in user service to add them to the redis cache 75 | 76 | server.listen(config.port, () => { 77 | Logger.info('LandKid is running', { port: config.port }); 78 | 79 | // In case there were things still in the queue when we start up 80 | runner.next(); 81 | }); 82 | } 83 | 84 | if (process.mainModule === module) { 85 | main().catch((err) => { 86 | Logger.error('Fatal error occurred in main()', { 87 | err: { message: err.message, stack: err.stack }, 88 | maybeResponse: err.response ? [err.response.status, err.response.data] : undefined, 89 | }); 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /typings/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@atlaskit/pagination'; 2 | 3 | declare namespace JSX { 4 | // Declare atlaskit reduced-ui grid components (for QueueItem) 5 | interface IntrinsicElements { 6 | 'ak-grid': React.DetailedHTMLProps; 7 | 'ak-grid-column': React.DetailedHTMLProps; 8 | } 9 | } 10 | 11 | declare namespace Express { 12 | interface User extends ISessionUser {} 13 | } 14 | 15 | declare interface Window { 16 | landClicked: () => void; 17 | landWhenAbleClicked: () => void; 18 | } 19 | 20 | declare interface ILandRequest { 21 | id: string; 22 | pullRequestId: number; 23 | triggererAaid: string; 24 | buildId: number | null; 25 | forCommit: string; 26 | created: Date; 27 | pullRequest: IPullRequest; 28 | dependsOn: string | null; 29 | dependsOnPrIds: string | null; 30 | priority: number | null; 31 | impact: number | null; 32 | } 33 | 34 | declare interface IPullRequest { 35 | prId: number; 36 | authorAaid: string; 37 | title: string; 38 | targetBranch: string | null; 39 | } 40 | 41 | declare interface IPauseState { 42 | pauserAaid: string; 43 | reason: string | null; 44 | date: Date; 45 | } 46 | 47 | declare interface IMessageState { 48 | senderAaid: string; 49 | message: string; 50 | messageType: 'default' | 'warning' | 'error'; 51 | date: Date; 52 | } 53 | 54 | declare interface IConcurrentBuildState { 55 | adminAaid: string; 56 | maxConcurrentBuilds: number; 57 | date: Date; 58 | } 59 | 60 | declare interface IPriorityBranch { 61 | id: string; 62 | adminAaid: string; 63 | branchName: string; 64 | date: Date; 65 | } 66 | 67 | declare interface IAdminSettings { 68 | adminAaid?: string; 69 | mergeBlockingEnabled: boolean; 70 | speculationEngineEnabled: boolean; 71 | } 72 | 73 | declare type IStatusState = 74 | | 'will-queue-when-ready' 75 | | 'queued' 76 | | 'running' 77 | | 'awaiting-merge' 78 | | 'merging' 79 | | 'success' 80 | | 'fail' 81 | | 'aborted'; 82 | 83 | declare interface IStatusUpdate { 84 | id: string; 85 | date: Date; 86 | state: IStatusState; 87 | reason: string | null; 88 | requestId: string; 89 | isLatest: boolean; 90 | request: ILandRequest; 91 | } 92 | 93 | declare interface ISessionUser { 94 | aaid: string; 95 | username: string; 96 | accountId: string; 97 | displayName: string; 98 | permission?: IPermissionMode; 99 | } 100 | 101 | declare type IPermissionMode = 'read' | 'land' | 'admin'; 102 | 103 | declare type IMergeStrategy = 'merge-commit' | 'squash'; 104 | 105 | declare interface IPermission { 106 | aaid: string; 107 | dateAssigned: Date; 108 | mode: IPermissionMode; 109 | assignedByAaid: string; 110 | } 111 | 112 | declare interface IUserNote { 113 | aaid: string; 114 | note: string; 115 | setByAaid: string; 116 | } 117 | 118 | declare interface UserState extends IPermission { 119 | note?: string; 120 | } 121 | 122 | declare type HistoryResponse = { 123 | history: IStatusUpdate[]; 124 | count: number; 125 | pageLen: number; 126 | }; 127 | 128 | declare interface ICanLand { 129 | canLand: boolean; 130 | canLandWhenAble: boolean; 131 | errors: string[]; 132 | } 133 | -------------------------------------------------------------------------------- /src/lib/Queue.ts: -------------------------------------------------------------------------------- 1 | import { LandRequestStatus, LandRequest, PullRequest } from '../db'; 2 | import { Op } from 'sequelize'; 3 | 4 | export class LandRequestQueue { 5 | public getStatusesForWaitingRequests = async () => { 6 | return LandRequestStatus.findAll({ 7 | where: { 8 | isLatest: true, 9 | state: 'will-queue-when-ready', 10 | // only retrieve requests from last 7 days 11 | date: { [Op.gt]: new Date(new Date().getTime() - 7 * 1000 * 60 * 60 * 24) }, 12 | }, 13 | order: [['date', 'ASC']], 14 | include: [ 15 | { 16 | model: LandRequest, 17 | include: [PullRequest], 18 | }, 19 | ], 20 | }); 21 | }; 22 | 23 | // returns the list of queued, running, awaiting-merge, and merging items as these are the actual "queue" per se 24 | // all the status' we display on the frontend 25 | public getQueue = async ( 26 | state: IStatusState[] = ['queued', 'running', 'awaiting-merge', 'merging'], 27 | ) => { 28 | const queue = await LandRequestStatus.findAll({ 29 | where: { 30 | isLatest: true, 31 | state: { 32 | $in: state, 33 | }, 34 | }, 35 | order: [ 36 | [LandRequestStatus.associations?.request, 'priority', 'DESC'], 37 | ['date', 'ASC'], 38 | ], 39 | include: [ 40 | { 41 | model: LandRequest, 42 | include: [PullRequest], 43 | }, 44 | ], 45 | }); 46 | return queue; 47 | }; 48 | 49 | // returns builds that are running, awaiting-merge, or merging, used to find the dependencies of a request 50 | // that is about to move to running state 51 | public getRunning = async (state: IStatusState[] = ['running', 'awaiting-merge', 'merging']) => { 52 | return LandRequestStatus.findAll({ 53 | where: { 54 | isLatest: true, 55 | state: { 56 | $in: state, 57 | }, 58 | }, 59 | order: [['date', 'ASC']], 60 | include: [ 61 | { 62 | model: LandRequest, 63 | include: [PullRequest], 64 | }, 65 | ], 66 | }); 67 | }; 68 | 69 | public maybeGetStatusForNextRequestInQueue = async () => { 70 | const requestStatus = await LandRequestStatus.findOne({ 71 | where: { 72 | isLatest: true, 73 | state: 'queued', 74 | }, 75 | order: [['date', 'ASC']], 76 | include: [ 77 | { 78 | model: LandRequest, 79 | include: [PullRequest], 80 | }, 81 | ], 82 | }); 83 | if (!requestStatus) return null; 84 | 85 | return requestStatus; 86 | }; 87 | 88 | public maybeGetStatusForQueuedRequestById = async (requestId: string) => { 89 | const requestStatus = await LandRequestStatus.findOne({ 90 | where: { 91 | requestId, 92 | isLatest: true, 93 | state: 'queued', 94 | }, 95 | include: [ 96 | { 97 | model: LandRequest, 98 | }, 99 | ], 100 | }); 101 | if (!requestStatus || !requestStatus.request) return null; 102 | 103 | return requestStatus; 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /src/static/bitbucket/components/Message.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import Message from './Message'; 5 | 6 | const bannerMessageOptions = { 7 | None: null, 8 | Default: { 9 | messageExists: true, 10 | message: 'Default banner message', 11 | messageType: 'default', 12 | }, 13 | Warning: { 14 | messageExists: true, 15 | message: 'Banner message warning', 16 | messageType: 'warning', 17 | }, 18 | Error: { 19 | messageExists: true, 20 | message: 'Banner message error', 21 | messageType: 'error', 22 | }, 23 | }; 24 | 25 | const queueOptions = { 26 | Running: [ 27 | { 28 | request: { pullRequestId: 10 }, 29 | state: 'running', 30 | }, 31 | { 32 | request: { pullRequestId: 11 }, 33 | state: 'running', 34 | }, 35 | { 36 | request: { pullRequestId: 12 }, 37 | state: 'queued', 38 | }, 39 | { 40 | request: { pullRequestId: 13 }, 41 | state: 'queued', 42 | }, 43 | ], 44 | Waiting: [ 45 | { 46 | request: { pullRequestId: 10 }, 47 | state: 'queued', 48 | }, 49 | { 50 | request: { pullRequestId: 11 }, 51 | state: 'queued', 52 | }, 53 | { 54 | request: { pullRequestId: 8 }, 55 | state: 'running', 56 | }, 57 | { 58 | request: { pullRequestId: 9 }, 59 | state: 'running', 60 | }, 61 | ], 62 | }; 63 | 64 | export default { 65 | title: 'Message', 66 | component: Message, 67 | argTypes: { 68 | queue: { 69 | defaultValue: 'Running', 70 | options: Object.keys(queueOptions), 71 | mapping: queueOptions, 72 | }, 73 | onCheckAgainClicked: { 74 | action: 'onCheckAgainClicked', 75 | }, 76 | onLandWhenAbleClicked: { 77 | action: 'onLandWhenAbleClicked', 78 | }, 79 | onLandClicked: { 80 | action: 'onLandClicked', 81 | }, 82 | bannerMessage: { 83 | defaultValue: 'Default', 84 | options: Object.keys(bannerMessageOptions), 85 | mapping: bannerMessageOptions, 86 | }, 87 | }, 88 | } as ComponentMeta; 89 | 90 | const Template: ComponentStory = (args) => ; 91 | 92 | export const Configurable = Template.bind({}); 93 | Configurable.args = { 94 | appName: 'Landkid', 95 | status: 'can-land', 96 | canLandWhenAble: true, 97 | loadStatus: 'loaded', 98 | pullRequestId: 10, 99 | errors: [ 100 | 'All tasks must be resolved', 101 | 'Must be approved', 102 | `Must be approved by the teams added as mandatory reviewers: 103 | `, 107 | ], 108 | warnings: [ 109 | 'The metadata for this PR has not yet been uploaded by the PR pipeline. See go/af-package-ownership for more information.', 110 | ], 111 | }; 112 | -------------------------------------------------------------------------------- /config.example.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is just an example of the config you need to provide, none of the values present here are real 3 | */ 4 | 5 | module.exports = { 6 | name: 'MyName Landkid', 7 | key: 'myname-landkid', 8 | baseUrl: 'https://myname-landkid.ngrok.io', 9 | port: process.env.SERVER_PORT ? Number(process.env.SERVER_PORT) : 8080, 10 | landkidAdmins: ['your bb uuid'], 11 | repoConfig: { 12 | repoOwner: 'bitbucket workspace (e.g. bb username)', 13 | repoName: 'myname-landkid-test-repo', 14 | uuid: 'repo uuid', // This is optional but will make development startup faster 15 | }, 16 | deployment: { 17 | secret: 'session secret', // for local dev this can be anything 18 | redis: { 19 | endpoint: process.env.REDIS_SESSION_HOST, 20 | port: process.env.REDIS_SESSION_PORT, 21 | }, 22 | // Create oauth consumer for workspace 23 | // Needs to be private with callback URL as baseUrl/auth/callback and URL set to baseUrl 24 | // Requires account read permissions 25 | oAuth: { 26 | key: process.env.oauth_key, 27 | secret: process.env.oauth_secret, 28 | }, 29 | enableBasicAuth: false, 30 | }, 31 | maxConcurrentBuilds: 3, 32 | widgetSettings: { 33 | refreshInterval: 10000, 34 | refreshOnlyWhenInViewport: false, 35 | enableSquashMerge: false, 36 | }, 37 | prSettings: { 38 | requiredApprovals: 0, 39 | canApproveOwnPullRequest: true, 40 | requireClosedTasks: true, 41 | requireGreenBuild: false, 42 | allowLandWhenAble: true, 43 | landBuildTimeoutTime: 1000 * 60 * 60 * 2, // 2 hours 44 | /** What is provided to a custom rule: 45 | * { 46 | * pullRequest: BB.PullRequest -- see /src/bitbucket/types.d.ts 47 | * buildStatuses: BB.BuildStatus[] -- see /src/bitbucket/types.d.ts 48 | * approvals: string[] -- usernames of all real approvals 49 | * permissionLevel: "read" | "land" | "admin" -- permission level of the user requesting /can-land 50 | * } 51 | * Return true if the rule is passed and is not blocking landing, 52 | * otherwise return the error message to be displayed on the PR 53 | */ 54 | }, 55 | mergeSettings: { 56 | skipBuildOnDependentsAwaitingMerge: true, 57 | mergeBlocking: { 58 | enabled: false, 59 | builds: [ 60 | { 61 | targetBranch: 'master', 62 | pipelineFilterFn: (pipelines) => { 63 | return ( 64 | pipelines 65 | .filter( 66 | (pipeline) => 67 | pipeline.state.name === 'IN_PROGRESS' || pipeline.state.name === 'PENDING', 68 | ) 69 | // Filter to only default builds run on 'push'. 70 | // Allow manual trigger of default builds but exclude custom builds that are triggered manually 71 | .filter( 72 | (job) => job.trigger.name !== 'SCHEDULE' && job.target.selector.type !== 'custom', 73 | ) 74 | ); 75 | }, 76 | }, 77 | ], 78 | }, 79 | }, 80 | queueSettings: { 81 | speculationEngineEnabled: false, 82 | }, 83 | eventListeners: [ 84 | { 85 | event: 'PULL_REQUEST.MERGE.SUCCESS', 86 | listener: ({ 87 | landRequestId, 88 | pullRequestId, 89 | sourceBranch, 90 | targetBranch, 91 | commit, 92 | duration, 93 | }) => { 94 | // send data to metrics tooling 95 | }, 96 | }, 97 | ], 98 | }; 99 | -------------------------------------------------------------------------------- /src/static/current-state/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Landkid Status 7 | 8 | 9 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
Loading...
81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /src/bitbucket/descriptor.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../lib/Config'; 2 | 3 | const getAppName = () => 4 | `${config.name || 'Landkid'} ${(process.env.LANDKID_DEPLOYMENT || 'local').toUpperCase()}`; 5 | 6 | export const getAppKey = () => 7 | `${config.key || 'landkid'}-${process.env.LANDKID_DEPLOYMENT || 'local'}`; 8 | 9 | export const makeDescriptor = () => { 10 | const appName = getAppName(); 11 | const appKey = getAppKey(); 12 | 13 | const addonName = `Landkid Queue${ 14 | process.env.LANDKID_DEPLOYMENT !== 'prod' 15 | ? ` (${process.env.LANDKID_DEPLOYMENT || 'local'})` 16 | : '' 17 | }`; 18 | 19 | const params = new URLSearchParams(); 20 | params.append('appName', appName); 21 | const appNameQueryString = params.toString(); 22 | 23 | return { 24 | name: appName, 25 | description: 'Addon to add PRs to a merge queue', 26 | baseUrl: config.baseUrl, 27 | key: appKey, 28 | vendor: { 29 | name: 'Fabric Build', 30 | }, 31 | // :write implies base scope (e.g. pullrequest:write implies pullrequest) 32 | // pullrequest:write requires repository:write so we don't need to define that as well 33 | scopes: ['account', 'pullrequest:write', 'pipeline:write'], 34 | authentication: { 35 | type: 'jwt', 36 | }, 37 | contexts: ['account'], 38 | lifecycle: { 39 | installed: '/bitbucket/lifecycle/installed', 40 | uninstalled: '/bitbucket/lifecycle/uninstalled', 41 | }, 42 | modules: { 43 | proxy: { 44 | '/can-land/{repository}/{pullrequest}': { 45 | destination: 46 | '/bitbucket/proxy/can-land?aaid={user.uuid}&pullRequestId={pullrequest.id}&accountId={user.account_id}&sourceBranch={pullrequest.source.branch.name}&destinationBranch={pullrequest.destination.branch.name}', 47 | }, 48 | '/settings': { 49 | destination: '/bitbucket/proxy/settings', 50 | }, 51 | '/queue': { 52 | destination: '/bitbucket/proxy/queue', 53 | }, 54 | '/land/{repository}/{pullrequest}': { 55 | destination: 56 | '/bitbucket/proxy/land?aaid={user.uuid}&pullRequestId={pullrequest.id}&commit={pullrequest.source.commit.hash}&accountId={user.account_id}', 57 | }, 58 | '/land-when-able/{repository}/{pullrequest}': { 59 | destination: 60 | '/bitbucket/proxy/land-when-able?aaid={user.uuid}&pullRequestId={pullrequest.id}&commit={pullrequest.source.commit.hash}&accountId={user.account_id}', 61 | }, 62 | }, 63 | webPanels: [ 64 | { 65 | weight: 100, 66 | tooltip: { 67 | value: 'Landkid PR panel', 68 | }, 69 | key: 'landkid-addon-panel', 70 | name: { 71 | value: addonName, 72 | }, 73 | url: `/bitbucket/index.html?state={pullrequest.state}&repoId={repository.uuid}&repoName={repository.full_name}&pullRequestId={pullrequest.id}&${appNameQueryString}`, 74 | location: 'org.bitbucket.pullrequest.overview.informationPanel', 75 | conditions: [ 76 | { 77 | condition: 'has_permission', 78 | params: { 79 | permission: 'write', 80 | }, 81 | }, 82 | { 83 | condition: 'equals', 84 | target: 'repository.uuid', 85 | params: { 86 | value: config.repoConfig.uuid, 87 | }, 88 | }, 89 | ], 90 | }, 91 | ], 92 | webhooks: [ 93 | { 94 | event: 'repo:commit_status_updated', 95 | url: '/bitbucket/webhook/status-updated', 96 | }, 97 | ], 98 | }, 99 | }; 100 | }; 101 | -------------------------------------------------------------------------------- /src/lib/PermissionService.ts: -------------------------------------------------------------------------------- 1 | import { Permission, UserNote } from '../db'; 2 | import { config } from './Config'; 3 | import { Logger } from './Logger'; 4 | 5 | class PermissionService { 6 | getPermissionForUser = async (aaid: string): Promise => { 7 | const permission = await Permission.findOne({ 8 | where: { 9 | aaid, 10 | }, 11 | order: [['dateAssigned', 'DESC']], 12 | }); 13 | 14 | if (!permission) { 15 | const defaultMode: IPermissionMode = config.landkidAdmins.includes(aaid) ? 'admin' : 'land'; 16 | Logger.info('User does not exist, creating one', { 17 | namespace: 'lib:permissions:getPermissionForUser', 18 | defaultMode, 19 | aaid, 20 | }); 21 | await Permission.create({ 22 | aaid, 23 | mode: defaultMode, 24 | }); 25 | return defaultMode; 26 | } 27 | 28 | return permission.mode; 29 | }; 30 | 31 | setPermissionForUser = async ( 32 | aaid: string, 33 | mode: IPermissionMode, 34 | setter: ISessionUser, 35 | ): Promise => { 36 | await Permission.create({ 37 | aaid, 38 | mode, 39 | assignedByAaid: setter.aaid, 40 | }); 41 | }; 42 | 43 | getNotes = async (): Promise => { 44 | return UserNote.findAll(); 45 | }; 46 | 47 | setNoteForUser = async (aaid: string, note: string, setter: ISessionUser): Promise => { 48 | const noteExists = await UserNote.count({ where: { aaid } }); 49 | if (noteExists) { 50 | await UserNote.update({ note, setByAaid: setter.aaid }, { where: { aaid } }); 51 | } else { 52 | await UserNote.create({ aaid, note, setByAaid: setter.aaid }); 53 | } 54 | }; 55 | 56 | removeUserNote = async (aaid: string): Promise => { 57 | await UserNote.destroy({ 58 | where: { 59 | aaid, 60 | }, 61 | }); 62 | }; 63 | 64 | getUsersPermissions = async (requestingUserMode: IPermissionMode): Promise => { 65 | // TODO: Figure out how to use distinct 66 | const perms = await Permission.findAll({ 67 | order: [['dateAssigned', 'DESC']], 68 | }); 69 | 70 | // Need to get only the latest record for each user 71 | const aaidPerms: Record = {}; 72 | for (const perm of perms) { 73 | if ( 74 | !aaidPerms[perm.aaid] || 75 | aaidPerms[perm.aaid].dateAssigned.getTime() < perm.dateAssigned.getTime() 76 | ) { 77 | aaidPerms[perm.aaid] = perm; 78 | } 79 | } 80 | 81 | const aaidNotes: Record = {}; 82 | if (requestingUserMode === 'admin') { 83 | const notes = await UserNote.findAll(); 84 | for (const note of notes) { 85 | aaidNotes[note.aaid] = note.note; 86 | } 87 | } 88 | 89 | // Now we need to filter to only show the records that the requesting user is allowed to see 90 | const users: UserState[] = []; 91 | for (const aaid of Object.keys(aaidPerms)) { 92 | // admins see all users 93 | if (requestingUserMode === 'admin') { 94 | users.push({ 95 | aaid, 96 | mode: aaidPerms[aaid].mode, 97 | dateAssigned: aaidPerms[aaid].dateAssigned, 98 | assignedByAaid: aaidPerms[aaid].assignedByAaid, 99 | note: aaidNotes[aaid], 100 | }); 101 | // land users can see land and admin users 102 | } else if (requestingUserMode === 'land' && aaidPerms[aaid].mode !== 'read') { 103 | users.push(aaidPerms[aaid]); 104 | // read users can only see admins 105 | } else if (requestingUserMode === 'read' && aaidPerms[aaid].mode === 'admin') { 106 | users.push(aaidPerms[aaid]); 107 | } 108 | } 109 | 110 | return users; 111 | }; 112 | } 113 | 114 | export const permissionService = new PermissionService(); 115 | -------------------------------------------------------------------------------- /src/static/current-state/components/tabs/Messenger.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type MessengerProps = { 4 | bannerMessageState: IMessageState | null; 5 | refreshData: () => void; 6 | }; 7 | 8 | type MessengerState = { 9 | message: string; 10 | type: string; 11 | }; 12 | 13 | export class Messenger extends React.Component { 14 | messageColours = { 15 | default: 'transparent', 16 | warning: 'orange', 17 | error: 'red', 18 | }; 19 | 20 | messageEmoji = { 21 | default: '📢', 22 | warning: '⚠️', 23 | error: '❌', 24 | }; 25 | 26 | state: MessengerState = { 27 | message: '', 28 | type: 'default', 29 | }; 30 | 31 | sendMessage = () => { 32 | const { refreshData } = this.props; 33 | const { message, type } = this.state; 34 | if (!message) return; 35 | fetch('/api/message', { 36 | method: 'POST', 37 | headers: new Headers({ 'Content-Type': 'application/json' }), 38 | body: JSON.stringify({ message, type }), 39 | }).then(() => refreshData()); 40 | }; 41 | 42 | removeMessage = () => { 43 | const { refreshData } = this.props; 44 | fetch('/api/remove-message', { method: 'POST' }).then(() => refreshData()); 45 | }; 46 | 47 | render() { 48 | const { bannerMessageState } = this.props; 49 | const msgType = bannerMessageState ? bannerMessageState.messageType : 'default'; 50 | return ( 51 | 52 | 53 |
54 |

55 | Banner Message to be displayed on Pull Requests: 56 |

57 |