Skip to content
Snippets Groups Projects
Commit 5404e726 authored by Michael Kriese's avatar Michael Kriese Committed by Rhys Arkins
Browse files

feat(bitbucket): add missing features (#4110)

parent 1207152c
No related branches found
No related tags found
No related merge requests found
import { logger } from '../../logger';
import { Config, accumulateValues } from './utils';
import { api } from './bb-got-wrapper';
interface Comment {
content: { raw: string };
id: number;
}
export type CommentsConfig = Pick<Config, 'repository'>;
async function getComments(config: CommentsConfig, prNo: number) {
const comments = await accumulateValues<Comment>(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/comments`
);
logger.debug(`Found ${comments.length} comments`);
return comments;
}
async function addComment(config: CommentsConfig, prNo: number, raw: string) {
await api.post(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/comments`,
{
body: { content: { raw } },
}
);
}
async function editComment(
config: CommentsConfig,
prNo: number,
commentId: number,
raw: string
) {
await api.put(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/comments/${commentId}`,
{
body: { content: { raw } },
}
);
}
async function deleteComment(
config: CommentsConfig,
prNo: number,
commentId: number
) {
await api.delete(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/comments/${commentId}`
);
}
export async function ensureComment(
config: CommentsConfig,
prNo: number,
topic: string | null,
content: string
) {
try {
const comments = await getComments(config, prNo);
let body: string;
let commentId: number | undefined;
let commentNeedsUpdating: boolean | undefined;
if (topic) {
logger.debug(`Ensuring comment "${topic}" in #${prNo}`);
body = `### ${topic}\n\n${content}`;
comments.forEach(comment => {
if (comment.content.raw.startsWith(`### ${topic}\n\n`)) {
commentId = comment.id;
commentNeedsUpdating = comment.content.raw !== body;
}
});
} else {
logger.debug(`Ensuring content-only comment in #${prNo}`);
body = `${content}`;
comments.forEach(comment => {
if (comment.content.raw === body) {
commentId = comment.id;
commentNeedsUpdating = false;
}
});
}
if (!commentId) {
await addComment(config, prNo, body);
logger.info({ repository: config.repository, prNo }, 'Comment added');
} else if (commentNeedsUpdating) {
await editComment(config, prNo, commentId, body);
logger.info({ repository: config.repository, prNo }, 'Comment updated');
} else {
logger.debug('Comment is already update-to-date');
}
return true;
} catch (err) /* istanbul ignore next */ {
logger.warn({ err }, 'Error ensuring comment');
return false;
}
}
export async function ensureCommentRemoval(
config: CommentsConfig,
prNo: number,
topic: string
) {
try {
logger.debug(`Ensuring comment "${topic}" in #${prNo} is removed`);
const comments = await getComments(config, prNo);
let commentId;
comments.forEach(comment => {
if (comment.content.raw.startsWith(`### ${topic}\n\n`)) {
commentId = comment.id;
}
});
if (commentId) {
await deleteComment(config, prNo, commentId);
}
} catch (err) /* istanbul ignore next */ {
logger.warn({ err }, 'Error ensuring comment removal');
}
}
...@@ -6,20 +6,9 @@ import { logger } from '../../logger'; ...@@ -6,20 +6,9 @@ import { logger } from '../../logger';
import GitStorage from '../git/storage'; import GitStorage from '../git/storage';
import { readOnlyIssueBody } from '../utils/read-only-issue-body'; import { readOnlyIssueBody } from '../utils/read-only-issue-body';
import { appSlug } from '../../config/app-strings'; import { appSlug } from '../../config/app-strings';
import * as comments from './comments';
interface Config { let config: utils.Config = {} as any;
baseBranch: string;
baseCommitSHA: string;
defaultBranch: string;
fileList: any[];
mergeMethod: string;
owner: string;
prList: any[];
repository: string;
storage: GitStorage;
}
let config: Config = {} as any;
export function initPlatform({ export function initPlatform({
endpoint, endpoint,
...@@ -75,9 +64,12 @@ export async function initRepo({ ...@@ -75,9 +64,12 @@ export async function initRepo({
hostType: 'bitbucket', hostType: 'bitbucket',
url: 'https://api.bitbucket.org/', url: 'https://api.bitbucket.org/',
}); });
config = {} as any; config = {
repository,
username: opts!.username,
} as any;
// TODO: get in touch with @rarkins about lifting up the caching into the app layer // TODO: get in touch with @rarkins about lifting up the caching into the app layer
config.repository = repository;
const platformConfig: any = {}; const platformConfig: any = {};
const url = GitStorage.getUrl({ const url = GitStorage.getUrl({
...@@ -101,11 +93,16 @@ export async function initRepo({ ...@@ -101,11 +93,16 @@ export async function initRepo({
platformConfig.privateRepo = info.privateRepo; platformConfig.privateRepo = info.privateRepo;
platformConfig.isFork = info.isFork; platformConfig.isFork = info.isFork;
platformConfig.repoFullName = info.repoFullName; platformConfig.repoFullName = info.repoFullName;
config.owner = info.owner;
Object.assign(config, {
owner: info.owner,
defaultBranch: info.mainbranch,
baseBranch: info.mainbranch,
mergeMethod: info.mergeMethod,
has_issues: info.has_issues,
});
logger.debug(`${repository} owner = ${config.owner}`); logger.debug(`${repository} owner = ${config.owner}`);
config.defaultBranch = info.mainbranch;
config.baseBranch = config.defaultBranch;
config.mergeMethod = info.mergeMethod;
} catch (err) /* istanbul ignore next */ { } catch (err) /* istanbul ignore next */ {
if (err.statusCode === 404) { if (err.statusCode === 404) {
throw new Error('not-found'); throw new Error('not-found');
...@@ -296,12 +293,11 @@ export async function setBranchStatus( ...@@ -296,12 +293,11 @@ export async function setBranchStatus(
async function findOpenIssues(title: string) { async function findOpenIssues(title: string) {
try { try {
const currentUser = (await api.get('/2.0/user')).body.username;
const filter = encodeURIComponent( const filter = encodeURIComponent(
[ [
`title=${JSON.stringify(title)}`, `title=${JSON.stringify(title)}`,
'(state = "new" OR state = "open")', '(state = "new" OR state = "open")',
`reporter.username="${currentUser}"`, `reporter.username="${config.username}"`,
].join(' AND ') ].join(' AND ')
); );
return ( return (
...@@ -310,13 +306,19 @@ async function findOpenIssues(title: string) { ...@@ -310,13 +306,19 @@ async function findOpenIssues(title: string) {
)).body.values || /* istanbul ignore next */ [] )).body.values || /* istanbul ignore next */ []
); );
} catch (err) /* istanbul ignore next */ { } catch (err) /* istanbul ignore next */ {
logger.warn('Error finding issues'); logger.warn({ err }, 'Error finding issues');
return []; return [];
} }
} }
export async function findIssue(title: string) { export async function findIssue(title: string) {
logger.debug(`findIssue(${title})`); logger.debug(`findIssue(${title})`);
/* istanbul ignore if */
if (!config.has_issues) {
logger.warn('Issues are disabled');
return null;
}
const issues = await findOpenIssues(title); const issues = await findOpenIssues(title);
if (!issues.length) { if (!issues.length) {
return null; return null;
...@@ -339,6 +341,12 @@ async function closeIssue(issueNumber: number) { ...@@ -339,6 +341,12 @@ async function closeIssue(issueNumber: number) {
export async function ensureIssue(title: string, body: string) { export async function ensureIssue(title: string, body: string) {
logger.debug(`ensureIssue()`); logger.debug(`ensureIssue()`);
/* istanbul ignore if */
if (!config.has_issues) {
logger.warn('Issues are disabled');
return null;
}
try { try {
const issues = await findOpenIssues(title); const issues = await findOpenIssues(title);
if (issues.length) { if (issues.length) {
...@@ -381,13 +389,38 @@ export async function ensureIssue(title: string, body: string) { ...@@ -381,13 +389,38 @@ export async function ensureIssue(title: string, body: string) {
return null; return null;
} }
export /* istanbul ignore next */ function getIssueList() { export /* istanbul ignore next */ async function getIssueList() {
logger.debug(`getIssueList()`); logger.debug(`getIssueList()`);
// TODO: Needs implementation
/* istanbul ignore if */
if (!config.has_issues) {
logger.warn('Issues are disabled');
return []; return [];
} }
try {
const filter = encodeURIComponent(
[
'(state = "new" OR state = "open")',
`reporter.username="${config.username}"`,
].join(' AND ')
);
return (
(await api.get(
`/2.0/repositories/${config.repository}/issues?q=${filter}`
)).body.values || /* istanbul ignore next */ []
);
} catch (err) /* istanbul ignore next */ {
logger.warn({ err }, 'Error finding issues');
return [];
}
}
export async function ensureIssueClosing(title: string) { export async function ensureIssueClosing(title: string) {
/* istanbul ignore if */
if (!config.has_issues) {
logger.warn('Issues are disabled');
return;
}
const issues = await findOpenIssues(title); const issues = await findOpenIssues(title);
for (const issue of issues) { for (const issue of issues) {
await closeIssue(issue.id); await closeIssue(issue.id);
...@@ -420,22 +453,18 @@ export /* istanbul ignore next */ function deleteLabel() { ...@@ -420,22 +453,18 @@ export /* istanbul ignore next */ function deleteLabel() {
throw new Error('deleteLabel not implemented'); throw new Error('deleteLabel not implemented');
} }
/* eslint-disable @typescript-eslint/no-unused-vars */
export function ensureComment( export function ensureComment(
_prNo: number, prNo: number,
_topic: string | null, topic: string | null,
_content: string content: string
) { ) {
// https://developer.atlassian.com/bitbucket/api/2/reference/search?q=pullrequest+comment // https://developer.atlassian.com/bitbucket/api/2/reference/search?q=pullrequest+comment
logger.warn('Comment functionality not implemented yet'); return comments.ensureComment(config, prNo, topic, content);
return Promise.resolve();
} }
export function ensureCommentRemoval(_prNo: number, _topic: string) { export function ensureCommentRemoval(prNo: number, topic: string) {
// The api does not support removing comments return comments.ensureCommentRemoval(config, prNo, topic);
return Promise.resolve();
} }
/* eslint-enable @typescript-eslint/no-unused-vars */
// istanbul ignore next // istanbul ignore next
function matchesState(state: string, desiredState: string) { function matchesState(state: string, desiredState: string) {
......
import url from 'url'; import url from 'url';
import { api } from './bb-got-wrapper'; import { api } from './bb-got-wrapper';
import { Storage } from '../git/storage';
export interface Config {
baseBranch: string;
baseCommitSHA: string;
defaultBranch: string;
fileList: any[];
has_issues: boolean;
mergeMethod: string;
owner: string;
prList: any[];
repository: string;
storage: Storage;
username: string;
}
export function repoInfoTransformer(repoInfoBody: any) { export function repoInfoTransformer(repoInfoBody: any) {
return { return {
...@@ -9,6 +25,7 @@ export function repoInfoTransformer(repoInfoBody: any) { ...@@ -9,6 +25,7 @@ export function repoInfoTransformer(repoInfoBody: any) {
owner: repoInfoBody.owner.username, owner: repoInfoBody.owner.username,
mainbranch: repoInfoBody.mainbranch.name, mainbranch: repoInfoBody.mainbranch.name,
mergeMethod: 'merge', mergeMethod: 'merge',
has_issues: repoInfoBody.has_issues,
}; };
} }
...@@ -40,13 +57,13 @@ const addMaxLength = (inputUrl: string, pagelen = 100) => { ...@@ -40,13 +57,13 @@ const addMaxLength = (inputUrl: string, pagelen = 100) => {
return maxedUrl; return maxedUrl;
}; };
export async function accumulateValues( export async function accumulateValues<T = any>(
reqUrl: string, reqUrl: string,
method = 'get', method = 'get',
options?: any, options?: any,
pagelen?: number pagelen?: number
) { ) {
let accumulator: any[] = []; let accumulator: T[] = [];
let nextUrl = addMaxLength(reqUrl, pagelen); let nextUrl = addMaxLength(reqUrl, pagelen);
const lowerCaseMethod = method.toLocaleLowerCase(); const lowerCaseMethod = method.toLocaleLowerCase();
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`platform/comments ensureComment() add comment if not found 1`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100",
undefined,
],
]
`;
exports[`platform/comments ensureComment() add comment if not found 2`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100",
undefined,
],
]
`;
exports[`platform/comments ensureComment() add updates comment if necessary 1`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100",
undefined,
],
]
`;
exports[`platform/comments ensureComment() add updates comment if necessary 2`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100",
undefined,
],
]
`;
exports[`platform/comments ensureComment() does not throw 1`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/3/comments?pagelen=100",
undefined,
],
]
`;
exports[`platform/comments ensureComment() skips comment 1`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100",
undefined,
],
]
`;
exports[`platform/comments ensureComment() skips comment 2`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100",
undefined,
],
]
`;
exports[`platform/comments ensureCommentRemoval() deletes comment if found 1`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100",
undefined,
],
]
`;
exports[`platform/comments ensureCommentRemoval() deletes nothing 1`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100",
undefined,
],
]
`;
exports[`platform/comments ensureCommentRemoval() does not throw 1`] = `
Array [
Array [
"/2.0/repositories/some/repo/pullrequests/5/comments?pagelen=100",
undefined,
],
]
`;
...@@ -66,10 +66,7 @@ Array [ ...@@ -66,10 +66,7 @@ Array [
undefined, undefined,
], ],
Array [ Array [
"/2.0/user", "/2.0/repositories/some/empty/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22abc%22",
],
Array [
"/2.0/repositories/some/empty/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22nobody%22",
], ],
] ]
`; `;
...@@ -94,10 +91,7 @@ Array [ ...@@ -94,10 +91,7 @@ Array [
exports[`platform/bitbucket ensureIssue() noop for existing issue 1`] = ` exports[`platform/bitbucket ensureIssue() noop for existing issue 1`] = `
Array [ Array [
Array [ Array [
"/2.0/user", "/2.0/repositories/some/repo/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22abc%22",
],
Array [
"/2.0/repositories/some/repo/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22nobody%22",
], ],
] ]
`; `;
...@@ -105,10 +99,7 @@ Array [ ...@@ -105,10 +99,7 @@ Array [
exports[`platform/bitbucket ensureIssue() updates existing issues 1`] = ` exports[`platform/bitbucket ensureIssue() updates existing issues 1`] = `
Array [ Array [
Array [ Array [
"/2.0/user", "/2.0/repositories/some/repo/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22abc%22",
],
Array [
"/2.0/repositories/some/repo/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22nobody%22",
], ],
] ]
`; `;
...@@ -118,10 +109,7 @@ exports[`platform/bitbucket ensureIssue() updates existing issues 2`] = `Array [ ...@@ -118,10 +109,7 @@ exports[`platform/bitbucket ensureIssue() updates existing issues 2`] = `Array [
exports[`platform/bitbucket ensureIssueClosing() does not throw 1`] = ` exports[`platform/bitbucket ensureIssueClosing() does not throw 1`] = `
Array [ Array [
Array [ Array [
"/2.0/user", "/2.0/repositories/some/repo/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22abc%22",
],
Array [
"/2.0/repositories/some/repo/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22nobody%22",
], ],
] ]
`; `;
...@@ -138,10 +126,7 @@ Object { ...@@ -138,10 +126,7 @@ Object {
exports[`platform/bitbucket findIssue() does not throw 2`] = ` exports[`platform/bitbucket findIssue() does not throw 2`] = `
Array [ Array [
Array [ Array [
"/2.0/user", "/2.0/repositories/some/repo/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22abc%22",
],
Array [
"/2.0/repositories/some/repo/issues?q=title%3D%22title%22%20AND%20(state%20%3D%20%22new%22%20OR%20state%20%3D%20%22open%22)%20AND%20reporter.username%3D%22nobody%22",
], ],
] ]
`; `;
......
...@@ -21,6 +21,7 @@ const issue = { ...@@ -21,6 +21,7 @@ const issue = {
const repo = { const repo = {
is_private: false, is_private: false,
full_name: 'some/repo', full_name: 'some/repo',
has_issues: true,
owner: { username: 'some' }, owner: { username: 'some' },
mainbranch: { name: 'master' }, mainbranch: { name: 'master' },
}; };
...@@ -66,6 +67,14 @@ module.exports = { ...@@ -66,6 +67,14 @@ module.exports = {
'/2.0/repositories/some/repo/pullrequests/5/commits': { '/2.0/repositories/some/repo/pullrequests/5/commits': {
values: [{}], values: [{}],
}, },
'/2.0/repositories/some/repo/pullrequests/5/comments': {
values: [
{ id: 21, content: { raw: '### some-subject\n\nblablabla' } },
{ id: 22, content: { raw: '!merge' } }
],
},
'/2.0/repositories/some/repo/pullrequests/5/comments/21': {},
'/2.0/repositories/some/repo/pullrequests/5/comments/22': {},
'/2.0/repositories/some/repo/refs/branches': { '/2.0/repositories/some/repo/refs/branches': {
values: [ values: [
{ name: 'master' }, { name: 'master' },
......
import URL from 'url';
import { api as _api } from '../../../lib/platform/bitbucket/bb-got-wrapper';
import * as comments from '../../../lib/platform/bitbucket/comments';
import responses from './_fixtures/responses';
jest.mock('../../../lib/platform/bitbucket/bb-got-wrapper');
const api: jest.Mocked<typeof _api> = _api as any;
describe('platform/comments', () => {
const config: comments.CommentsConfig = { repository: 'some/repo' };
async function mockedGet(path: string) {
const uri = URL.parse(path).pathname!;
let body = (responses as any)[uri];
if (!body) {
throw new Error('Missing request');
}
if (typeof body === 'function') {
body = await body();
}
return { body } as any;
}
beforeAll(() => {
api.get.mockImplementation(mockedGet);
api.post.mockImplementation(mockedGet);
api.put.mockImplementation(mockedGet);
api.delete.mockImplementation(mockedGet);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('ensureComment()', () => {
it('does not throw', async () => {
expect.assertions(2);
expect(await comments.ensureComment(config, 3, 'topic', 'content')).toBe(
false
);
expect(api.get.mock.calls).toMatchSnapshot();
});
it('add comment if not found', async () => {
expect.assertions(6);
api.get.mockClear();
expect(await comments.ensureComment(config, 5, 'topic', 'content')).toBe(
true
);
expect(api.get.mock.calls).toMatchSnapshot();
expect(api.post).toHaveBeenCalledTimes(1);
api.get.mockClear();
api.post.mockClear();
expect(await comments.ensureComment(config, 5, null, 'content')).toBe(
true
);
expect(api.get.mock.calls).toMatchSnapshot();
expect(api.post).toHaveBeenCalledTimes(1);
});
it('add updates comment if necessary', async () => {
expect.assertions(8);
api.get.mockClear();
expect(
await comments.ensureComment(config, 5, 'some-subject', 'some\ncontent')
).toBe(true);
expect(api.get.mock.calls).toMatchSnapshot();
expect(api.post).toHaveBeenCalledTimes(0);
expect(api.put).toHaveBeenCalledTimes(1);
api.get.mockClear();
api.put.mockClear();
expect(
await comments.ensureComment(config, 5, null, 'some\ncontent')
).toBe(true);
expect(api.get.mock.calls).toMatchSnapshot();
expect(api.post).toHaveBeenCalledTimes(1);
expect(api.put).toHaveBeenCalledTimes(0);
});
it('skips comment', async () => {
expect.assertions(6);
api.get.mockClear();
expect(
await comments.ensureComment(config, 5, 'some-subject', 'blablabla')
).toBe(true);
expect(api.get.mock.calls).toMatchSnapshot();
expect(api.put).toHaveBeenCalledTimes(0);
api.get.mockClear();
api.put.mockClear();
expect(await comments.ensureComment(config, 5, null, '!merge')).toBe(
true
);
expect(api.get.mock.calls).toMatchSnapshot();
expect(api.put).toHaveBeenCalledTimes(0);
});
});
describe('ensureCommentRemoval()', () => {
it('does not throw', async () => {
expect.assertions(1);
await comments.ensureCommentRemoval(config, 5, 'topic');
expect(api.get.mock.calls).toMatchSnapshot();
});
it('deletes comment if found', async () => {
expect.assertions(2);
api.get.mockClear();
await comments.ensureCommentRemoval(config, 5, 'some-subject');
expect(api.get.mock.calls).toMatchSnapshot();
expect(api.delete).toHaveBeenCalledTimes(1);
});
it('deletes nothing', async () => {
expect.assertions(2);
api.get.mockClear();
await comments.ensureCommentRemoval(config, 5, 'topic');
expect(api.get.mock.calls).toMatchSnapshot();
expect(api.delete).toHaveBeenCalledTimes(0);
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment