diff --git a/lib/modules/platform/bitbucket/index.spec.ts b/lib/modules/platform/bitbucket/index.spec.ts index b4b8b8782712b9..ab13c73de1277d 100644 --- a/lib/modules/platform/bitbucket/index.spec.ts +++ b/lib/modules/platform/bitbucket/index.spec.ts @@ -1,9 +1,9 @@ import * as httpMock from '../../../../test/http-mock'; import type { logger as _logger } from '../../../logger'; +import { reset as memCacheReset } from '../../../util/cache/memory'; import type * as _git from '../../../util/git'; import { setBaseUrl } from '../../../util/http/bitbucket'; import type { Platform, PlatformResult, RepoParams } from '../types'; -import { prFieldsFilter } from './utils'; jest.mock('../../../util/git'); jest.mock('../../../util/host-rules'); @@ -43,6 +43,7 @@ describe('modules/platform/bitbucket/index', () => { }); setBaseUrl(baseUrl); + memCacheReset(); }); async function initRepoMock( @@ -235,9 +236,8 @@ describe('modules/platform/bitbucket/index', () => { it('bitbucket finds PR for branch', async () => { const scope = await initRepoMock(); scope - .get( - `/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&fields=${prFieldsFilter}&pagelen=50`, - ) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) .reply(200, { values: [pr] }) .get('/2.0/repositories/some/repo/pullrequests/5') .reply(200, pr); @@ -248,9 +248,8 @@ describe('modules/platform/bitbucket/index', () => { it('returns null if no PR for branch', async () => { const scope = await initRepoMock(); scope - .get( - `/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&fields=${prFieldsFilter}&pagelen=50`, - ) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) .reply(200, { values: [pr] }); const res = await bitbucket.getBranchPr('branch_without_pr'); @@ -753,9 +752,8 @@ describe('modules/platform/bitbucket/index', () => { await bitbucket.initPlatform({ username: 'renovate', password: 'pass' }); await initRepoMock(undefined, null, scope); scope - .get( - `/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&q=author.uuid="12345"&fields=${prFieldsFilter}&pagelen=50`, - ) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) .reply(200, { values: [ { @@ -779,9 +777,8 @@ describe('modules/platform/bitbucket/index', () => { it('finds pr', async () => { const scope = await initRepoMock(); scope - .get( - `/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&fields=${prFieldsFilter}&pagelen=50`, - ) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) .reply(200, { values: [pr] }); expect( await bitbucket.findPr({ @@ -805,9 +802,8 @@ describe('modules/platform/bitbucket/index', () => { const scope = await initRepoMock(); scope - .get( - `/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&fields=${prFieldsFilter}&pagelen=50`, - ) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) .reply(200, { values: [ { @@ -843,9 +839,8 @@ describe('modules/platform/bitbucket/index', () => { const scope = await initRepoMock({}, { is_private: true }); scope - .get( - `/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&fields=${prFieldsFilter}&pagelen=50`, - ) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) .reply(200, { values: [ { @@ -883,9 +878,8 @@ describe('modules/platform/bitbucket/index', () => { const scope = await initRepoMock({}, { is_private: false }); scope - .get( - `/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&fields=${prFieldsFilter}&pagelen=50`, - ) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) .reply(200, { values: [ { @@ -927,9 +921,8 @@ describe('modules/platform/bitbucket/index', () => { const scope = await initRepoMock({}, { is_private: false }); scope - .get( - `/2.0/repositories/some/repo/pullrequests?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&fields=${prFieldsFilter}&pagelen=50`, - ) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) .reply(200, { values: [ { @@ -1025,7 +1018,12 @@ describe('modules/platform/bitbucket/index', () => { values: [projectReviewer, repoReviewer], }) .post('/2.0/repositories/some/repo/pullrequests') - .reply(200, { id: 5 }); + .reply(200, { id: 5 }) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) + .reply(200, { + values: [{ id: 5 }], + }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', @@ -1103,7 +1101,12 @@ describe('modules/platform/bitbucket/index', () => { account_status: 'inactive', }) .post('/2.0/repositories/some/repo/pullrequests') - .reply(200, { id: 5 }); + .reply(200, { id: 5 }) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) + .reply(200, { + values: [{ id: 5 }], + }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', @@ -1161,7 +1164,12 @@ describe('modules/platform/bitbucket/index', () => { ) .reply(200) .post('/2.0/repositories/some/repo/pullrequests') - .reply(200, { id: 5 }); + .reply(200, { id: 5 }) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) + .reply(200, { + values: [{ id: 5 }], + }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', @@ -1257,7 +1265,12 @@ describe('modules/platform/bitbucket/index', () => { }, }) .post('/2.0/repositories/some/repo/pullrequests') - .reply(200, { id: 5 }); + .reply(200, { id: 5 }) + .get(`/2.0/repositories/some/repo/pullrequests`) + .query(true) + .reply(200, { + values: [{ id: 5 }], + }); const pr = await bitbucket.createPr({ sourceBranch: 'branch', targetBranch: 'master', diff --git a/lib/modules/platform/bitbucket/index.ts b/lib/modules/platform/bitbucket/index.ts index 9ddef12de074a9..165a09d446c9d8 100644 --- a/lib/modules/platform/bitbucket/index.ts +++ b/lib/modules/platform/bitbucket/index.ts @@ -31,6 +31,7 @@ import { repoFingerprint } from '../util'; import { smartTruncate } from '../utils/pr-body'; import { readOnlyIssueBody } from '../utils/read-only-issue-body'; import * as comments from './comments'; +import { BitbucketPrCache } from './pr-cache'; import type { Account, BitbucketStatus, @@ -44,7 +45,7 @@ import type { RepoInfoBody, } from './types'; import * as utils from './utils'; -import { mergeBodyTransformer, prFieldsFilter } from './utils'; +import { mergeBodyTransformer } from './utils'; export const id = 'bitbucket'; @@ -273,28 +274,11 @@ function matchesState(state: string, desiredState: string): boolean { export async function getPrList(): Promise { logger.debug('getPrList()'); - if (!config.prList) { - logger.debug('Retrieving PR list'); - const querySearchParams = new URL.URLSearchParams(); - for (const state of utils.prStates.all) { - querySearchParams.append('state', state); - } - if (renovateUserUuid && !config.ignorePrAuthor) { - querySearchParams.append('q', `author.uuid="${renovateUserUuid}"`); - } - querySearchParams.append('fields', prFieldsFilter); - const query = querySearchParams.toString(); - const url = `/2.0/repositories/${config.repository}/pullrequests?${query}`; - const prs = ( - await bitbucketHttp.getJson>(url, { - paginate: true, - pagelen: 50, - }) - ).body.values; - config.prList = prs.map(utils.prInfo); - logger.debug(`Retrieved Pull Requests, count: ${config.prList.length}`); - } - return config.prList; + return await BitbucketPrCache.getPrs( + bitbucketHttp, + config.repository, + renovateUserUuid, + ); } export async function findPr({ @@ -328,15 +312,17 @@ export async function findPr({ (!prTitle || p.title.toUpperCase() === prTitle.toUpperCase()) && matchesState(p.state, state), ); - if (pr) { - logger.debug(`Found PR #${pr.number}`); + + if (!pr) { + return null; } + logger.debug(`Found PR #${pr.number}`); /** * Bitbucket doesn't support renaming or reopening declined PRs. * Instead, we have to use comment-driven signals. */ - if (pr?.state === 'closed') { + if (pr.state === 'closed') { const reopenComments = await comments.reopenComments(config, pr.number); if (is.nonEmptyArray(reopenComments)) { @@ -359,7 +345,7 @@ export async function findPr({ } } - return pr ?? null; + return pr; } // Gets details for a PR @@ -913,10 +899,12 @@ export async function createPr({ ) ).body; const pr = utils.prInfo(prRes); - // istanbul ignore if - if (config.prList) { - config.prList.push(pr); - } + await BitbucketPrCache.addPr( + bitbucketHttp, + config.repository, + renovateUserUuid, + pr, + ); return pr; } catch (err) /* istanbul ignore next */ { // Try sanitizing reviewers @@ -938,10 +926,12 @@ export async function createPr({ ) ).body; const pr = utils.prInfo(prRes); - // istanbul ignore if - if (config.prList) { - config.prList.push(pr); - } + await BitbucketPrCache.addPr( + bitbucketHttp, + config.repository, + renovateUserUuid, + pr, + ); return pr; } } diff --git a/lib/modules/platform/bitbucket/pr-cache.spec.ts b/lib/modules/platform/bitbucket/pr-cache.spec.ts new file mode 100644 index 00000000000000..2dea9fe977b49a --- /dev/null +++ b/lib/modules/platform/bitbucket/pr-cache.spec.ts @@ -0,0 +1,137 @@ +import * as httpMock from '../../../../test/http-mock'; +import { reset as memCacheReset } from '../../../util/cache/memory'; +import { + getCache, + resetCache as repoCacheReset, +} from '../../../util/cache/repository'; +import { BitbucketHttp } from '../../../util/http/bitbucket'; +import { BitbucketPrCache } from './pr-cache'; +import type { PrResponse } from './types'; +import { prInfo } from './utils'; + +const http = new BitbucketHttp(); + +const pr1: PrResponse = { + id: 1, + title: 'title', + state: 'OPEN', + links: { + commits: { + href: 'https://api.bitbucket.org/2.0/repositories/some/repo/pullrequests/1/commits', + }, + }, + source: { branch: { name: 'branch' } }, + destination: { branch: { name: 'master' } }, + reviewers: [], + created_on: '2020-01-01T00:00:00.000Z', + updated_on: '2020-01-01T00:00:00.000Z', +}; + +const pr2: PrResponse = { + id: 2, + title: 'title', + state: 'OPEN', + links: { + commits: { + href: 'https://api.bitbucket.org/2.0/repositories/some/repo/pullrequests/2/commits', + }, + }, + source: { branch: { name: 'branch' } }, + destination: { branch: { name: 'master' } }, + reviewers: [], + created_on: '2023-01-01T00:00:00.000Z', + updated_on: '2023-01-01T00:00:00.000Z', +}; + +describe('modules/platform/bitbucket/pr-cache', () => { + let cache = getCache(); + + beforeEach(() => { + memCacheReset(); + repoCacheReset(); + cache = getCache(); + }); + + it('fetches cache', async () => { + httpMock + .scope('https://api.bitbucket.org') + .get(`/2.0/repositories/some-workspace/some-repo/pullrequests`) + .query(true) + .reply(200, { + values: [pr1], + }); + + const res = await BitbucketPrCache.getPrs( + http, + 'some-workspace/some-repo', + 'some-author', + ); + + expect(res).toMatchObject([ + { + number: 1, + title: 'title', + }, + ]); + expect(cache).toEqual({ + platform: { + bitbucket: { + pullRequestsCache: { + author: 'some-author', + items: { + '1': prInfo(pr1), + }, + updated_on: '2020-01-01T00:00:00.000Z', + }, + }, + }, + }); + }); + + it('syncs cache', async () => { + cache.platform = { + bitbucket: { + pullRequestsCache: { + items: { + '1': prInfo(pr1), + }, + author: 'some-author', + updated_on: '2020-01-01T00:00:00.000Z', + }, + }, + }; + + httpMock + .scope('https://api.bitbucket.org') + .get(`/2.0/repositories/some-workspace/some-repo/pullrequests`) + .query(true) + .reply(200, { + values: [pr2], + }); + + const res = await BitbucketPrCache.getPrs( + http, + 'some-workspace/some-repo', + 'some-author', + ); + + expect(res).toMatchObject([ + { number: 1, title: 'title' }, + { number: 2, title: 'title' }, + ]); + expect(cache).toEqual({ + platform: { + bitbucket: { + pullRequestsCache: { + items: { + '1': prInfo(pr1), + '2': prInfo(pr2), + }, + author: 'some-author', + updated_on: '2023-01-01T00:00:00.000Z', + }, + }, + }, + }); + }); +}); diff --git a/lib/modules/platform/bitbucket/pr-cache.ts b/lib/modules/platform/bitbucket/pr-cache.ts new file mode 100644 index 00000000000000..95fb4b947a7af0 --- /dev/null +++ b/lib/modules/platform/bitbucket/pr-cache.ts @@ -0,0 +1,135 @@ +import { dequal } from 'dequal'; +import { DateTime } from 'luxon'; +import { logger } from '../../../logger'; +import * as memCache from '../../../util/cache/memory'; +import { getCache } from '../../../util/cache/repository'; +import type { BitbucketHttp } from '../../../util/http/bitbucket'; +import type { Pr } from '../types'; +import type { BitbucketPrCacheData, PagedResult, PrResponse } from './types'; +import { prFieldsFilter, prInfo, prStates } from './utils'; + +export class BitbucketPrCache { + private cache: BitbucketPrCacheData; + + private constructor( + private repo: string, + private author: string | null, + ) { + const repoCache = getCache(); + repoCache.platform ??= {}; + repoCache.platform.bitbucket ??= {}; + + let pullRequestCache: BitbucketPrCacheData | undefined = + repoCache.platform.bitbucket.pullRequestsCache; + if (!pullRequestCache || pullRequestCache.author !== author) { + pullRequestCache = { + items: {}, + updated_on: null, + author, + }; + } + repoCache.platform.bitbucket.pullRequestsCache = pullRequestCache; + this.cache = pullRequestCache; + } + + private static async init( + http: BitbucketHttp, + repo: string, + author: string | null, + ): Promise { + const res = new BitbucketPrCache(repo, author); + const isSynced = memCache.get( + 'bitbucket-pr-cache-synced', + ); + + if (!isSynced) { + await res.sync(http); + memCache.set('bitbucket-pr-cache-synced', true); + } + + return res; + } + + private getPrs(): Pr[] { + return Object.values(this.cache.items); + } + + static async getPrs( + http: BitbucketHttp, + repo: string, + author: string | null, + ): Promise { + const prCache = await BitbucketPrCache.init(http, repo, author); + return prCache.getPrs(); + } + + private addPr(pr: Pr): void { + this.cache.items[pr.number] = pr; + } + + static async addPr( + http: BitbucketHttp, + repo: string, + author: string | null, + item: Pr, + ): Promise { + const prCache = await BitbucketPrCache.init(http, repo, author); + prCache.addPr(item); + } + + private reconcile(rawItems: PrResponse[]): void { + const { items: oldItems } = this.cache; + let { updated_on } = this.cache; + + for (const rawItem of rawItems) { + const id = rawItem.id; + + const oldItem = oldItems[id]; + const newItem = prInfo(rawItem); + + const itemNewTime = DateTime.fromISO(rawItem.updated_on); + + if (!dequal(oldItem, newItem)) { + oldItems[id] = newItem; + } + + const cacheOldTime = updated_on ? DateTime.fromISO(updated_on) : null; + if (!cacheOldTime || itemNewTime > cacheOldTime) { + updated_on = rawItem.updated_on; + } + } + + this.cache.updated_on = updated_on; + } + + private getUrl(): string { + const params = new URLSearchParams(); + + for (const state of prStates.all) { + params.append('state', state); + } + + params.append('fields', prFieldsFilter); + + const q: string[] = []; + if (this.author) { + q.push(`author.uuid = "${this.author}"`); + } + if (this.cache.updated_on) { + q.push(`updated_on > "${this.cache.updated_on}"`); + } + params.append('q', q.join(' AND ')); + + const query = params.toString(); + return `/2.0/repositories/${this.repo}/pullrequests?${query}`; + } + + private async sync(http: BitbucketHttp): Promise { + logger.debug('Syncing PR list'); + const url = this.getUrl(); + const opts = { paginate: true, pagelen: 50 }; + const res = await http.getJson>(url, opts); + this.reconcile(res.body.values); + return this; + } +} diff --git a/lib/modules/platform/bitbucket/types.ts b/lib/modules/platform/bitbucket/types.ts index 56bc4f82717cb1..35fba3d1638a5e 100644 --- a/lib/modules/platform/bitbucket/types.ts +++ b/lib/modules/platform/bitbucket/types.ts @@ -13,7 +13,6 @@ export interface Config { has_issues: boolean; mergeMethod: string; owner: string; - prList: Pr[]; repository: string; ignorePrAuthor: boolean; is_private: boolean; @@ -91,6 +90,7 @@ export interface PrResponse { }; reviewers: Account[]; created_on: string; + updated_on: string; } export interface Account { @@ -105,3 +105,9 @@ export interface EffectiveReviewer { reviewer_type: string; user: Account; } + +export interface BitbucketPrCacheData { + items: Record; + updated_on: string | null; + author: string | null; +} diff --git a/lib/modules/platform/bitbucket/utils.ts b/lib/modules/platform/bitbucket/utils.ts index 4d9ee1e87967d3..852f61611164f5 100644 --- a/lib/modules/platform/bitbucket/utils.ts +++ b/lib/modules/platform/bitbucket/utils.ts @@ -86,4 +86,5 @@ export const prFieldsFilter = [ 'values.reviewers.nickname', 'values.reviewers.account_status', 'values.created_on', + 'values.updated_on', ].join(','); diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts index 4560e7aa74023a..89d454aa89c893 100644 --- a/lib/util/cache/repository/types.ts +++ b/lib/util/cache/repository/types.ts @@ -4,6 +4,7 @@ import type { UpdateType, } from '../../../config/types'; import type { PackageFile } from '../../../modules/manager/types'; +import type { BitbucketPrCacheData } from '../../../modules/platform/bitbucket/types'; import type { RepoInitConfig } from '../../../workers/repository/init/types'; import type { PrBlockedBy } from '../../../workers/types'; @@ -131,6 +132,9 @@ export interface RepoCacheData { lastPlatformAutomergeFailure?: string; platform?: { github?: Record; + bitbucket?: { + pullRequestsCache?: BitbucketPrCacheData; + }; }; prComments?: Record>; onboardingBranchCache?: OnboardingBranchCache;