diff --git a/lib/platform/bitbucket/comments.ts b/lib/platform/bitbucket/comments.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcff83a27182d29dcf486dd80127456c912bfdf6 --- /dev/null +++ b/lib/platform/bitbucket/comments.ts @@ -0,0 +1,120 @@ +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'); + } +} diff --git a/lib/platform/bitbucket/index.ts b/lib/platform/bitbucket/index.ts index 4f3a8f818929b6fb7c75442116fbe4fa6a68c7a0..776b7aa81ea8f32e644bbf92c14e946d0a199dc4 100644 --- a/lib/platform/bitbucket/index.ts +++ b/lib/platform/bitbucket/index.ts @@ -6,20 +6,9 @@ import { logger } from '../../logger'; import GitStorage from '../git/storage'; import { readOnlyIssueBody } from '../utils/read-only-issue-body'; import { appSlug } from '../../config/app-strings'; +import * as comments from './comments'; -interface Config { - baseBranch: string; - baseCommitSHA: string; - defaultBranch: string; - fileList: any[]; - mergeMethod: string; - owner: string; - prList: any[]; - repository: string; - storage: GitStorage; -} - -let config: Config = {} as any; +let config: utils.Config = {} as any; export function initPlatform({ endpoint, @@ -75,9 +64,12 @@ export async function initRepo({ hostType: 'bitbucket', 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 - config.repository = repository; const platformConfig: any = {}; const url = GitStorage.getUrl({ @@ -101,11 +93,16 @@ export async function initRepo({ platformConfig.privateRepo = info.privateRepo; platformConfig.isFork = info.isFork; 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}`); - config.defaultBranch = info.mainbranch; - config.baseBranch = config.defaultBranch; - config.mergeMethod = info.mergeMethod; } catch (err) /* istanbul ignore next */ { if (err.statusCode === 404) { throw new Error('not-found'); @@ -296,12 +293,11 @@ export async function setBranchStatus( async function findOpenIssues(title: string) { try { - const currentUser = (await api.get('/2.0/user')).body.username; const filter = encodeURIComponent( [ `title=${JSON.stringify(title)}`, '(state = "new" OR state = "open")', - `reporter.username="${currentUser}"`, + `reporter.username="${config.username}"`, ].join(' AND ') ); return ( @@ -310,13 +306,19 @@ async function findOpenIssues(title: string) { )).body.values || /* istanbul ignore next */ [] ); } catch (err) /* istanbul ignore next */ { - logger.warn('Error finding issues'); + logger.warn({ err }, 'Error finding issues'); return []; } } export async function findIssue(title: string) { logger.debug(`findIssue(${title})`); + + /* istanbul ignore if */ + if (!config.has_issues) { + logger.warn('Issues are disabled'); + return null; + } const issues = await findOpenIssues(title); if (!issues.length) { return null; @@ -339,6 +341,12 @@ async function closeIssue(issueNumber: number) { export async function ensureIssue(title: string, body: string) { logger.debug(`ensureIssue()`); + + /* istanbul ignore if */ + if (!config.has_issues) { + logger.warn('Issues are disabled'); + return null; + } try { const issues = await findOpenIssues(title); if (issues.length) { @@ -381,13 +389,38 @@ export async function ensureIssue(title: string, body: string) { return null; } -export /* istanbul ignore next */ function getIssueList() { +export /* istanbul ignore next */ async function getIssueList() { logger.debug(`getIssueList()`); - // TODO: Needs implementation - return []; + + /* istanbul ignore if */ + if (!config.has_issues) { + logger.warn('Issues are disabled'); + 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) { + /* istanbul ignore if */ + if (!config.has_issues) { + logger.warn('Issues are disabled'); + return; + } const issues = await findOpenIssues(title); for (const issue of issues) { await closeIssue(issue.id); @@ -420,22 +453,18 @@ export /* istanbul ignore next */ function deleteLabel() { throw new Error('deleteLabel not implemented'); } -/* eslint-disable @typescript-eslint/no-unused-vars */ export function ensureComment( - _prNo: number, - _topic: string | null, - _content: string + prNo: number, + topic: string | null, + content: string ) { // https://developer.atlassian.com/bitbucket/api/2/reference/search?q=pullrequest+comment - logger.warn('Comment functionality not implemented yet'); - return Promise.resolve(); + return comments.ensureComment(config, prNo, topic, content); } -export function ensureCommentRemoval(_prNo: number, _topic: string) { - // The api does not support removing comments - return Promise.resolve(); +export function ensureCommentRemoval(prNo: number, topic: string) { + return comments.ensureCommentRemoval(config, prNo, topic); } -/* eslint-enable @typescript-eslint/no-unused-vars */ // istanbul ignore next function matchesState(state: string, desiredState: string) { diff --git a/lib/platform/bitbucket/utils.ts b/lib/platform/bitbucket/utils.ts index d44bf2b1dd508ece22d218749c53dbc1b18cd85b..b753a2f45ec3a98f3090cc1121740713669c242e 100644 --- a/lib/platform/bitbucket/utils.ts +++ b/lib/platform/bitbucket/utils.ts @@ -1,5 +1,21 @@ import url from 'url'; 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) { return { @@ -9,6 +25,7 @@ export function repoInfoTransformer(repoInfoBody: any) { owner: repoInfoBody.owner.username, mainbranch: repoInfoBody.mainbranch.name, mergeMethod: 'merge', + has_issues: repoInfoBody.has_issues, }; } @@ -40,13 +57,13 @@ const addMaxLength = (inputUrl: string, pagelen = 100) => { return maxedUrl; }; -export async function accumulateValues( +export async function accumulateValues<T = any>( reqUrl: string, method = 'get', options?: any, pagelen?: number ) { - let accumulator: any[] = []; + let accumulator: T[] = []; let nextUrl = addMaxLength(reqUrl, pagelen); const lowerCaseMethod = method.toLocaleLowerCase(); diff --git a/test/platform/bitbucket/__snapshots__/comments.spec.ts.snap b/test/platform/bitbucket/__snapshots__/comments.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..0e7a5aeef0302416b4175f4a5c9e131d5fe21a00 --- /dev/null +++ b/test/platform/bitbucket/__snapshots__/comments.spec.ts.snap @@ -0,0 +1,91 @@ +// 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, + ], +] +`; diff --git a/test/platform/bitbucket/__snapshots__/index.spec.ts.snap b/test/platform/bitbucket/__snapshots__/index.spec.ts.snap index ced5c899ec9d1c995ff552111cd3c62c8415cf63..75470c880fc44f8340d3fe9b0c3133f19c5f7fab 100644 --- a/test/platform/bitbucket/__snapshots__/index.spec.ts.snap +++ b/test/platform/bitbucket/__snapshots__/index.spec.ts.snap @@ -66,10 +66,7 @@ Array [ undefined, ], Array [ - "/2.0/user", - ], - 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", + "/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", ], ] `; @@ -94,10 +91,7 @@ Array [ exports[`platform/bitbucket ensureIssue() noop for existing issue 1`] = ` Array [ Array [ - "/2.0/user", - ], - 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", + "/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", ], ] `; @@ -105,10 +99,7 @@ Array [ exports[`platform/bitbucket ensureIssue() updates existing issues 1`] = ` Array [ Array [ - "/2.0/user", - ], - 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", + "/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", ], ] `; @@ -118,10 +109,7 @@ exports[`platform/bitbucket ensureIssue() updates existing issues 2`] = `Array [ exports[`platform/bitbucket ensureIssueClosing() does not throw 1`] = ` Array [ Array [ - "/2.0/user", - ], - 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", + "/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", ], ] `; @@ -138,10 +126,7 @@ Object { exports[`platform/bitbucket findIssue() does not throw 2`] = ` Array [ Array [ - "/2.0/user", - ], - 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", + "/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", ], ] `; diff --git a/test/platform/bitbucket/_fixtures/responses.js b/test/platform/bitbucket/_fixtures/responses.js index 28ab68654db0f0252a29c262fad0eb010578f55d..88142ba1f4715764848122acace1c09c8ee0b51f 100644 --- a/test/platform/bitbucket/_fixtures/responses.js +++ b/test/platform/bitbucket/_fixtures/responses.js @@ -21,6 +21,7 @@ const issue = { const repo = { is_private: false, full_name: 'some/repo', + has_issues: true, owner: { username: 'some' }, mainbranch: { name: 'master' }, }; @@ -66,6 +67,14 @@ module.exports = { '/2.0/repositories/some/repo/pullrequests/5/commits': { 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': { values: [ { name: 'master' }, diff --git a/test/platform/bitbucket/comments.spec.ts b/test/platform/bitbucket/comments.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..05c47a81940e29e233ea7081489789e6393dcce7 --- /dev/null +++ b/test/platform/bitbucket/comments.spec.ts @@ -0,0 +1,133 @@ +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); + }); + }); +});