Skip to content
This repository was archived by the owner on Sep 13, 2023. It is now read-only.

Commit dbd00b3

Browse files
Wait for master (#215)
* Add initial implementation of blocking target branch build functionality * Remove raw error logging to prevent auth header leakage * Cache the blocking build pipeline API calls * Merge blocking build changes - Updated admin workflow to enable feature toggling - Minor UI changes - Added unit tests * [chore] Addressed review comments --------- Co-authored-by: Michael Blaszczyk <[email protected]> Co-authored-by: Grace <[email protected]>
1 parent faed840 commit dbd00b3

27 files changed

+891
-48
lines changed

config.example.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,29 @@ module.exports = {
5252
*/
5353
},
5454
mergeSettings: {
55-
skipBuildOnDependentsAwaitingMerge: false,
55+
skipBuildOnDependentsAwaitingMerge: true,
56+
mergeBlocking: {
57+
enabled: false,
58+
builds: [
59+
{
60+
targetBranch: 'master',
61+
pipelineFilterFn: (pipelines) => {
62+
return (
63+
pipelines
64+
.filter(
65+
(pipeline) =>
66+
pipeline.state.name === 'IN_PROGRESS' || pipeline.state.name === 'PENDING',
67+
)
68+
// Filter to only default builds run on 'push'.
69+
// Allow manual trigger of default builds but exclude custom builds that are triggered manually
70+
.filter(
71+
(job) => job.trigger.name !== 'SCHEDULE' && job.target.selector.type !== 'custom',
72+
)
73+
);
74+
},
75+
},
76+
],
77+
},
5678
},
5779
eventListeners: [
5880
{

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,11 @@
106106
"express": "^4.16.2",
107107
"express-session": "^1.17.1",
108108
"express-winston": "^3.0.1",
109+
"mem": "^8",
109110
"micromatch": "^4.0.2",
110111
"mime": "^3.0.0",
111112
"p-limit": "^3.1.0",
113+
"p-retry": "^4",
112114
"passport": "^0.4.1",
113115
"passport-http": "^0.3.0",
114116
"passport-oauth2": "^1.5.0",

src/bitbucket/BitbucketClient.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,49 @@ export class BitbucketClient {
156156
getUser(aaid: string): Promise<ISessionUser> {
157157
return this.bitbucket.getUser(aaid);
158158
}
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+
}
159204
}

src/bitbucket/BitbucketPipelinesAPI.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import axios from 'axios';
22
import { fromMethodAndPathAndBody, fromMethodAndUrl } from 'atlassian-jwt';
3+
import pRetry from 'p-retry';
34

45
import { Logger } from '../lib/Logger';
56
import { RepoConfig } from '../types';
@@ -147,6 +148,43 @@ export class BitbucketPipelinesAPI {
147148
return true;
148149
};
149150

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<BB.PaginatedResponse<BB.Pipeline>>(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+
150188
getLandBuild = async (buildId: Number): Promise<BB.Pipeline> => {
151189
const endpoint = `${this.apiBaseUrl}/pipelines/${buildId}`;
152190
const { data } = await axios.get<BB.Pipeline>(
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
});

src/bitbucket/__tests__/BitbucketPipelinesAPI.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import axios from 'axios';
22
import { BitbucketPipelinesAPI } from '../BitbucketPipelinesAPI';
33
import { bitbucketAuthenticator } from '../BitbucketAuthenticator';
4+
import { Logger } from '../../lib/Logger';
45

56
jest.mock('axios');
67
const mockedAxios = axios as unknown as jest.Mocked<typeof axios>;
@@ -34,4 +35,43 @@ describe('BitbucketPipelinesAPI', () => {
3435
{},
3536
);
3637
});
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+
});
3777
});

src/bitbucket/types.d.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,94 @@ declare namespace BB {
151151
url: string;
152152
};
153153

154-
type Pipeline = {
155-
state: { result: { name: BuildState } };
154+
interface PipelineTarget {
155+
type: 'pipeline_commit_target' | 'pipeline_ref_target' | 'pipeline_pullrequest_target';
156+
selector:
157+
| { type: 'default' }
158+
| {
159+
type: 'custom';
160+
pattern: string;
161+
}
162+
| {
163+
type: 'branches';
164+
pattern: string;
165+
}
166+
| {
167+
type: 'pull-requests';
168+
pattern: string;
169+
};
170+
commit: {
171+
type: 'commit';
172+
hash: string;
173+
};
174+
ref_type?: string;
175+
ref_name?: string;
176+
}
177+
178+
type PendingState = {
179+
name: 'PENDING';
180+
};
181+
182+
type InprogressState = {
183+
name: 'INPROGRESS';
184+
};
185+
186+
type CompletedState = {
187+
name: 'COMPLETED';
188+
result: {
189+
name: 'SUCCESSFUL' | 'FAILED' | 'STOPPED';
190+
};
191+
};
192+
193+
interface PipelineBase {
194+
uuid: string;
195+
repository: { [key: string]: any };
196+
state: PendingState | InprogressState | CompletedState;
197+
build_number: string;
198+
creator: { [key: string]: any };
199+
created_on: string;
200+
completed_on?: string;
201+
target: PipelineTarget;
202+
trigger: any;
203+
run_number: number;
204+
duration_in_seconds: number;
205+
build_seconds_used: number;
206+
first_successful: boolean;
207+
expired: boolean;
208+
links: SelfLink & StepLink;
209+
has_variables: boolean;
210+
}
211+
212+
interface InprogressPipeline extends PipelineBase {
213+
state: InprogressState;
214+
}
215+
216+
interface CompletedPipeline extends PipelineBase {
217+
state: CompletedState;
218+
completed_on: string;
219+
}
220+
221+
interface PendingPipeline extends PipelineBase {
222+
state: PendingState;
223+
}
224+
225+
type Pipeline = InprogressPipeline | CompletedPipeline | PendingPipeline;
226+
227+
type PaginatedResponse<T> = {
228+
size: number;
229+
page: number;
230+
pagelen: number;
231+
// URI
232+
next?: string;
233+
// URI
234+
previous?: string;
235+
values: T[];
236+
};
237+
238+
type QueryParams = {
239+
pagelen?: number;
240+
sort?: string;
241+
'target.ref_name'?: string;
242+
'target.ref_type'?: 'BRANCH';
156243
};
157244
}

src/db/__mocks__/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@ export const PauseState = MockedDb.PauseState;
3434
export const BannerMessageState = MockedDb.BannerMessageState;
3535
export const ConcurrentBuildState = MockedDb.ConcurrentBuildState;
3636
export const PriorityBranch = MockedDb.PriorityBranch;
37+
export const AdminSettings = MockedDb.AdminSettings;
3738
export const initializeSequelize = MockedDb.initializeSequelize;

0 commit comments

Comments
 (0)