├── .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 = () => ;
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 |
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 |
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 |
92 |
93 | {bannerMessageState ? (
94 |
95 | Current Banner Message:
96 |
106 | {`${this.messageEmoji[msgType]} ${bannerMessageState.message} ${this.messageEmoji[msgType]}`}
107 |
108 |
115 |
116 | ) : null}
117 |
118 | );
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/static/bitbucket/components/Queue.tsx:
--------------------------------------------------------------------------------
1 | import { css, keyframes } from 'emotion';
2 | import { G300, N60, N200, N400 } from '@atlaskit/theme/colors';
3 | import { QueueResponse } from './types';
4 |
5 | const queueContainerStyle = css({
6 | display: 'flex',
7 | flexDirection: 'row',
8 | });
9 |
10 | const queueElementStyle = {
11 | width: 20,
12 | height: 20,
13 | background: 'rgb(94 108 132 / 22%)',
14 | borderRadius: '50%',
15 | marginRight: 6,
16 | fontSize: 10,
17 | display: 'flex',
18 | justifyContent: 'center',
19 | alignItems: 'center',
20 | };
21 |
22 | const queueElementInactiveStyle = css({
23 | ...queueElementStyle,
24 | background: 'rgb(94 108 132 / 22%)',
25 | color: N60,
26 | });
27 |
28 | const queueElementActiveStyle = css({
29 | ...queueElementStyle,
30 | background: G300,
31 | color: 'white',
32 | boxShadow: `0px 0px 0px 2px white, 0px 0px 0px 3px ${G300}`,
33 | });
34 |
35 | const shimmer = keyframes({
36 | from: {
37 | background: N200,
38 | },
39 | to: {
40 | background: N400,
41 | },
42 | });
43 |
44 | const queueElementRunningStyle = css({
45 | ...queueElementStyle,
46 | animation: `${shimmer} 0.5s alternate infinite`,
47 | });
48 |
49 | const queueSeparatorStyle = css({
50 | width: 2,
51 | height: 20,
52 | background: N60,
53 | marginRight: 14,
54 | marginLeft: 8,
55 | });
56 |
57 | const queueLabelStyle = css({
58 | marginTop: 6,
59 | marginBottom: 6,
60 | });
61 |
62 | const Queue = ({
63 | queue,
64 | pullRequestId,
65 | }: {
66 | queue: QueueResponse['queue'] | undefined;
67 | pullRequestId: number;
68 | }) => {
69 | if (!queue) {
70 | return null;
71 | }
72 | const waitingQueue = queue.filter((request) => request.state === 'queued');
73 | const runningQueue = queue.filter((request) => request.state === 'running');
74 |
75 | const prPositionWaitQueue = waitingQueue.findIndex(
76 | (pr) => pr.request.pullRequestId === pullRequestId,
77 | );
78 | const prPositionRunningQueue = runningQueue.findIndex(
79 | (pr) => pr.request.pullRequestId === pullRequestId,
80 | );
81 |
82 | const isPRInWaitQueue = prPositionWaitQueue > -1;
83 | const isPRInRunningQueue = prPositionRunningQueue > -1;
84 | const isPRInQueue = isPRInWaitQueue || isPRInRunningQueue;
85 |
86 | if (!isPRInQueue) {
87 | return Pull request is no longer in queue.
;
88 | }
89 |
90 | return (
91 | <>
92 |
93 | {runningQueue.map(({ request }, index) => (
94 |
103 | ))}
104 | {waitingQueue.length > 0 &&
}
105 | {waitingQueue.map(({ request }, index) => (
106 |
115 | {index + 1}
116 |
117 | ))}
118 |
119 | {isPRInWaitQueue ? (
120 | prPositionWaitQueue === 0 ? (
121 | Land request is waiting at start of the queue...
122 | ) : (
123 |
124 | {' '}
125 | Land request is behind {prPositionWaitQueue} other requests...
126 |
127 | )
128 | ) : null}
129 | {isPRInRunningQueue && (
130 |
131 | Build checks are being run for this pull request. If they succeed, the pull request will
132 | be merged.{' '}
133 |
134 | )}
135 | >
136 | );
137 | };
138 |
139 | export default Queue;
140 |
--------------------------------------------------------------------------------
/src/routes/middleware.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { decode, fromExpressRequest, createQueryStringHash } from 'atlassian-jwt';
3 | import { Installation } from '../db';
4 | import { permissionService } from '../lib/PermissionService';
5 | import { Logger } from '../lib/Logger';
6 |
7 | export const wrap = (fn: express.RequestHandler): express.RequestHandler => {
8 | return async (req, res, next) => {
9 | try {
10 | await Promise.resolve(fn(req, res, next));
11 | } catch (err) {
12 | next(err);
13 | }
14 | };
15 | };
16 |
17 | const modeHierarchy: IPermissionMode[] = ['read', 'land', 'admin'];
18 |
19 | export const permission = (userMode: IPermissionMode) => ({
20 | isAtLeast: (mode: IPermissionMode) =>
21 | modeHierarchy.indexOf(userMode) >= modeHierarchy.indexOf(mode),
22 | });
23 |
24 | export const requireAuth = (mode: IPermissionMode = 'read'): express.RequestHandler =>
25 | wrap(async (req, res, next) => {
26 | if (!req.user) {
27 | Logger.warn('Endpoint requires authentication', {
28 | namespace: 'routes:middleware:requireAuth',
29 | });
30 | return res.status(401).json({
31 | error: 'This endpoint requires authentication',
32 | });
33 | }
34 |
35 | const userMode = await permissionService.getPermissionForUser(req.user.aaid);
36 |
37 | if (!permission(userMode).isAtLeast(mode)) {
38 | return res.status(403).json({
39 | error: 'You are not powerful enough to use this endpoint, sorry not sorry...',
40 | });
41 | }
42 |
43 | next();
44 | });
45 |
46 | export const authenticateIncomingBBCall: express.RequestHandler = wrap(async (req, res, next) => {
47 | const install = await Installation.findOne();
48 | if (!install) {
49 | Logger.error('Addon has not been installed, can not be validated', {
50 | namespace: 'routes:middleware:authenticateIncomingBBCall',
51 | });
52 | return res.status(401).json({
53 | error: 'Addon has not been installed, can not be validated',
54 | });
55 | }
56 | let jwt: string | undefined =
57 | (req.query.jwt as string | undefined) || req.header('authorization');
58 | if (!jwt) {
59 | Logger.error('Authenticated request requires a JWT token', {
60 | namespace: 'routes:middleware:authenticateIncomingBBCall',
61 | });
62 | return res.status(401).json({
63 | error: 'Authenticated request requires a JWT token',
64 | });
65 | }
66 | if (jwt.startsWith('JWT ')) {
67 | jwt = jwt.substr(4);
68 | }
69 |
70 | let decoded: any;
71 | try {
72 | decoded = decode(jwt, install.sharedSecret);
73 | } catch (err) {
74 | Logger.error('Could not validate JWT', {
75 | namespace: 'routes:middleware:authenticateIncomingBBCall',
76 | });
77 | return res.status(401).json({
78 | error: 'Could not validate JWT',
79 | });
80 | }
81 |
82 | if (!decoded || !decoded.qsh || decoded.iss !== install.clientKey) {
83 | return res.status(401).json({
84 | error: 'That JWT is pretty bad lol',
85 | });
86 | }
87 |
88 | const expectedHash = createQueryStringHash(fromExpressRequest(req));
89 |
90 | if (expectedHash !== decoded.qsh) {
91 | Logger.error('JWT token is valid but not for this request', {
92 | namespace: 'routes:middleware:authenticateIncomingBBCall',
93 | });
94 | return res.status(401).json({
95 | error: 'JWT token is valid but not for this request',
96 | });
97 | }
98 |
99 | next();
100 | });
101 |
102 | export const requireCustomToken: express.RequestHandler = wrap(async (req, res, next) => {
103 | let token: string | undefined = req.header('authorization');
104 | if (!token) {
105 | return res.status(400).json({ error: 'Request requires a custom token' });
106 | }
107 | token = token.substr(6);
108 |
109 | let decoded: any;
110 | try {
111 | decoded = Buffer.from(token, 'base64').toString().trim();
112 | } catch (err) {
113 | Logger.error('Could not decode custom token', { err });
114 | return res.status(401).json({ error: 'Could not decode custom token' });
115 | }
116 |
117 | if (decoded !== process.env.CUSTOM_TOKEN) {
118 | Logger.error('Custom token is not valid', { decoded });
119 | return res.status(401).json({ error: 'Custom token is not valid' });
120 | }
121 |
122 | next();
123 | });
124 |
--------------------------------------------------------------------------------
/src/static/current-state/components/InlineEdit.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { css } from 'emotion';
3 | import Check from '@atlaskit/icon/glyph/check';
4 | import Cross from '@atlaskit/icon/glyph/cross';
5 | import Edit from '@atlaskit/icon/glyph/edit';
6 |
7 | const inputWrapper = css({
8 | width: '400px',
9 | paddingBottom: '5px',
10 | });
11 |
12 | const editButton = css({
13 | marginLeft: '5px',
14 | background: 'none',
15 | border: 'none',
16 | padding: '0px',
17 | verticalAlign: 'middle',
18 | color: 'darkgray',
19 | ':focus': { outline: 'none' },
20 | ':hover': {
21 | cursor: 'pointer',
22 | color: 'black',
23 | },
24 | });
25 |
26 | const valueSpan = css({
27 | marginLeft: '5px',
28 | marginBottom: '1px',
29 | color: 'darkgray',
30 | fontStyle: 'italic',
31 | verticalAlign: 'middle',
32 | });
33 |
34 | const inputButtons = css({
35 | paddingTop: '5px',
36 | display: 'flex',
37 | height: '50px',
38 | });
39 |
40 | const errorText = css({
41 | color: 'red',
42 | paddingTop: '2px',
43 | });
44 |
45 | type InlineEditProps = {
46 | value: string;
47 | id: string;
48 | handleRemove: (value: string) => void;
49 | handleEdit: (id: string, updatedValue: string) => boolean | void;
50 | hasInlineError: boolean;
51 | setHasInLineError: React.Dispatch>;
52 | };
53 |
54 | export const InlineEdit: React.FunctionComponent = ({
55 | value,
56 | id,
57 | handleEdit,
58 | handleRemove,
59 | hasInlineError,
60 | setHasInLineError,
61 | }) => {
62 | const [isInputDisplayed, setIsInputDisplayed] = useState(false);
63 | const [editedValue, setEditedValue] = useState(value);
64 |
65 | const defaultSpan = css({
66 | display: isInputDisplayed ? 'none' : 'block',
67 | });
68 |
69 | const displayInput = css({
70 | display: isInputDisplayed ? 'flex' : 'none',
71 | });
72 |
73 | const errorWrapper = css({
74 | display: hasInlineError ? 'block' : 'none',
75 | });
76 |
77 | const handleCancel = () => {
78 | setIsInputDisplayed(false);
79 | setHasInLineError(false);
80 | setEditedValue(value);
81 | };
82 | return (
83 |
84 |
85 |
86 | {value}
87 |
88 |
97 |
104 |
105 |
106 |
107 |
{
115 | setEditedValue(e.target.value);
116 | }}
117 | />
118 |
119 |
120 | Priority branch name already exists.
121 |
122 |
123 |
124 |
125 |
139 |
142 |
143 |
144 |
145 | );
146 | };
147 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { AxiosInstance } from 'axios';
2 | import { Logger } from 'winston';
3 |
4 | export type LandRequestOptions = {
5 | prId: number;
6 | prAuthorAaid: string;
7 | prAuthorAccountId: string;
8 | prTitle: string;
9 | prSourceBranch: string;
10 | prTargetBranch: string;
11 | triggererAaid: string;
12 | triggererAccountId: string;
13 | commit: string;
14 | mergeStrategy?: IMergeStrategy;
15 | };
16 |
17 | export type RepoConfig = {
18 | repoOwner: string;
19 | repoName: string;
20 | uuid?: string;
21 | };
22 |
23 | type CustomRule = {
24 | rule: (
25 | pullRequestInfo: {
26 | pullRequest: BB.PullRequest;
27 | buildStatuses: BB.BuildStatus[];
28 | approvals: string[];
29 | permissionLevel: IPermissionMode;
30 | },
31 | utils: {
32 | axios: AxiosInstance;
33 | Logger: Logger;
34 | },
35 | ) => Promise;
36 | errorKeys?: {
37 | [key: string]: string;
38 | };
39 | };
40 |
41 | export type WidgetSettings = {
42 | refreshInterval: number;
43 | refreshOnlyWhenInViewport: boolean;
44 | enableSquashMerge: boolean;
45 | };
46 |
47 | export type PullRequestSettings = {
48 | requiredApprovals: number;
49 | requireClosedTasks: boolean;
50 | requireGreenBuild: boolean;
51 | canApproveOwnPullRequest: boolean;
52 | allowLandWhenAble: boolean;
53 | customChecks?: CustomRule[];
54 | customWarnings?: CustomRule[];
55 | landBuildTimeoutTime?: number;
56 | };
57 |
58 | export type ApprovalChecks = {
59 | isOpen: boolean;
60 | isApproved: boolean;
61 | isGreen: boolean;
62 | allTasksClosed: boolean;
63 | };
64 |
65 | export type DeploymentConfig = {
66 | secret: string;
67 | redis: {
68 | endpoint: string;
69 | port: number;
70 | };
71 | oAuth: OAuthConfig;
72 | enableBasicAuth?: boolean;
73 | };
74 |
75 | export type OAuthConfig = {
76 | key: string;
77 | secret: string;
78 | };
79 |
80 | export type EventData = {
81 | landRequestId?: string;
82 | pullRequestId?: string;
83 | sourceBranch?: string;
84 | targetBranch?: string;
85 | commit?: string;
86 | duration?: number;
87 | };
88 |
89 | type EventListener = {
90 | event: string;
91 | listener: (data: EventData, { Logger }: { Logger: Logger }) => void;
92 | };
93 |
94 | export type Config = {
95 | name?: string;
96 | key?: string;
97 | port: number;
98 | baseUrl: string;
99 | landkidAdmins: string[];
100 | repoConfig: RepoConfig;
101 | widgetSettings: WidgetSettings;
102 | prSettings: PullRequestSettings;
103 | deployment: DeploymentConfig;
104 | maxConcurrentBuilds?: number;
105 | permissionsMessage: string;
106 | sequelize?: any;
107 | eventListeners?: EventListener[];
108 | easterEgg?: any;
109 | mergeSettings?: MergeSettings;
110 | queueSettings?: QueueSettings;
111 | };
112 |
113 | export type MergeSettings = {
114 | /** Skip the destination branch build when there are successful dependent requests awaiting merge.
115 | * This prevents multiple branch builds triggering multiple merges that happen in quick succession.
116 | * Achieved by adding [skip ci] to the merge commit message
117 | */
118 | skipBuildOnDependentsAwaitingMerge?: boolean;
119 | /** Wait for particular builds on the target branch to finish before merging */
120 | mergeBlocking?: {
121 | enabled: boolean;
122 | builds: [
123 | {
124 | targetBranch: string;
125 | pipelineFilterFn: (pipelines: BB.Pipeline[]) => BB.Pipeline[];
126 | },
127 | ];
128 | };
129 | };
130 |
131 | export type QueueSettings = {
132 | /**
133 | * Speculation engine reorders top n PRs where n is the available free slots based on the impact of the PRs. The lower impact PRs are given preference.
134 | * Impact meta data is processed and send to landkid by the consuming repo using build statuses.
135 | */
136 | speculationEngineEnabled: boolean;
137 | };
138 |
139 | export type State = {
140 | pauseState: IPauseState | null;
141 | bannerMessageState: IMessageState | null;
142 | maxConcurrentBuilds: number;
143 | daysSinceLastFailure: number;
144 | priorityBranchList: IPriorityBranch[];
145 | adminSettings: IAdminSettings;
146 | config: { mergeSettings?: MergeSettings; speculationEngineEnabled: boolean };
147 | };
148 |
149 | export type RunnerState = State & {
150 | queue: IStatusUpdate[];
151 | waitingToQueue: IStatusUpdate[];
152 | users: UserState[];
153 | priorityBranchList: IPriorityBranch[];
154 | bitbucketBaseUrl: string;
155 | permissionsMessage: string;
156 | };
157 |
158 | export type MergeOptions = {
159 | skipCI?: boolean;
160 | mergeStrategy?: IMergeStrategy;
161 | numRetries?: number;
162 | };
163 |
--------------------------------------------------------------------------------
/src/static/current-state/components/tabs/UsersList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css } from 'emotion';
3 | import { User } from '../User';
4 | import { PermissionControl } from '../PermissionControl';
5 |
6 | const noteButton = css({
7 | marginLeft: '5px',
8 | background: 'none',
9 | border: 'none',
10 | padding: '0px',
11 | verticalAlign: 'middle',
12 | color: 'darkgray',
13 | ':focus': { outline: 'none' },
14 | ':hover': {
15 | cursor: 'pointer',
16 | color: 'black',
17 | },
18 | });
19 |
20 | const noteSpan = css({
21 | marginLeft: '5px',
22 | marginBottom: '1px',
23 | color: 'darkgray',
24 | fontStyle: 'italic',
25 | verticalAlign: 'middle',
26 | });
27 |
28 | const icon = css({
29 | height: '15px',
30 | width: '15px',
31 | });
32 |
33 | type NoteManagerProps = {
34 | aaid: string;
35 | note?: string;
36 | };
37 |
38 | type NoteManagerState = {
39 | note?: string;
40 | };
41 |
42 | class NoteManager extends React.Component {
43 | state = {
44 | note: this.props.note,
45 | };
46 |
47 | addNote = () => {
48 | const note = window.prompt('What would you like the note to be?', this.state.note);
49 | if (note === null) return;
50 | fetch(`/api/note/${this.props.aaid}`, {
51 | method: 'PATCH',
52 | headers: new Headers({ 'Content-Type': 'application/json' }),
53 | body: JSON.stringify({ note }),
54 | })
55 | .then((res) => res.json())
56 | .then((json) => {
57 | if (json.error) {
58 | console.error(json.error);
59 | window.alert(json.error);
60 | } else {
61 | console.log(json);
62 | this.setState({ note });
63 | }
64 | });
65 | };
66 |
67 | removeNote = () => {
68 | fetch(`/api/note/${this.props.aaid}`, { method: 'DELETE' })
69 | .then((res) => res.json())
70 | .then((json) => {
71 | if (json.error) {
72 | console.error(json.error);
73 | window.alert(json.error);
74 | } else {
75 | console.log(json);
76 | this.setState({ note: undefined });
77 | }
78 | });
79 | };
80 |
81 | render() {
82 | const { note } = this.state;
83 | if (!note)
84 | return (
85 |
90 | );
91 | return (
92 |
93 | {note}
94 |
99 |
104 |
105 | );
106 | }
107 | }
108 |
109 | // sort by permssion descending (admin -> land -> read)
110 | function sortUsersByPermission(user1: IPermission, user2: IPermission) {
111 | const permssionsLevels = ['read', 'land', 'admin'];
112 | return permssionsLevels.indexOf(user2.mode) - permssionsLevels.indexOf(user1.mode);
113 | }
114 |
115 | export type Props = {
116 | users: UserState[];
117 | loggedInUser: ISessionUser;
118 | };
119 |
120 | export const UsersList: React.FunctionComponent = (props) => (
121 |
122 | Users
123 |
124 | {props.users
125 | .sort(sortUsersByPermission)
126 | .map(({ aaid, mode, assignedByAaid, dateAssigned, note }) => (
127 |
131 |
132 | {(user) => (
133 |
134 | |
135 |
140 | |
141 |
142 | {user.displayName}
143 | {props.loggedInUser.permission === 'admin' && (
144 |
145 | )}
146 | |
147 |
148 | )}
149 |
150 |
151 | ))}
152 |
153 |
154 | );
155 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "landkid",
3 | "version": "0.2.10",
4 | "description": "CI Queue",
5 | "main": "dist/index.js",
6 | "contributors": [
7 | "James Kyle ",
8 | "Luke Batchelor ",
9 | "Samuel Attard "
10 | ],
11 | "license": "MIT",
12 | "bin": {
13 | "landkid": "dist/index.js"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "scripts": {
19 | "clean": "rimraf dist",
20 | "test:unit": "jest",
21 | "test:unit:watch": "jest --watch",
22 | "format": "prettier --write src/**/*.ts src/**/*.tsx",
23 | "build": "yarn clean && yarn build:server && yarn build:ui",
24 | "build:server": "tsc",
25 | "build:ui": "cross-env OUTPUT_PATH=dist/static webpack --mode=production",
26 | "start": "cross-env NODE_ENV=production node ./dist",
27 | "dev": "yarn build:server && concurrently \"webpack-dev-server\" \"tsc -w\" \"nodemon ./dist --watch dist --watch config.js --watch rules --delay 1\"",
28 | "precommit": "lint-staged",
29 | "migrate": "ts-node src/db/MigrationService.ts",
30 | "tunnel": "./tools/tunnel.sh",
31 | "storybook": "start-storybook -p 6006",
32 | "build-storybook": "build-storybook"
33 | },
34 | "devDependencies": {
35 | "@babel/core": "^7.17.12",
36 | "@storybook/addon-actions": "^6.4.22",
37 | "@storybook/addon-essentials": "^6.4.22",
38 | "@storybook/addon-interactions": "^6.4.22",
39 | "@storybook/addon-links": "^6.4.22",
40 | "@storybook/react": "^6.4.22",
41 | "@storybook/testing-library": "^0.0.11",
42 | "@testing-library/jest-dom": "^5.16.5",
43 | "@testing-library/react": "^12.1.2",
44 | "@types/body-parser": "^1.17.0",
45 | "@types/connect-redis": "0.0.16",
46 | "@types/express": "^4.16.0",
47 | "@types/express-session": "^1.17.3",
48 | "@types/express-winston": "^3.0.0",
49 | "@types/faker": "^4.1.12",
50 | "@types/ioredis-mock": "^5.6.0",
51 | "@types/jest": "^26.0.5",
52 | "@types/joi": "^14.3.4",
53 | "@types/node": "^18.16.13",
54 | "@types/p-retry": "^2.0.0",
55 | "@types/passport": "^1.0.6",
56 | "@types/passport-http": "^0.3.9",
57 | "@types/passport-oauth2": "^1.4.10",
58 | "@types/react": "^16.8.0",
59 | "@types/react-dom": "^16.8.0",
60 | "@types/redis": "^2.8.28",
61 | "@types/redlock": "^3.0.2",
62 | "@types/umzug": "^2.2.2",
63 | "babel-loader": "^8.2.5",
64 | "cache-loader": "^4.1.0",
65 | "concurrently": "^4.1.0",
66 | "css-loader": "^6.7.1",
67 | "faker": "^4.1.0",
68 | "html-webpack-plugin": "^5.5.0",
69 | "httplease-asap": "^0.3.2",
70 | "husky": "^0.14.3",
71 | "ioredis": "^4.28.2",
72 | "ioredis-mock": "^5.8.1",
73 | "jest": "^25.5.4",
74 | "joi": "^14.3.1",
75 | "lint-staged": "^5.0.0",
76 | "nodemon": "^1.18.6",
77 | "prettier": "^2.3.0",
78 | "rimraf": "^2.6.2",
79 | "rxjs-compat": "^6.3.3",
80 | "sqlite3": "^5.0.11",
81 | "style-loader": "^3.3.1",
82 | "ts-jest": "25.5.1",
83 | "ts-loader": "^9.3.1",
84 | "ts-node": "^9.1.1",
85 | "webpack": "^5.74.0",
86 | "webpack-cli": "^4.10.0",
87 | "webpack-dev-server": "^4.10.1"
88 | },
89 | "dependencies": {
90 | "@atlaskit/button": "^15.0.0",
91 | "@atlaskit/checkbox": "^12.3.19",
92 | "@atlaskit/css-reset": "^6.3.13",
93 | "@atlaskit/pagination": "^8.0.5",
94 | "@atlaskit/section-message": "^6.1.12",
95 | "@atlaskit/spinner": "^15.1.11",
96 | "@atlaskit/tooltip": "^17.6.5",
97 | "@types/micromatch": "^4.0.1",
98 | "atlassian-jwt": "^1.0.1",
99 | "axios": "^0.21.4",
100 | "body-parser": "^1.18.2",
101 | "connect-redis": "^5.2.0",
102 | "cross-env": "^5.2.0",
103 | "date-fns": "^1.29.0",
104 | "delay": "^4.3.0",
105 | "emotion": "^10.0.27",
106 | "express": "^4.16.2",
107 | "express-session": "^1.17.1",
108 | "express-winston": "^3.0.1",
109 | "mem": "^8",
110 | "micromatch": "^4.0.2",
111 | "mime": "^3.0.0",
112 | "p-limit": "^3.1.0",
113 | "p-retry": "^4",
114 | "passport": "^0.4.1",
115 | "passport-http": "^0.3.0",
116 | "passport-oauth2": "^1.5.0",
117 | "pg": "^8.2.1",
118 | "react": "^16.8.0",
119 | "react-dom": "^16.8.0",
120 | "react-dom-confetti": "^0.2.0",
121 | "react-intersection-observer": "^9.4.0",
122 | "react-usestateref": "^1.0.8",
123 | "redis": "^3.1.2",
124 | "redlock": "^3.1.2",
125 | "reflect-metadata": "^0.1.12",
126 | "sequelize": "^4.44.0",
127 | "sequelize-typescript": "^0.6.6",
128 | "styled-components": "^3.2.6",
129 | "typescript": "4.3.5",
130 | "ua-parser-js": "^0.7.24",
131 | "umzug": "^2.2.0",
132 | "winston": "^3.1.0"
133 | },
134 | "resolutions": {
135 | "lodash": "^4.17.21",
136 | "color-string": "^1.5.5",
137 | "@types/sequelize": "^4.28.9",
138 | "path-parse": "^1.0.7"
139 | },
140 | "lint-staged": {
141 | "*.js": [
142 | "prettier --write",
143 | "git add"
144 | ],
145 | "*.ts": [
146 | "prettier --write",
147 | "git add"
148 | ],
149 | "*.tsx": [
150 | "prettier --write",
151 | "git add"
152 | ]
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/src/lib/SpeculationEngine.ts:
--------------------------------------------------------------------------------
1 | import { LandRequest, LandRequestStatus } from '../db';
2 | import { Logger } from './Logger';
3 | import { StateService } from './StateService';
4 |
5 | /**
6 | * SpeculationEngine uses impact of a PR to reorder it while placing the PRs on the running slots.
7 | * The lower impact PRs are given higher precedence. It reduces the likelihood of a land request failures due to dependencies.
8 | * Impact meta data is processed and send to landkid by the consuming repo using build statuses.
9 | * */
10 |
11 | export class SpeculationEngine {
12 | constructor() {}
13 |
14 | static async getAvailableSlots(running: LandRequestStatus[]): Promise {
15 | const maxConcurrentBuilds = await StateService.getMaxConcurrentBuilds();
16 | return maxConcurrentBuilds - running.filter(({ state }) => state === 'running').length;
17 | }
18 |
19 | static getPositionInQueue(queue: LandRequestStatus[], landRequestStatus: LandRequestStatus) {
20 | return queue.findIndex(({ id }) => id === landRequestStatus.id);
21 | }
22 |
23 | // get the request with the lowest impact by comparing the current request with the next requests behind in the queue
24 | static getLowestImpactedRequestStatus(
25 | queue: LandRequestStatus[],
26 | position: number,
27 | availableSlots: number,
28 | ): LandRequestStatus {
29 | // the number of queue requests we want to compare the impact with should equal to the number of available slots
30 | const nextqueueRequestStatus = queue.slice(0, availableSlots);
31 |
32 | // sort the impact of next queue requests so that we can compare the lowest request's impact with the current request's impact
33 | const sortedRequestStatuses = nextqueueRequestStatus.sort(
34 | (reqStatusA, reqStatusB) => reqStatusA.request.impact - reqStatusB.request.impact,
35 | );
36 |
37 | Logger.info('Impact retrieved for next queue requests:', {
38 | namespace: 'lib:speculationEngine:getImpact',
39 | pullRequestId: queue[position].request.pullRequestId,
40 | lowestImpactRequest: sortedRequestStatuses[0],
41 | sortedRequestStatuses: sortedRequestStatuses.map(
42 | ({ request }) => request.pullRequestId + ' ' + request.impact,
43 | ),
44 | });
45 |
46 | // return the lowest impact request
47 | return sortedRequestStatuses[0];
48 | }
49 |
50 | /**
51 | *
52 | *
53 | * Determines whether the current land request should be reordered with requests behind it in the queue.
54 | * Note that this is done in a way such that the current land request will only be reordered if it can simultaneously enter the running slots as the request(s)
55 | * that it is reordered with. This ensures that we don't de-prioritise a PR and extend the amount of time it is in the queue.
56 | *
57 | * A request will be reordered if it meets the following conditions:
58 | * 1. The speculationEngineEnabled toggle is enabled from admin settings
59 | * 2. There are at least 2 available free slots
60 | * 3. Size of the queue is greater than 1
61 | * 4. Impact of the current request is not the lowest impact compared with the requests behind it in the queue
62 | *
63 | * @param running
64 | * @param queue
65 | * @param currentLandRequestStatus
66 | * @returns true if requests needs to be reordered.
67 | *
68 | */
69 | static async reorderRequest(
70 | running: LandRequestStatus[],
71 | queue: LandRequestStatus[],
72 | currentLandRequestStatus: LandRequestStatus,
73 | ): Promise {
74 | const { speculationEngineEnabled } = await StateService.getAdminSettings();
75 | if (!speculationEngineEnabled) {
76 | return false;
77 | }
78 |
79 | const availableSlots = await this.getAvailableSlots(running);
80 | const landRequest: LandRequest = currentLandRequestStatus.request;
81 | const positionInQueue = this.getPositionInQueue(queue, currentLandRequestStatus);
82 | const logMessage = (message: string, extraProps = {}) =>
83 | Logger.info(message, {
84 | namespace: 'lib:speculationEngine:reorderRequest',
85 | pullRequestId: landRequest.pullRequestId,
86 | ...extraProps,
87 | });
88 |
89 | logMessage('Speculation engine details', {
90 | availableSlots,
91 | positionInQueue,
92 | queue: queue.map(({ request }) => request.pullRequestId),
93 | });
94 |
95 | if (availableSlots < 2 || queue.length < 2 || positionInQueue >= availableSlots - 1) {
96 | logMessage(
97 | 'Skipping reorder request. Speculation engine conditions to reorder current request are not met.',
98 | );
99 | return false;
100 | }
101 |
102 | logMessage('Attempting to reorder PR based on impact');
103 | const lowestImpact = this.getLowestImpactedRequestStatus(
104 | queue,
105 | positionInQueue,
106 | availableSlots,
107 | );
108 |
109 | // if the curent request has the lowest impact, we do not need to reorder this request.
110 | if (landRequest.id === lowestImpact.request.id) {
111 | logMessage('PR will not be reordered since the current request is the lowest impact');
112 | return false;
113 | }
114 | logMessage('PR will be reordered since the current request does not have the lowest impact');
115 | return true;
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/static/current-state/components/tabs/PriorityBranchList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useState } from 'react';
3 | import { InlineEdit } from '../InlineEdit';
4 | import { css } from 'emotion';
5 | import Info from '@atlaskit/icon/glyph/info';
6 | import Tooltip from '@atlaskit/tooltip';
7 |
8 | const wrapper = css({
9 | paddingTop: '20px',
10 | });
11 |
12 | const branchInputWrapper = css({
13 | width: '400px',
14 | });
15 | const innerWrapper = css({ paddingTop: '10px' });
16 |
17 | const headerWrapper = css({ display: 'flex', marginTop: '10px' });
18 |
19 | const header = css({ marginBottom: '5px' });
20 |
21 | const errorText = css({
22 | color: 'red',
23 | paddingTop: '2px',
24 | });
25 |
26 | const akButton = css({
27 | marginTop: '10px',
28 | });
29 |
30 | export type PriorityBranchListProps = {
31 | priorityBranchList: IPriorityBranch[];
32 | refreshData: () => void;
33 | };
34 |
35 | export const PriorityBranchList: React.FunctionComponent = (props) => {
36 | const [branchName, setBranchName] = useState('');
37 | const [hasError, setHasError] = useState(false);
38 | const [hasInlineError, setHasInlineError] = useState(false);
39 |
40 | const { refreshData, priorityBranchList } = props;
41 |
42 | const branchValidationCheck = (branchName: string, id?: string) => {
43 | const exists = priorityBranchList.some(
44 | (item) =>
45 | (item.branchName === branchName && !id) ||
46 | (item.branchName === branchName && id && item.id !== id),
47 | );
48 | if (id) {
49 | setHasInlineError(exists);
50 | } else {
51 | setHasError(exists);
52 | }
53 | return exists;
54 | };
55 | const handleAddBranch = () => {
56 | if (branchValidationCheck(branchName)) return;
57 | fetch('/api/add-priority-branch', {
58 | method: 'POST',
59 | headers: new Headers({ 'Content-Type': 'application/json' }),
60 | body: JSON.stringify({ branchName }),
61 | })
62 | .then((response) => response.json())
63 | .then((json) => {
64 | if (json.error) {
65 | console.error(json.error);
66 | window.alert(json.error);
67 | } else {
68 | setBranchName('');
69 | refreshData();
70 | }
71 | });
72 | };
73 |
74 | const handleRemoveBranch = (branchName: string) => {
75 | fetch('/api/remove-priority-branch', {
76 | method: 'POST',
77 | headers: new Headers({ 'Content-Type': 'application/json' }),
78 | body: JSON.stringify({ branchName }),
79 | })
80 | .then((response) => response.json())
81 | .then((json) => {
82 | if (json.error) {
83 | console.error(json.error);
84 | window.alert(json.error);
85 | } else {
86 | refreshData();
87 | }
88 | });
89 | };
90 |
91 | const handleUpdateBranch = (id: string, updatedBranchName: string) => {
92 | if (branchValidationCheck(updatedBranchName, id)) return false;
93 | fetch('/api/update-priority-branch', {
94 | method: 'POST',
95 | headers: new Headers({ 'Content-Type': 'application/json' }),
96 | body: JSON.stringify({ id, branchName: updatedBranchName }),
97 | })
98 | .then((response) => response.json())
99 | .then((json) => {
100 | if (json.error) {
101 | console.error(json.error);
102 | window.alert(json.error);
103 | } else {
104 | refreshData();
105 | }
106 | });
107 | };
108 |
109 | const errorWrapper = css({ display: hasError ? 'block' : 'none' });
110 | return (
111 |
112 |
113 |
Priority Branch list
114 | {priorityBranchList.map((branch) => (
115 |
125 | ))}
126 |
127 |
128 |
129 |
Add new branch
130 |
135 |
136 |
137 |
138 |
{
146 | setHasError(false);
147 | setBranchName(e.target.value);
148 | }}
149 | />
150 |
151 |
152 | Priority branch name already exists.
153 |
154 |
155 |
162 |
163 |
164 | );
165 | };
166 |
--------------------------------------------------------------------------------
/src/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Landkid Landing Page
6 |
7 |
8 |
131 |
176 |
177 |
178 |
179 |
180 |
183 |
184 |
Landkid helps you avoid merge hell when merging pull requests.
185 |
186 |
187 | In a nut shell it basically:
188 |
189 | - Creates a merge queue preventing more than one person merging at a time
190 | - Rebases your branch build on lastest master so that you
191 | know master will be green when you merge
192 |
193 |
194 |
195 |
196 |
197 |
198 | GO
199 |
200 |
201 |
😡 What are you still doing here, go check out Landkid!! 😡
202 |
203 |
204 | Jack Gardner accepts no liability for any damages incurred from viewing the content of this page.
205 |
206 |
207 |
208 |
--------------------------------------------------------------------------------
/src/bitbucket/types.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace BB {
2 | type BuildStatusEvent = {
3 | buildId: number;
4 | buildStatus: BuildState;
5 | };
6 |
7 | type PRState = 'OPEN' | 'DECLINED' | 'MERGED';
8 |
9 | type User = {
10 | username: string;
11 | // Note: We can't actually user the account_id anymore (we can't look users up by aaid)
12 | // We still refer to it everywhere as aaid, but we'll actually use the users uuid
13 | account_id: string;
14 | uuid: string;
15 | };
16 |
17 | type DiffStatResponse = {
18 | pagelen: number;
19 | values: Array<{
20 | type: string;
21 | status: 'added' | 'removed' | 'modified' | 'renamed' | 'merge conflict';
22 | lines_added: number;
23 | lines_removed: number;
24 | old: {
25 | type: string;
26 | path: string;
27 | commit: {
28 | type: string;
29 | };
30 | attributes: string;
31 | escaped_path: string;
32 | };
33 | new: {
34 | type: string;
35 | path: string;
36 | commit: {
37 | type: string;
38 | };
39 | attributes: string;
40 | escaped_path: string;
41 | };
42 | }>;
43 | page: number;
44 | size: number;
45 | };
46 |
47 | type PullRequestResponse = {
48 | participants: {
49 | approved: boolean;
50 | user: User;
51 | }[];
52 | author: User;
53 | title: string;
54 | description: string;
55 | source: {
56 | commit: {
57 | hash: string;
58 | };
59 | branch: {
60 | name: string;
61 | };
62 | };
63 | destination: {
64 | branch: {
65 | name: string;
66 | };
67 | };
68 | created_on: string;
69 | state: PRState;
70 | task_count: number;
71 | };
72 |
73 | type PullRequestTaskResponse = {
74 | pagelen: number;
75 | values: [
76 | {
77 | state: string;
78 | },
79 | ];
80 | page: number;
81 | size: number;
82 | };
83 |
84 | type BuildStatusResponse = {
85 | name: string;
86 | state: BuildState;
87 | created_on: string;
88 | url: string;
89 | };
90 |
91 | type MergeStatusResponse =
92 | | {
93 | task_status: 'PENDING';
94 | }
95 | | {
96 | task_status: 'SUCCESS';
97 | merge_result: any;
98 | };
99 |
100 | type PullRequest = {
101 | pullRequestId: number;
102 | title: string;
103 | description: string;
104 | createdOn: Date;
105 | author: string;
106 | authorAaid: string;
107 | targetBranch: string;
108 | commit: string;
109 | state: PRState;
110 | sourceBranch: string;
111 | approvals: Array;
112 | openTasks: number;
113 | };
114 |
115 | type RepositoryResponse = {
116 | uuid: string;
117 | full_name: string;
118 | description: string;
119 | slug: string;
120 | links: {
121 | html: {
122 | href: string;
123 | };
124 | };
125 | owner: {
126 | username: string;
127 | };
128 | };
129 |
130 | type Repository = {
131 | uuid: string;
132 | repoOwner: string;
133 | repoName: string;
134 | fullName: string;
135 | url: string;
136 | };
137 |
138 | type BuildState = 'SUCCESSFUL' | 'FAILED' | 'INPROGRESS' | 'STOPPED' | 'DEFAULT' | 'PENDING';
139 |
140 | type PRPriority = 'LOW' | 'HIGH';
141 |
142 | type BuildPriorityResponse = {
143 | name: string;
144 | description: PRPriority;
145 | };
146 |
147 | type BuildPriorityImpact = {
148 | name: string;
149 | description: string;
150 | };
151 |
152 | type BuildStatus = {
153 | name: string;
154 | state: BuildState;
155 | createdOn: Date;
156 | url: string;
157 | };
158 |
159 | interface PipelineTarget {
160 | type: 'pipeline_commit_target' | 'pipeline_ref_target' | 'pipeline_pullrequest_target';
161 | selector:
162 | | { type: 'default' }
163 | | {
164 | type: 'custom';
165 | pattern: string;
166 | }
167 | | {
168 | type: 'branches';
169 | pattern: string;
170 | }
171 | | {
172 | type: 'pull-requests';
173 | pattern: string;
174 | };
175 | commit: {
176 | type: 'commit';
177 | hash: string;
178 | };
179 | ref_type?: string;
180 | ref_name?: string;
181 | }
182 |
183 | type PendingState = {
184 | name: 'PENDING';
185 | };
186 |
187 | type InprogressState = {
188 | name: 'INPROGRESS';
189 | };
190 |
191 | type CompletedState = {
192 | name: 'COMPLETED';
193 | result: {
194 | name: 'SUCCESSFUL' | 'FAILED' | 'STOPPED';
195 | };
196 | };
197 |
198 | interface PipelineBase {
199 | uuid: string;
200 | repository: { [key: string]: any };
201 | state: PendingState | InprogressState | CompletedState;
202 | build_number: string;
203 | creator: { [key: string]: any };
204 | created_on: string;
205 | completed_on?: string;
206 | target: PipelineTarget;
207 | trigger: any;
208 | run_number: number;
209 | duration_in_seconds: number;
210 | build_seconds_used: number;
211 | first_successful: boolean;
212 | expired: boolean;
213 | links: SelfLink & StepLink;
214 | has_variables: boolean;
215 | }
216 |
217 | interface InprogressPipeline extends PipelineBase {
218 | state: InprogressState;
219 | }
220 |
221 | interface CompletedPipeline extends PipelineBase {
222 | state: CompletedState;
223 | completed_on: string;
224 | }
225 |
226 | interface PendingPipeline extends PipelineBase {
227 | state: PendingState;
228 | }
229 |
230 | type Pipeline = InprogressPipeline | CompletedPipeline | PendingPipeline;
231 |
232 | type PaginatedResponse = {
233 | size: number;
234 | page: number;
235 | pagelen: number;
236 | // URI
237 | next?: string;
238 | // URI
239 | previous?: string;
240 | values: T[];
241 | };
242 |
243 | type QueryParams = {
244 | pagelen?: number;
245 | sort?: string;
246 | 'target.ref_name'?: string;
247 | 'target.ref_type'?: 'BRANCH';
248 | };
249 | }
250 |
--------------------------------------------------------------------------------
/src/static/current-state/components/tabs/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css } from 'emotion';
3 | import { Section } from '../Section';
4 | import { SystemTab } from './SystemTab';
5 | import { QueueTab } from './QueueTab';
6 | import { HistoryTab } from './HistoryTab';
7 | import { MergeSettings } from '../../../../types';
8 | import { MergingTab } from './MergingTab';
9 | import { Badge } from '../Badge';
10 |
11 | let controlsStyles = css({
12 | border: '1px solid var(--n20-color)',
13 | borderRadius: '2em',
14 | width: '408px',
15 | margin: '0 auto',
16 | display: 'flex',
17 | overflow: 'hidden',
18 | marginTop: '63px',
19 |
20 | '& button': {
21 | flexGrow: 1,
22 | fontSize: '1.14285714em',
23 | border: 'none',
24 | padding: '12px 0',
25 | background: 'transparent',
26 | color: 'var(--secondary-text-color)',
27 | margin: 0,
28 |
29 | '&.--selected': {
30 | background: 'var(--n20-color)',
31 | color: 'var(--n500-color)',
32 | },
33 |
34 | '&:hover, &.--selected:hover': {
35 | color: 'var(--n400-color)',
36 | background: 'rgba(9, 30, 66, 0.08)',
37 | cursor: 'pointer',
38 | },
39 |
40 | '&:focus': {
41 | outline: 'none',
42 | boxShadow: 'none',
43 | },
44 |
45 | '& + button': {
46 | borderLeft: '1px solid var(--n20-color)',
47 | },
48 | },
49 | });
50 |
51 | export type TabsControlsProps = {
52 | selected: number;
53 | merging: IStatusUpdate[];
54 | selectTab: (tab: number) => void;
55 | };
56 |
57 | const getMerging = (updates: IStatusUpdate[]) =>
58 | updates.filter(({ state }) => ['awaiting-merge', 'merging'].includes(state));
59 |
60 | const TabsControls: React.FunctionComponent = (props) => {
61 | const { selected, merging, selectTab } = props;
62 | return (
63 |
64 |
71 |
78 |
85 |
92 |
93 | );
94 | };
95 |
96 | export type TabsProps = {
97 | selected: number;
98 | users: UserState[];
99 | queue: IStatusUpdate[];
100 | bitbucketBaseUrl: string;
101 | loggedInUser: ISessionUser;
102 | paused: boolean;
103 | bannerMessageState: IMessageState | null;
104 | maxConcurrentBuilds: number;
105 | permissionsMessage: string;
106 | priorityBranchList: IPriorityBranch[];
107 | adminSettings: IAdminSettings;
108 | config: { mergeSettings?: MergeSettings; speculationEngineEnabled: boolean };
109 | refreshData: () => void;
110 | };
111 |
112 | type TabsState = {
113 | selected: number;
114 | };
115 |
116 | export class Tabs extends React.Component {
117 | constructor(props: TabsProps) {
118 | super(props);
119 |
120 | let selected = 1;
121 | const selectedItem = sessionStorage.getItem('selectedTab');
122 | if (selectedItem) {
123 | selected = parseInt(selectedItem);
124 | }
125 |
126 | this.state = {
127 | selected: isNaN(selected) || ![0, 1, 2].includes(selected) ? 1 : selected,
128 | };
129 | }
130 |
131 | private onTabSelected = (selected: number) => {
132 | this.setState({ selected });
133 | sessionStorage.setItem('selectedTab', selected.toString());
134 | };
135 |
136 | render() {
137 | const { selected } = this.state;
138 | const {
139 | users,
140 | bitbucketBaseUrl,
141 | queue,
142 | loggedInUser,
143 | paused,
144 | bannerMessageState,
145 | maxConcurrentBuilds,
146 | permissionsMessage,
147 | priorityBranchList,
148 | adminSettings,
149 | config,
150 | refreshData,
151 | } = this.props;
152 |
153 | return (
154 |
155 |
160 | {selected === 0 ? (
161 |
172 | ) : null}
173 | {selected === 1 ? (
174 |
181 | ) : null}
182 | {selected === 2 ? (
183 |
190 | ) : null}
191 | {selected === 3 ? (
192 |
197 | ) : null}
198 |
199 | );
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## ⚠️ Archived Repository
2 |
3 | This repository has ceased active maintenance and will no longer accept any additional pull requests.
4 | # Landkid
5 |
6 | [](https://circleci.com/gh/atlassian/landkid/tree/master)
7 |
8 | > Your friendly neighborhood async merging robot G.O.A.T.
9 |
10 | Anyone who has worked in a large repo with many other developers, especially in
11 | a CI/CD environment will know the pains of merging. What might be green in a
12 | branch might suddenly break master and everyone else who is rebased from it!
13 | Nobody wants to be the person to blame for that and nobody wants to be tracking
14 | down which strange combinations of changes suddenly broke things.
15 |
16 | That's why we've made Landkid! A self-hosted service that can manage your merges
17 | so that your master builds stay green and your devs stay happy!
18 |
19 | ### So how does it work?
20 |
21 | It really is quite simple. The key to keeping master green is simple:
22 |
23 | - Make sure all branch builds are run whilst rebased against latest master
24 | before merging
25 | - Make sure only one build can be being merged at a time
26 |
27 | That's it!
28 |
29 | Landkid simply provides a simple queue and a way to call a custom build that
30 | runs with master. Instead of hitting "merge", you'll get landkid to add you to a
31 | queue which runs one sanity check build (the equivalent of the same build that
32 | would have run, had your merge happened) and if it goes green, is merged
33 | automatically!
34 |
35 | #### Supported Hosts
36 |
37 | - [x] Bitbucket
38 | - [ ] GitHub (Help Wanted!)
39 |
40 | #### Supported CI Services
41 |
42 | - [x] Bitbucket Pipelines
43 | - [ ] TravisCI (Help Wanted!)
44 | - [ ] CircleCI (Help Wanted!)
45 |
46 | #### Metrics
47 |
48 | Landkid emits events for metrics integrations:
49 |
50 | - 'STARTUP'
51 | - 'PULL_REQUEST.MERGE.SUCCESS'
52 | - 'PULL_REQUEST.MERGE.FAIL'
53 | - 'GET_STATE.SUCCESS'
54 | - 'GET_STATE.FAIL'
55 | - 'PULL_REQUEST.QUEUE.SUCCESS'
56 | - 'PULL_REQUEST.QUEUE.FAIL'
57 | - 'PULL_REQUEST.QUEUE_WHEN_ABLE.SUCCESS'
58 | - 'PULL_REQUEST.QUEUE_WHEN_ABLE.FAIL'
59 | - 'LAND_REQUEST.STATUS.CHANGED'
60 | - 'QUEUE.MAX_DEPENDENCIES'
61 |
62 | Add event listeners to `config.js` to use these events.
63 |
64 | ### Setting up your own Landkid server (Bitbucket)
65 |
66 | WIP
67 |
68 | ### Why can master break when branch builds are green?
69 |
70 | ### Branches being behind master
71 |
72 | By far, the number one root cause is a branch not being up to date with latest
73 | master when it is merged. This could mean it is depending on code that is no
74 | longer there, it reintroduces patterns that have been refactored, it adds code
75 | that fails linting rules that were changed, etc. There are tons of ways this can
76 | break, but all from the same root cause.
77 |
78 | > Can't we just always rebase branch builds on master then?
79 |
80 | Short answer: no.
81 |
82 | Longer answer:
83 |
84 | The problem is threefold:
85 |
86 | 1. What is to stop someone from running a branch build but not merging it for 24
87 | hours?
88 |
89 | In that time, more commits will be in master and your build will still be
90 | green!
91 |
92 | 2. What happens if master does actually break now? Not only is master + all of
93 | the latest branches broken, but every single branch build! Until it is fixed
94 | in master!
95 |
96 | 3. This also makes it so that the code that runs in your branch builds doesn't
97 | match the code that runs in your local machine. If a test passes locally but
98 | not in CI, it adds an extra layer of unknowns of what might have gone wrong.
99 |
100 | > Okay, so what if we just added a check that makes sure you can only merge when
101 | > you are already rebased on master?
102 |
103 | Sure, that would solve the problem.
104 |
105 | Except now you are moving all the responsibility to keep things up to date on the
106 | devs.
107 |
108 | It would create an endless cycle of approvals, rebases, merge conflicts,
109 | approvals, rebases, etc.
110 |
111 | ### Simultaneous Releases
112 |
113 | CI/CD builds suffer from even more complexity with merges. Consider a monorepo's
114 | build that looks something like this:
115 |
116 | ```yml
117 | - ./someSetupScript.sh
118 | # find which packages have changes (probably using git)
119 | - export CHANGED_PACKAGES=`./calculateChangedPackages.sh`
120 | - yarn run build
121 | - yarn run lint
122 | - yarn run test
123 | - ./bumpPackagesVersionsAndPushToMaster.sh
124 | - yarn run release
125 | ```
126 |
127 | Imagine we have two branches both trying to perform a minor release of
128 | package-A. If both are merged close together then both are going to try to
129 | release the same version of package-A with different changes!
130 |
131 | > So, can't we just have a check at the beginning of a master build that
132 | > automatically stops if there is one currently running?
133 |
134 | Sure, you could. but take a look below at the next problem.
135 |
136 | ### Changes in master whilst a current build is already happening
137 |
138 | Closely related to `Simultaneous Releases`, but not entirely restricted to
139 | CI/CD. Consider the build above, the `bumpPackagesVersionsAndPushToMaster.sh`
140 | script might look something like this:
141 |
142 | ```sh
143 | # bumpPackagesVersionsAndPushToMaster.sh
144 | ./bumpPackageVersions.sh --changed="$CHANGED_PACKAGES"
145 | ./updateChangelogs.sh --changed="$CHANGED_PACKAGES"
146 | git add packages/*/package.json
147 | git add packages/*/CHANGELOG.md
148 | git commit -m "Releasing; $CHANGED_PACKAGES"
149 | git push origin master
150 | ```
151 |
152 | Immediately, there is an obvious problem on the last line. What if someone has
153 | pushed to master whilst our build was still running? That push will fail because
154 | we cant make a fast-forward push!
155 |
156 | > Surely we can just rebase on latest master before pushing then, right?
157 |
158 | Unfortunately no!
159 |
160 | Take another look at the last line of our monorepos master build
161 |
162 | ```
163 | - yarn run release
164 | ```
165 |
166 | That's about to package our code up and push to npm. What about all the new
167 | changes we've just pulled into our build when we rebased? Did we test them? Lint
168 | them? **BUILD** them?
169 |
170 | You can keep going deeper, trying to add rebases, and checkouts, or staging
171 | branches, but you will inevitably wind up with the same solution, or another
172 | hidden caveat somewhere.
173 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # CONTRIBUTING
2 |
3 | ## Getting Started
4 |
5 | This is copied from `development-local-github.md` in the atlassian frontend landkid deployment repo with atlassian specific info stripped out.
6 |
7 | ### Setting up a repo
8 |
9 | First you'll need to have a repo to test in that is under your name. So [create a repo in Bitbucket](https://bitbucket.org/repo/create). Ensure the owner is set to yourself. Use any Repository name you like. It can be private if you prefer. Select "Include a README Yes" and hit Create Repository.
10 |
11 | Now you'll need to set up Pipelines. On the sidebar you should see a `Pipelines` button, click that. It will ask which kind of build you wish to create, select `Javascript`. It should have a sample config in front of you. Replace it with:
12 |
13 | ```yml
14 | image: node:18
15 |
16 | pipelines:
17 | default: # This is the default branch build
18 | - step:
19 | script:
20 | - echo "Default pipelines script"
21 | - sleep 5
22 |
23 | custom: # This is the landkid build we will run
24 | landkid:
25 | - step:
26 | script:
27 | - echo "This is a landkid build"
28 | - sleep 5
29 | ```
30 |
31 | Confirm that and you should have Pipelines running.
32 |
33 | ### Set up a public tunnel
34 |
35 | See the atlassian frontend landkid deployment repo docs for more info.
36 |
37 | ### Creating oauth keys
38 |
39 | Next you'll need to create an Oauth token and key for your local landkid. These are used to register your local landkid as an Oauth consumer (this lets your app ask users for their usernames when they login).
40 |
41 | Head to your personal workspace by clicking your profile icon and selecting the [workspace with your name](https://bitbucket.org/[username]/). Go to the workspace [settings](https://bitbucket.org/[username]/workspace/settings) and move to the "OAuth consumers" tab. Under "OAuth consumers" click "Add consumer".
42 |
43 | Fill in a Name (e.g "Landkid local"). Fill in the callback url using your tunnel url from above with `/auth/callback` on the end (e.g `https:///auth/callback`). Make sure "This is a private consumer" is selected. And select the `Account Read` permissions. Everything else can be skipped.
44 |
45 | This will give you a `Key` and `Secret` which you'll need in a moment.
46 |
47 | ### Creating your config
48 |
49 | Next copy and paste the `example.config.js` file in your checked out https://github.com/atlassian/landkid repo and name it `config.js`. This will be your local landkid config and is gitignored. Make sure this is **never** checked in, because it will contain your OAuth key and secret!
50 |
51 | Next we need to get a couple more pieces of information.
52 |
53 | The easiest way to get your UUID and the repo UUID is:
54 |
55 | 1. Browse to your test repo -> Repository settings
56 | 2. Click 'OpenID Connect' under Pipelines in the sidebar
57 | 3. Repository UUID will be displayed. If the test repo is under your personal workspace, then the Workspace UUID will be your own UUID.
58 | 4. If the test repo is not under your workspace. Repeat the same steps but with a repository under your workspace to grab your own UUID.
59 |
60 | Now we can edit the config. Change the following fields:
61 |
62 | - `baseUrl` - change to your tunnel url (e.g `https://`)
63 | - `landkidAdmins` - change this to an array that contains your uuid. These people will get admin status as soon as they login to your landkid instance
64 | - `repoConfig` - set owner and name to your name and repo respectively. Add in your repo uuid from earlier (this just makes start up be slightly faster because we don't need to do the extra lookup)
65 | - `deployment.oAuth` - set the key and secret from earlier (you can leave everything else there)
66 |
67 | That's it!
68 |
69 | ### Setting up databases
70 |
71 | Landkid uses two data bases, a redis and postgres. In local development, I find it's easier to _not_ fiddle around with the postgres database, so you can actually not connect that up and landkid will use a local sqllite database instead. It's not _exactly_ the same as whats running in production, but I find it easier to use personally.
72 |
73 | To get both databases running you should just be able to run
74 |
75 | ```
76 | docker-compose up
77 | ```
78 |
79 | and that will have both running for you (though we wont use the postgres one for now).
80 |
81 | I'd also reccomend installing [DBBrowser for SQLite](https://sqlitebrowser.org/) to help to look at your local sqllite database.
82 |
83 | By default, landkid will use the sqlite database. To make it use postgres, add the following config entry to your config.js:
84 |
85 | ```
86 | sequelize: {
87 | database: 'postgres',
88 | dialect: 'postgres',
89 | username: 'postgres',
90 | password: process.env.PG_DB_PASSWORD,
91 | host: '0.0.0.0',
92 | port: '5434',
93 | },
94 | ```
95 |
96 | ### Running Landkid
97 |
98 | All that's left now is to run `Landkid`.
99 |
100 | ```
101 | yarn dev
102 | ```
103 |
104 | This is spinning up two servers, a node express server for the backend and a webpack-dev-server for the front end (running on ports 8080 and 3000 respectively). You'll always talk directly to the frontend though as it will proxy any backend requests that need proxying.
105 |
106 | You should now be able to head to your tunnel url and be able to see Landkid running! When your first open it you'll be asked to authorize your OAuth app you created earlier, then you'll be in. If you look at the `System` tab, you should see yourself as an admin user.
107 |
108 | You can run landkid with different ports for the `backend server` and the `webpack-dev-server` using the `SERVER_PORT` and `DEV_SERVER_PORT` variables respectively.
109 |
110 | ```
111 | SERVER_PORT="8081" DEV_SERVER_PORT="9001" yarn dev
112 | # can now access the application on port 9001
113 | ```
114 |
115 | ### Installing your local landkid addon
116 |
117 | Now you'll need to install your landkid addon into bitbucket. Note: landkid is an `account` addon, that means it installed under a users accounts and affects all of their repo's they own and will be visible to all users who view it. For that reason you can only install landkid for a repo you control.
118 |
119 | First you might like to familiarise yourself with Landkid's `atlassian-connect.json` file by heading to `https:///ac`. This is the metadata we are sending to bitbucket to install our adddon.
120 |
121 | Head to you Bitbucket Settings and go to [Installed Apps](https://bitbucket.org/account/user/[your_username]/addon-management).
122 |
123 | Now click `Install app from URL` and paste your url from above and click install. You should get an authorization dialog and then that's it.
124 |
125 | You should now be able to go to a Pull Request in your repo (I usually use the BB ui to edit the readme file and use that to create a PR) and you should see your addon running. You'll be able to approve your own PRs when running a local instance to make testing easier.
126 |
--------------------------------------------------------------------------------
/src/bitbucket/BitbucketPipelinesAPI.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { fromMethodAndPathAndBody, fromMethodAndUrl } from 'atlassian-jwt';
3 | import pRetry from 'p-retry';
4 |
5 | import { Logger } from '../lib/Logger';
6 | import { RepoConfig } from '../types';
7 | import { bitbucketAuthenticator, axiosPostConfig } from './BitbucketAuthenticator';
8 |
9 | const baseApiUrl = 'https://api.bitbucket.org/2.0/repositories';
10 |
11 | interface PipelinesStatusEvent {
12 | commit_status: {
13 | state: BB.BuildState;
14 | url: string;
15 | };
16 | }
17 |
18 | export interface PipelinesVariables {
19 | dependencyCommits: string[];
20 | targetBranch: string;
21 | }
22 |
23 | export class BitbucketPipelinesAPI {
24 | private apiBaseUrl = `${baseApiUrl}/${this.config.repoOwner}/${this.config.repoName}`;
25 |
26 | constructor(private config: RepoConfig) {}
27 |
28 | public processStatusWebhook = (body: any): BB.BuildStatusEvent | null => {
29 | // Sometimes the events are wrapped in an extra body layer. We don't know why,
30 | // so we'll just guard for it here
31 | const statusEvent: PipelinesStatusEvent = body && body.data ? body.data : body;
32 | if (
33 | !statusEvent ||
34 | !statusEvent.commit_status ||
35 | !statusEvent.commit_status.state ||
36 | !statusEvent.commit_status.url ||
37 | typeof statusEvent.commit_status.url !== 'string'
38 | ) {
39 | Logger.error('Status event receieved that does not match the shape we were expecting', {
40 | namespace: 'bitbucket:pipelines:processStatusWebhook',
41 | statusEvent: body,
42 | });
43 | return null;
44 | }
45 | const buildStatus = statusEvent.commit_status.state;
46 | const buildUrl = statusEvent.commit_status.url;
47 | Logger.info('Received build status event', {
48 | namespace: 'bitbucket:pipelines:processStatusWebhook',
49 | buildUrl,
50 | buildStatus,
51 | });
52 |
53 | // Status webhooks dont give you build uuid's or even build numbers. We need to get from url
54 | const buildUrlParts = buildUrl.split('/');
55 | const buildId = parseInt(buildUrlParts[buildUrlParts.length - 1], 10);
56 |
57 | return {
58 | buildId,
59 | buildStatus,
60 | };
61 | };
62 |
63 | public createLandBuild = async (
64 | requestId: string,
65 | commit: string,
66 | { dependencyCommits, targetBranch }: PipelinesVariables,
67 | lockId: Date,
68 | ) => {
69 | const depCommitsStr = JSON.stringify(dependencyCommits);
70 | Logger.info('Creating land build for commit', {
71 | namespace: 'bitbucket:pipelines:createLandBuild',
72 | landRequestId: requestId,
73 | commit,
74 | depCommitsStr,
75 | lockId,
76 | });
77 | const data = {
78 | target: {
79 | commit: { hash: commit, type: 'commit' },
80 | selector: { type: 'custom', pattern: 'landkid' },
81 | type: 'pipeline_commit_target',
82 | },
83 | variables: [
84 | {
85 | key: 'LANDKID_DEPENDENCY_COMMITS',
86 | value: depCommitsStr,
87 | },
88 | {
89 | key: 'TARGET_BRANCH',
90 | value: targetBranch,
91 | },
92 | ],
93 | };
94 | const endpoint = `${this.apiBaseUrl}/pipelines/`;
95 | const resp = await axios.post(
96 | endpoint,
97 | JSON.stringify(data),
98 | await bitbucketAuthenticator.getAuthConfig(
99 | fromMethodAndPathAndBody('post', endpoint, data),
100 | axiosPostConfig,
101 | ),
102 | );
103 | if (!resp.data.build_number || typeof resp.data.build_number !== 'number') {
104 | Logger.error('Response from creating build does not match the shape we expected', {
105 | namespace: 'bitbucket:pipelines:createLandBuild',
106 | landRequestId: requestId,
107 | commit,
108 | lockId,
109 | });
110 | return null;
111 | }
112 | Logger.info('Created build', {
113 | namespace: 'bitbucket:pipelines:createLandBuild',
114 | landRequestId: requestId,
115 | commit,
116 | buildNumber: resp.data.build_number,
117 | lockId,
118 | });
119 | // build_number comes back as a number unfortunately
120 | return resp.data.build_number as number;
121 | };
122 |
123 | public stopLandBuild = async (buildId: number, lockId?: Date) => {
124 | Logger.info('Stopping land build with id', {
125 | namespace: 'bitbucket:pipelines:stopLandBuild',
126 | buildId,
127 | lockId,
128 | });
129 | const endpoint = `${this.apiBaseUrl}/pipelines/${buildId}/stopPipeline`;
130 | try {
131 | await axios.post(
132 | endpoint,
133 | null,
134 | await bitbucketAuthenticator.getAuthConfig(
135 | fromMethodAndUrl('post', endpoint),
136 | axiosPostConfig,
137 | ),
138 | );
139 | } catch (err) {
140 | Logger.info('Build could not be cancelled', {
141 | namespace: 'bitbucket:pipelines:stopLandBuild',
142 | buildId,
143 | lockId,
144 | error: err,
145 | });
146 | return false;
147 | }
148 | return true;
149 | };
150 |
151 | public getPipelines = async (queryParams: BB.QueryParams = {}, numRetries = 3) => {
152 | const endpoint = `${this.apiBaseUrl}/pipelines/?${+new Date()}`;
153 | const paramsWithAuth = await bitbucketAuthenticator.getAuthConfig(
154 | fromMethodAndUrl('get', endpoint),
155 | {
156 | params: queryParams,
157 | },
158 | );
159 | async function fetchPipelines() {
160 | const response = await axios.get>(endpoint, paramsWithAuth);
161 | return response?.data;
162 | }
163 |
164 | const data = await pRetry(fetchPipelines, {
165 | onFailedAttempt: (error) => {
166 | const anyError: any = error;
167 | Logger.error('Error fetching pipelines', {
168 | namespace: 'bitbucket:pipelines:getPipelines',
169 | attemptNumber: error.attemptNumber,
170 | retriesLeft: error.retriesLeft,
171 | queryParams,
172 | errorString: String(error),
173 | errorStack: String(error.stack),
174 | error: anyError.response ? [anyError.response.status, anyError.response.data] : null,
175 | });
176 | },
177 | retries: numRetries,
178 | });
179 |
180 | Logger.info('Successfully fetched pipelines', {
181 | namespace: 'bitbucket:pipelines:getPipelines',
182 | queryParams,
183 | });
184 |
185 | return data;
186 | };
187 |
188 | getLandBuild = async (buildId: Number): Promise => {
189 | const endpoint = `${this.apiBaseUrl}/pipelines/${buildId}`;
190 | const { data } = await axios.get(
191 | endpoint,
192 | await bitbucketAuthenticator.getAuthConfig(fromMethodAndUrl('get', endpoint)),
193 | );
194 |
195 | Logger.info('Successfully loaded land build data', {
196 | namespace: 'bitbucket:api:getLandBuild',
197 | state: data.state,
198 | buildId,
199 | });
200 |
201 | return data;
202 | };
203 | }
204 |
--------------------------------------------------------------------------------
/src/bitbucket/BitbucketClient.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { Config, MergeOptions } from '../types';
3 | import { Logger } from '../lib/Logger';
4 | import { BitbucketPipelinesAPI, PipelinesVariables } from './BitbucketPipelinesAPI';
5 | import { BitbucketAPI } from './BitbucketAPI';
6 | import { LandRequestStatus } from '../db';
7 | import pLimit from 'p-limit';
8 |
9 | // Given a list of approvals, will filter out users own approvals if settings don't allow that
10 | function getRealApprovals(approvals: Array, creator: string, creatorCanApprove: boolean) {
11 | if (creatorCanApprove) return approvals;
12 | return approvals.filter((approval) => approval !== creator);
13 | }
14 |
15 | export class BitbucketClient {
16 | public bitbucket = new BitbucketAPI(this.config.repoConfig);
17 | private pipelines = new BitbucketPipelinesAPI(this.config.repoConfig);
18 |
19 | constructor(private config: Config) {}
20 |
21 | async isAllowedToMerge({
22 | pullRequestId,
23 | permissionLevel,
24 | sourceBranch,
25 | destinationBranch,
26 | }: {
27 | pullRequestId: number;
28 | permissionLevel: IPermissionMode;
29 | sourceBranch: string;
30 | destinationBranch: string;
31 | }) {
32 | const [pullRequest, buildStatuses, hasConflicts] = await Promise.all([
33 | this.bitbucket.getPullRequest(pullRequestId),
34 | this.bitbucket.getPullRequestBuildStatuses(pullRequestId),
35 | this.bitbucket.pullRequestHasConflicts(sourceBranch, destinationBranch),
36 | ]);
37 |
38 | const author = pullRequest.author;
39 | const approvals = getRealApprovals(
40 | pullRequest.approvals,
41 | author,
42 | this.config.prSettings.canApproveOwnPullRequest,
43 | );
44 |
45 | const approvalChecks = {
46 | isOpen: pullRequest.state === 'OPEN',
47 | isGreen:
48 | buildStatuses.every((status) => status.state === 'SUCCESSFUL') && buildStatuses.length > 0,
49 | allTasksClosed: pullRequest.openTasks === 0,
50 | isApproved: approvals.length >= this.config.prSettings.requiredApprovals,
51 | };
52 |
53 | const { prSettings } = this.config;
54 | const MAX_CONCURRENT_CHECKS_LIMIT = 5;
55 | const limit = pLimit(MAX_CONCURRENT_CHECKS_LIMIT);
56 |
57 | const errors: string[] = [];
58 | const warnings: string[] = [];
59 |
60 | Logger.info('Pull request approval checks', {
61 | namespace: 'bitbucket:client:isAllowedToLand',
62 | pullRequestId,
63 | approvalChecks,
64 | requirements: prSettings,
65 | });
66 |
67 | if (hasConflicts) {
68 | errors.push('Pull request must not have any conflicts');
69 | }
70 |
71 | if (prSettings.requireClosedTasks && !approvalChecks.allTasksClosed) {
72 | errors.push('All tasks must be resolved');
73 | }
74 |
75 | if (prSettings.requiredApprovals && !approvalChecks.isApproved) {
76 | errors.push('Must be approved');
77 | }
78 |
79 | if (prSettings.requireGreenBuild && !approvalChecks.isGreen) {
80 | errors.push('Must have a successful build');
81 | }
82 |
83 | if (!approvalChecks.isOpen) {
84 | errors.push('Pull request is already closed');
85 | } else {
86 | const pullRequestInfo = {
87 | pullRequest,
88 | buildStatuses,
89 | approvals,
90 | permissionLevel,
91 | };
92 | const errorsAndWarningsPromises: Promise[] = [];
93 |
94 | if (prSettings.customChecks) {
95 | prSettings.customChecks.forEach(({ rule }) => {
96 | errorsAndWarningsPromises.push(
97 | limit(async () => {
98 | const passesRule = await rule(pullRequestInfo, { axios, Logger });
99 | if (typeof passesRule === 'string') errors.push(passesRule);
100 | }),
101 | );
102 | });
103 | }
104 | if (prSettings.customWarnings) {
105 | prSettings.customWarnings.forEach(({ rule }) => {
106 | errorsAndWarningsPromises.push(
107 | limit(async () => {
108 | const passesWarning = await rule(pullRequestInfo, { axios, Logger });
109 | if (typeof passesWarning === 'string') warnings.push(passesWarning);
110 | }),
111 | );
112 | });
113 | }
114 |
115 | await Promise.all(errorsAndWarningsPromises);
116 | }
117 |
118 | return {
119 | ...approvalChecks,
120 | errors,
121 | warnings,
122 | pullRequest,
123 | };
124 | }
125 |
126 | createLandBuild(requestId: string, commit: string, variables: PipelinesVariables, lockId: Date) {
127 | return this.pipelines.createLandBuild(requestId, commit, variables, lockId);
128 | }
129 |
130 | stopLandBuild(buildId: number, lockId?: Date) {
131 | return this.pipelines.stopLandBuild(buildId, lockId);
132 | }
133 |
134 | getLandBuild(buildId: number) {
135 | return this.pipelines.getLandBuild(buildId);
136 | }
137 |
138 | mergePullRequest(landRequestStatus: LandRequestStatus, options?: MergeOptions) {
139 | return this.bitbucket.mergePullRequest(landRequestStatus, options);
140 | }
141 |
142 | cancelMergePolling(prId: number) {
143 | return this.bitbucket.cancelMergePolling(prId);
144 | }
145 |
146 | processStatusWebhook(body: any): BB.BuildStatusEvent | null {
147 | return this.pipelines.processStatusWebhook(body);
148 | }
149 |
150 | async getRepoUuid(): Promise {
151 | const repo = await this.bitbucket.getRepository();
152 |
153 | return repo.uuid;
154 | }
155 |
156 | getUser(aaid: string): Promise {
157 | return this.bitbucket.getUser(aaid);
158 | }
159 |
160 | async isBlockingBuildRunning(targetBranch: string): Promise<{
161 | running: boolean;
162 | pipelines?: BB.Pipeline[];
163 | }> {
164 | const notRunning = {
165 | running: false,
166 | };
167 | const mergeBlockingConfig = this.config.mergeSettings?.mergeBlocking;
168 | if (!mergeBlockingConfig?.enabled) {
169 | Logger.error('Attempting to check merge blocking build with disabled config', {
170 | targetBranch,
171 | });
172 | return notRunning;
173 | }
174 |
175 | const blockingBuildConfig = mergeBlockingConfig.builds.find(
176 | (buildConfig) => buildConfig.targetBranch === targetBranch,
177 | );
178 | if (!blockingBuildConfig) {
179 | Logger.info('No blocking build configured for target branch', {
180 | targetBranch,
181 | });
182 | return notRunning;
183 | }
184 |
185 | const pipelinesResult = await this.pipelines.getPipelines({
186 | // Fetching last 30 builds should be more than sufficient for finding the latest in-progress build
187 | pagelen: 30,
188 | // get the most recent builds first
189 | sort: '-created_on',
190 | 'target.ref_name': blockingBuildConfig.targetBranch,
191 | 'target.ref_type': 'BRANCH',
192 | });
193 |
194 | const blockingBuild = blockingBuildConfig.pipelineFilterFn(pipelinesResult.values);
195 | if (blockingBuild.length === 0) {
196 | return notRunning;
197 | } else {
198 | return {
199 | running: true,
200 | pipelines: blockingBuild,
201 | };
202 | }
203 | }
204 | }
205 |
--------------------------------------------------------------------------------