diff --git a/lib/platform/comment.spec.ts b/lib/platform/comment.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f516e80873d898f6e7aced3cc01f61b8567455b9 --- /dev/null +++ b/lib/platform/comment.spec.ts @@ -0,0 +1,178 @@ +import { mocked, platform } from '../../test/util'; +import * as _cache from '../util/cache/repository'; +import type { Cache } from '../util/cache/repository/types'; +import { ensureComment, ensureCommentRemoval } from './comment'; + +jest.mock('.'); +jest.mock('../util/cache/repository'); + +const cache = mocked(_cache); + +describe('platform/comment', () => { + let repoCache: Cache = {}; + beforeEach(() => { + repoCache = {}; + jest.resetAllMocks(); + cache.getCache.mockReturnValue(repoCache); + }); + + describe('ensureComment', () => { + it('caches created comment', async () => { + platform.ensureComment.mockResolvedValueOnce(true); + + const res = await ensureComment({ + number: 1, + topic: 'foo', + content: 'bar', + }); + + expect(res).toBe(true); + expect(repoCache).toEqual({ + prComments: { + '1': { + foo: '62cdb7020ff920e5aa642c3d4066950dd1f01f4d', + }, + }, + }); + }); + + it('caches comment with no topic', async () => { + platform.ensureComment.mockResolvedValueOnce(true); + + const res = await ensureComment({ + number: 1, + topic: null, + content: 'bar', + }); + + expect(res).toBe(true); + expect(repoCache).toEqual({ + prComments: { + '1': { + '': '62cdb7020ff920e5aa642c3d4066950dd1f01f4d', + }, + }, + }); + }); + + it('does not cache failed comment', async () => { + platform.ensureComment.mockResolvedValueOnce(false); + + const res = await ensureComment({ + number: 1, + topic: 'foo', + content: 'bar', + }); + + expect(res).toBe(false); + expect(repoCache).toBeEmpty(); + }); + + it('short-circuits if comment already exists', async () => { + platform.ensureComment.mockResolvedValue(true); + + await ensureComment({ number: 1, topic: 'aaa', content: '111' }); + await ensureComment({ number: 1, topic: 'aaa', content: '111' }); + + expect(platform.ensureComment).toHaveBeenCalledTimes(1); + }); + + it('rewrites content hash', async () => { + platform.ensureComment.mockResolvedValue(true); + + await ensureComment({ number: 1, topic: 'aaa', content: '111' }); + await ensureComment({ number: 1, topic: 'aaa', content: '222' }); + + expect(platform.ensureComment).toHaveBeenCalledTimes(2); + expect(repoCache).toEqual({ + prComments: { + '1': { + aaa: '1c6637a8f2e1f75e06ff9984894d6bd16a3a36a9', + }, + }, + }); + }); + + it('caches comments many comments with different topics', async () => { + platform.ensureComment.mockResolvedValue(true); + + await ensureComment({ number: 1, topic: 'aaa', content: '111' }); + await ensureComment({ number: 1, topic: 'aaa', content: '111' }); + + await ensureComment({ number: 1, topic: 'bbb', content: '111' }); + await ensureComment({ number: 1, topic: 'bbb', content: '111' }); + + expect(platform.ensureComment).toHaveBeenCalledTimes(2); + expect(repoCache).toEqual({ + prComments: { + '1': { + aaa: '6216f8a75fd5bb3d5f22b6f9958cdede3fc086c2', + bbb: '6216f8a75fd5bb3d5f22b6f9958cdede3fc086c2', + }, + }, + }); + }); + }); + + describe('ensureCommentRemoval', () => { + beforeEach(() => { + platform.ensureCommentRemoval.mockResolvedValueOnce(); + platform.ensureComment.mockResolvedValue(true); + }); + + it('deletes cached comment by topic', async () => { + await ensureComment({ number: 1, topic: 'aaa', content: '111' }); + await ensureCommentRemoval({ type: 'by-topic', number: 1, topic: 'aaa' }); + expect(repoCache).toEqual({ + prComments: { '1': {} }, + }); + }); + + it('deletes cached comment by content', async () => { + await ensureComment({ number: 1, topic: 'aaa', content: '111' }); + await ensureCommentRemoval({ + type: 'by-content', + number: 1, + content: '111', + }); + expect(repoCache).toEqual({ + prComments: { '1': {} }, + }); + }); + + it('deletes by content only one comment', async () => { + await ensureComment({ number: 1, topic: 'aaa', content: '111' }); + await ensureComment({ number: 1, topic: 'bbb', content: '111' }); + await ensureCommentRemoval({ + type: 'by-content', + number: 1, + content: '111', + }); + expect(repoCache).toEqual({ + prComments: { + '1': { + bbb: '6216f8a75fd5bb3d5f22b6f9958cdede3fc086c2', + }, + }, + }); + }); + + it('deletes only for selected PR', async () => { + await ensureComment({ number: 1, topic: 'aaa', content: '111' }); + await ensureComment({ number: 2, topic: 'aaa', content: '111' }); + await ensureCommentRemoval({ + type: 'by-content', + number: 1, + content: '111', + }); + expect(repoCache).toEqual({ + prComments: { + '1': {}, + '2': { + aaa: '6216f8a75fd5bb3d5f22b6f9958cdede3fc086c2', + }, + }, + }); + }); + }); +}); diff --git a/lib/platform/comment.ts b/lib/platform/comment.ts new file mode 100644 index 0000000000000000000000000000000000000000..c361b6d69ee67408bd3036e1188ceaef5e055649 --- /dev/null +++ b/lib/platform/comment.ts @@ -0,0 +1,53 @@ +import hasha from 'hasha'; +import { getCache } from '../util/cache/repository'; +import type { EnsureCommentConfig, EnsureCommentRemovalConfig } from './types'; +import { platform } from '.'; + +const hash = (content: string): string => hasha(content, { algorithm: 'sha1' }); + +export async function ensureComment( + commentConfig: EnsureCommentConfig +): Promise<boolean> { + const { number, content } = commentConfig; + const topic = commentConfig.topic ?? ''; + + const contentHash = hash(content); + const repoCache = getCache(); + + if (contentHash !== repoCache.prComments?.[number]?.[topic]) { + const res = await platform.ensureComment(commentConfig); + if (res) { + repoCache.prComments ??= {}; + repoCache.prComments[number] ??= {}; + repoCache.prComments[number][topic] = contentHash; + } + return res; + } + + return true; +} + +export async function ensureCommentRemoval( + config: EnsureCommentRemovalConfig +): Promise<void> { + await platform.ensureCommentRemoval(config); + + const repoCache = getCache(); + + const { type, number } = config; + if (repoCache.prComments?.[number]) { + if (type === 'by-topic') { + delete repoCache.prComments?.[number]?.[config.topic]; + } else if (type === 'by-content') { + const contentHash = hash(config.content); + for (const [cachedTopic, cachedContentHash] of Object.entries( + repoCache.prComments?.[number] + )) { + if (cachedContentHash === contentHash) { + delete repoCache.prComments?.[number]?.[cachedTopic]; + return; + } + } + } + } +} diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts index 7ba2a0e3252ad16d5045ea6ec3ec21187cd50b9d..55ae60d4d1a1f45a2484cada700dce4a8186cf21 100644 --- a/lib/util/cache/repository/types.ts +++ b/lib/util/cache/repository/types.ts @@ -52,4 +52,5 @@ export interface Cache { }; }; gitConflicts?: GitConflictsCache; + prComments?: Record<number, Record<string, string>>; } diff --git a/lib/workers/branch/handle-existing.ts b/lib/workers/branch/handle-existing.ts index 89026ccc481750a4ad1dd431238495d2283422d3..ac1c249cc2a301f86880b5aaa15444d4a7c87cb7 100644 --- a/lib/workers/branch/handle-existing.ts +++ b/lib/workers/branch/handle-existing.ts @@ -1,6 +1,7 @@ import { GlobalConfig } from '../../config/global'; import { logger } from '../../logger'; -import { Pr, platform } from '../../platform'; +import type { Pr } from '../../platform'; +import { ensureComment } from '../../platform/comment'; import { PrState } from '../../types'; import { branchExists, deleteBranch } from '../../util/git'; import * as template from '../../util/template'; @@ -24,7 +25,7 @@ export async function handlepr(config: BranchConfig, pr: Pr): Promise<void> { `DRY-RUN: Would ensure closed PR comment in PR #${pr.number}` ); } else { - await platform.ensureComment({ + await ensureComment({ number: pr.number, topic: config.userStrings.ignoreTopic, content, diff --git a/lib/workers/branch/index.ts b/lib/workers/branch/index.ts index a905611400e0ff40c2cf8f2e1816b374b3b5b0de..5c3377dff20cb78d64688dd8c46c0f75aed500d9 100644 --- a/lib/workers/branch/index.ts +++ b/lib/workers/branch/index.ts @@ -16,6 +16,7 @@ import { import { logger, removeMeta } from '../../logger'; import { getAdditionalFiles } from '../../manager/npm/post-update'; import { Pr, platform } from '../../platform'; +import { ensureComment, ensureCommentRemoval } from '../../platform/comment'; import { BranchStatus, PrState } from '../../types'; import { ExternalHostError } from '../../types/errors/external-host-error'; import { getElapsedDays } from '../../util/date'; @@ -433,7 +434,7 @@ export async function processBranch( ); } else { // Remove artifacts error comment only if this run has successfully updated artifacts - await platform.ensureCommentRemoval({ + await ensureCommentRemoval({ type: 'by-topic', number: branchPr.number, topic: artifactErrorTopic, @@ -691,7 +692,7 @@ export async function processBranch( `DRY-RUN: Would ensure lock file error comment in PR #${pr.number}` ); } else { - await platform.ensureComment({ + await ensureComment({ number: pr.number, topic: artifactErrorTopic, content, diff --git a/lib/workers/pr/automerge.ts b/lib/workers/pr/automerge.ts index 62838e0a7ea8c500c4819285dcbce07ee79e4ba1..b99a31ca723e3f5f8661119517eb7fe778b80a2c 100644 --- a/lib/workers/pr/automerge.ts +++ b/lib/workers/pr/automerge.ts @@ -1,6 +1,7 @@ import { GlobalConfig } from '../../config/global'; import { logger } from '../../logger'; import { Pr, platform } from '../../platform'; +import { ensureComment, ensureCommentRemoval } from '../../platform/comment'; import { BranchStatus } from '../../types'; import { deleteBranch, @@ -93,13 +94,13 @@ export async function checkAutoMerge( }; } if (rebaseRequested) { - await platform.ensureCommentRemoval({ + await ensureCommentRemoval({ type: 'by-content', number: pr.number, content: automergeComment, }); } - await platform.ensureComment({ + await ensureComment({ number: pr.number, topic: null, content: automergeComment, diff --git a/lib/workers/pr/index.ts b/lib/workers/pr/index.ts index 42c1d3dbd408baf8c6f688ec12ff02c0dffec7e7..383aa0ead3ecdfb316376da4658a2da0f96ccea7 100644 --- a/lib/workers/pr/index.ts +++ b/lib/workers/pr/index.ts @@ -7,6 +7,7 @@ import { } from '../../constants/error-messages'; import { logger } from '../../logger'; import { PlatformPrOptions, Pr, platform } from '../../platform'; +import { ensureComment } from '../../platform/comment'; import { BranchStatus } from '../../types'; import { ExternalHostError } from '../../types/errors/external-host-error'; import { sampleSize } from '../../util'; @@ -493,7 +494,7 @@ export async function ensurePr( if (GlobalConfig.get('dryRun')) { logger.info(`DRY-RUN: Would add comment to PR #${pr.number}`); } else { - await platform.ensureComment({ + await ensureComment({ number: pr.number, topic, content, diff --git a/lib/workers/repository/finalise/prune.ts b/lib/workers/repository/finalise/prune.ts index 17aaf3bd05d235278a83e8bacf8c29ae76e68231..f469040ed916bcf74de7f7def7e1d80b133103f3 100644 --- a/lib/workers/repository/finalise/prune.ts +++ b/lib/workers/repository/finalise/prune.ts @@ -3,6 +3,7 @@ import type { RenovateConfig } from '../../../config/types'; import { REPOSITORY_CHANGED } from '../../../constants/error-messages'; import { logger } from '../../../logger'; import { platform } from '../../../platform'; +import { ensureComment } from '../../../platform/comment'; import { PrState } from '../../../types'; import { deleteBranch, @@ -34,7 +35,7 @@ async function cleanUpBranches( if (GlobalConfig.get('dryRun')) { logger.info(`DRY-RUN: Would add Autoclosing Skipped comment to PR`); } else { - await platform.ensureComment({ + await ensureComment({ number: pr.number, topic: 'Autoclosing Skipped', content: diff --git a/lib/workers/repository/onboarding/branch/check.ts b/lib/workers/repository/onboarding/branch/check.ts index c609e41b4d7c3ab717bfeee37742d9355df11ee2..9b20c29a890174135039c9d902ed4a46174f1aa3 100644 --- a/lib/workers/repository/onboarding/branch/check.ts +++ b/lib/workers/repository/onboarding/branch/check.ts @@ -6,6 +6,7 @@ import { } from '../../../../constants/error-messages'; import { logger } from '../../../../logger'; import { platform } from '../../../../platform'; +import { ensureComment } from '../../../../platform/comment'; import { PrState } from '../../../../types'; import { getCache } from '../../../../util/cache/repository'; import { readLocalFile } from '../../../../util/fs'; @@ -110,7 +111,7 @@ export const isOnboarded = async (config: RenovateConfig): Promise<boolean> => { logger.debug('Repo is not onboarded and no merged PRs exist'); if (!config.suppressNotifications.includes('onboardingClose')) { // ensure PR comment - await platform.ensureComment({ + await ensureComment({ number: pr.number, topic: `Renovate is disabled`, content: `Renovate is disabled due to lack of config. If you wish to reenable it, you can either (a) commit a config file to your base branch, or (b) rename this closed PR to trigger a replacement onboarding PR.`, diff --git a/tsconfig.strict.json b/tsconfig.strict.json index 25201cde3a32bd02cb07d92e7212b551cf47233d..b07c7d7d963b387ea9cdfa39fb4298d9066feca1 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -309,6 +309,7 @@ "lib/platform/azure/util.ts", "lib/platform/bitbucket/index.ts", "lib/platform/bitbucket-server/index.ts", + "lib/platform/comment.ts", "lib/platform/commit.ts", "lib/platform/gitea/index.ts", "lib/platform/github/index.ts",