diff --git a/lib/config/presets/local/index.ts b/lib/config/presets/local/index.ts index 4703b51c5e00dbaf6a717401ec08662eda91ab19..81a4b97694235738da776b55de467f3cb349a552 100644 --- a/lib/config/presets/local/index.ts +++ b/lib/config/presets/local/index.ts @@ -7,11 +7,13 @@ import * as gitea from '../gitea'; import * as github from '../github'; import * as gitlab from '../gitlab'; import type { Preset, PresetConfig } from '../types'; +import * as local from './common'; const resolvers = { [PlatformId.Azure]: azure, [PlatformId.Bitbucket]: bitbucket, [PlatformId.BitbucketServer]: bitbucketServer, + [PlatformId.CodeCommit]: local, [PlatformId.Gitea]: gitea, [PlatformId.Github]: github, [PlatformId.Gitlab]: gitlab, diff --git a/lib/constants/platforms.ts b/lib/constants/platforms.ts index fd2e3ebd1dcb0a146de8ff0d708b62bc133df2f5..af2d3c8ec41de427eeea91373601b45d8122ba64 100644 --- a/lib/constants/platforms.ts +++ b/lib/constants/platforms.ts @@ -3,6 +3,7 @@ export const enum PlatformId { Azure = 'azure', Bitbucket = 'bitbucket', BitbucketServer = 'bitbucket-server', + CodeCommit = 'codecommit', Gitea = 'gitea', Github = 'github', Gitlab = 'gitlab', diff --git a/lib/modules/manager/gomod/artifacts.ts b/lib/modules/manager/gomod/artifacts.ts index 4a856c2831f70fc659a1c392e05882020a4565f6..30cf703dc2feadd8db78e9121f20bb514c8aa9ad 100644 --- a/lib/modules/manager/gomod/artifacts.ts +++ b/lib/modules/manager/gomod/artifacts.ts @@ -59,6 +59,7 @@ function getGitEnvironmentVariables(): NodeJS.ProcessEnv { PlatformId.Azure, PlatformId.Bitbucket, PlatformId.BitbucketServer, + PlatformId.CodeCommit, PlatformId.Gitea, PlatformId.Github, PlatformId.Gitlab, diff --git a/lib/modules/platform/api.ts b/lib/modules/platform/api.ts index 44df268c3d321994ffe577ad0b3da9925ecff5f0..3a0a16e3da3528c0dd03d7913776b5fd1eda0ac5 100644 --- a/lib/modules/platform/api.ts +++ b/lib/modules/platform/api.ts @@ -1,6 +1,7 @@ import * as azure from './azure'; import * as bitbucket from './bitbucket'; import * as bitbucketServer from './bitbucket-server'; +import * as codecommit from './codecommit'; import * as gitea from './gitea'; import * as github from './github'; import * as gitlab from './gitlab'; @@ -12,6 +13,7 @@ export default api; api.set('azure', azure); api.set('bitbucket', bitbucket); api.set('bitbucket-server', bitbucketServer); +api.set('codecommit', codecommit); api.set('gitea', gitea); api.set('github', github); api.set('gitlab', gitlab); diff --git a/lib/modules/platform/codecommit/codecommit-client.ts b/lib/modules/platform/codecommit/codecommit-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc427ba0854b5cdc3ae12a9d2cc2ac1b2107825d --- /dev/null +++ b/lib/modules/platform/codecommit/codecommit-client.ts @@ -0,0 +1,342 @@ +import { + CodeCommitClient, + CreatePullRequestApprovalRuleCommand, + CreatePullRequestApprovalRuleInput, + CreatePullRequestApprovalRuleOutput, + CreatePullRequestCommand, + CreatePullRequestInput, + CreatePullRequestOutput, + DeleteCommentContentCommand, + DeleteCommentContentInput, + DeleteCommentContentOutput, + DescribePullRequestEventsCommand, + DescribePullRequestEventsInput, + DescribePullRequestEventsOutput, + GetCommentsForPullRequestCommand, + GetCommentsForPullRequestInput, + GetCommentsForPullRequestOutput, + GetFileCommand, + GetFileInput, + GetFileOutput, + GetPullRequestCommand, + GetPullRequestInput, + GetPullRequestOutput, + GetRepositoryCommand, + GetRepositoryInput, + GetRepositoryOutput, + ListPullRequestsCommand, + ListPullRequestsInput, + ListPullRequestsOutput, + ListRepositoriesCommand, + ListRepositoriesInput, + ListRepositoriesOutput, + // MergeBranchesByFastForwardCommand, + // MergeBranchesByFastForwardInput, + // MergeBranchesByFastForwardOutput, + // MergeBranchesBySquashCommand, + // MergeBranchesBySquashInput, + // MergeBranchesBySquashOutput, + PostCommentForPullRequestCommand, + PostCommentForPullRequestInput, + PostCommentForPullRequestOutput, + PullRequestEventType, + PullRequestStatusEnum, + UpdateCommentCommand, + UpdateCommentInput, + UpdateCommentOutput, + UpdatePullRequestDescriptionCommand, + UpdatePullRequestDescriptionInput, + UpdatePullRequestDescriptionOutput, + UpdatePullRequestStatusCommand, + UpdatePullRequestStatusInput, + UpdatePullRequestStatusOutput, + UpdatePullRequestTitleCommand, + UpdatePullRequestTitleInput, + UpdatePullRequestTitleOutput, +} from '@aws-sdk/client-codecommit'; +import type { Credentials } from '@aws-sdk/types'; +import is from '@sindresorhus/is'; +import * as aws4 from 'aws4'; +import { REPOSITORY_UNINITIATED } from '../../../constants/error-messages'; +import { logger } from '../../../logger'; + +let codeCommitClient: CodeCommitClient; + +export function buildCodeCommitClient( + region: string, + credentials: Credentials +): void { + if (!codeCommitClient) { + codeCommitClient = new CodeCommitClient({ + region, + credentials, + }); + } + + // istanbul ignore if + if (!codeCommitClient) { + throw new Error('Failed to initialize codecommit client'); + } +} + +export async function deleteComment( + commentId: string +): Promise<DeleteCommentContentOutput> { + const input: DeleteCommentContentInput = { + commentId, + }; + const cmd = new DeleteCommentContentCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function getPrComments( + repositoryName: string, + pullRequestId: string +): Promise<GetCommentsForPullRequestOutput> { + const input: GetCommentsForPullRequestInput = { + repositoryName, + pullRequestId, + }; + const cmd = new GetCommentsForPullRequestCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function updateComment( + commentId: string, + content: string +): Promise<UpdateCommentOutput> { + const input: UpdateCommentInput = { + commentId, + content, + }; + const cmd = new UpdateCommentCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function createPrComment( + pullRequestId: string, + repositoryName: string | undefined, + content: string, + beforeCommitId: string, + afterCommitId: string +): Promise<PostCommentForPullRequestOutput> { + const input: PostCommentForPullRequestInput = { + pullRequestId, + repositoryName, + content, + afterCommitId, + beforeCommitId, + }; + const cmd = new PostCommentForPullRequestCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function getPrEvents( + pullRequestId: string +): Promise<DescribePullRequestEventsOutput> { + const input: DescribePullRequestEventsInput = { + pullRequestId, + pullRequestEventType: + PullRequestEventType.PULL_REQUEST_SOURCE_REFERENCE_UPDATED, + }; + const cmd = new DescribePullRequestEventsCommand(input); + return await codeCommitClient.send(cmd); +} + +// export async function fastForwardMerge( +// repositoryName: string, +// sourceCommitSpecifier: string, +// destinationReference: string +// ): Promise<MergeBranchesByFastForwardOutput> { +// const input: MergeBranchesByFastForwardInput = { +// repositoryName, +// sourceCommitSpecifier, +// destinationCommitSpecifier: destinationReference, +// targetBranch: destinationReference, +// }; +// const cmd = new MergeBranchesByFastForwardCommand(input); +// return await codeCommitClient.send(cmd); +// } + +// export async function squashMerge( +// repositoryName: string, +// sourceCommitSpecifier: string, +// destinationReference: string, +// commitMessage: string | undefined +// ): Promise<MergeBranchesBySquashOutput> { +// const input: MergeBranchesBySquashInput = { +// repositoryName, +// sourceCommitSpecifier, +// destinationCommitSpecifier: destinationReference, +// targetBranch: destinationReference, +// commitMessage, +// }; +// const cmd = new MergeBranchesBySquashCommand(input); +// return await codeCommitClient.send(cmd); +// } + +export async function updatePrStatus( + pullRequestId: string, + pullRequestStatus: PullRequestStatusEnum.CLOSED | PullRequestStatusEnum.OPEN +): Promise<UpdatePullRequestStatusOutput> { + const input: UpdatePullRequestStatusInput = { + pullRequestId, + pullRequestStatus, + }; + const cmd = new UpdatePullRequestStatusCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function updatePrTitle( + prNo: string, + title: string +): Promise<UpdatePullRequestTitleOutput> { + const input: UpdatePullRequestTitleInput = { + pullRequestId: `${prNo}`, + title, + }; + const cmd = new UpdatePullRequestTitleCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function updatePrDescription( + pullRequestId: string, + description: string +): Promise<UpdatePullRequestDescriptionOutput> { + const input: UpdatePullRequestDescriptionInput = { + pullRequestId, + description, + }; + const cmd = new UpdatePullRequestDescriptionCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function createPr( + title: string, + description: string, + sourceReference: string, + destinationReference: string, + repositoryName: string | undefined +): Promise<CreatePullRequestOutput> { + const input: CreatePullRequestInput = { + title, + description, + targets: [ + { + sourceReference, + destinationReference, + repositoryName, + }, + ], + }; + const cmd = new CreatePullRequestCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function getFile( + repositoryName: string | undefined, + filePath: string, + commitSpecifier: string | undefined +): Promise<GetFileOutput> { + const input: GetFileInput = { + repositoryName, + filePath, + commitSpecifier, + }; + const cmd: GetFileCommand = new GetFileCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function listPullRequests( + repositoryName: string, + authorArn: string +): Promise<ListPullRequestsOutput> { + const input: ListPullRequestsInput = { + repositoryName, + authorArn, + pullRequestStatus: PullRequestStatusEnum.OPEN, + }; + const cmd = new ListPullRequestsCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function getRepositoryInfo( + repository: string +): Promise<GetRepositoryOutput> { + const input: GetRepositoryInput = { + repositoryName: `${repository}`, + }; + const cmd = new GetRepositoryCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function getPr( + pullRequestId: string +): Promise<GetPullRequestOutput | undefined> { + const input: GetPullRequestInput = { + pullRequestId, + }; + const cmd = new GetPullRequestCommand(input); + let res; + try { + res = await codeCommitClient.send(cmd); + } catch (err) { + logger.debug({ err }, 'failed to get PR using prId'); + } + return res; +} + +export async function listRepositories(): Promise<ListRepositoriesOutput> { + const input: ListRepositoriesInput = {}; + const cmd = new ListRepositoriesCommand(input); + return await codeCommitClient.send(cmd); +} + +export async function createPrApprovalRule( + pullRequestId: string, + approvalRuleContent: string +): Promise<CreatePullRequestApprovalRuleOutput> { + const input: CreatePullRequestApprovalRuleInput = { + approvalRuleContent, + approvalRuleName: 'Reviewers By Renovate', + pullRequestId, + }; + const cmd = new CreatePullRequestApprovalRuleCommand(input); + return await codeCommitClient.send(cmd); +} + +export function getCodeCommitUrl( + region: string, + repoName: string, + credentials: Credentials +): string { + const signer = new aws4.RequestSigner( + { + service: 'codecommit', + host: `git-codecommit.${region}.amazonaws.com`, + method: 'GIT', + path: `v1/repos/${repoName}`, + }, + credentials + ); + const dateTime = signer.getDateTime(); + + /* istanbul ignore if */ + if (!is.string(dateTime)) { + throw new Error(REPOSITORY_UNINITIATED); + } + + const accessKeyId = credentials.accessKeyId; + const token = `${dateTime}Z${signer.signature()}`; + + let username = `${accessKeyId}${ + credentials.sessionToken ? `%${credentials.sessionToken}` : '' + }`; + + // massaging username with the session token, + // istanbul ignore if + if (username.includes('/')) { + username = username.replace(/\//g, '%2F'); + } + return `https://${username}:${token}@git-codecommit.${region}.amazonaws.com/v1/repos/${repoName}`; +} diff --git a/lib/modules/platform/codecommit/iam-client.spec.ts b/lib/modules/platform/codecommit/iam-client.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4782852fc97e5c8a23c3c18521579098eea4765 --- /dev/null +++ b/lib/modules/platform/codecommit/iam-client.spec.ts @@ -0,0 +1,43 @@ +import { GetUserCommand, IAMClient } from '@aws-sdk/client-iam'; +import { mockClient } from 'aws-sdk-client-mock'; +import { PLATFORM_BAD_CREDENTIALS } from '../../../constants/error-messages'; +import * as iam from './iam-client'; + +describe('modules/platform/codecommit/iam-client', () => { + const iamClient = mockClient(IAMClient); + iam.initIamClient('eu-east', { + accessKeyId: 'aaa', + secretAccessKey: 'bbb', + }); + + it('should return empty', async () => { + iamClient.on(GetUserCommand).resolves({}); + await expect(iam.getUserArn()).resolves.toMatch(''); + }); + + it('should return the user normally', async () => { + iamClient.on(GetUserCommand).resolves({ + User: { + Arn: 'aws:arn:example:123456', + UserName: 'someone', + UserId: 'something', + Path: 'somewhere', + CreateDate: new Date(), + }, + }); + await expect(iam.getUserArn()).resolves.toMatch('aws:arn:example:123456'); + }); + + it('should throw in case of bad authentication', async () => { + const err = new Error(PLATFORM_BAD_CREDENTIALS); + iamClient.on(GetUserCommand).rejects(err); + await expect(iam.getUserArn()).rejects.toThrow(err); + }); + + it('should return the user arn, even though user has no permission', async () => { + iamClient + .on(GetUserCommand) + .rejects(new Error('User: aws:arn:example:123456 has no permissions')); + await expect(iam.getUserArn()).resolves.toMatch('aws:arn:example:123456'); + }); +}); diff --git a/lib/modules/platform/codecommit/iam-client.ts b/lib/modules/platform/codecommit/iam-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..f52811006c13ab96eae579c79205d39a56a11173 --- /dev/null +++ b/lib/modules/platform/codecommit/iam-client.ts @@ -0,0 +1,52 @@ +import { + GetUserCommand, + GetUserCommandOutput, + IAMClient, +} from '@aws-sdk/client-iam'; +import type { Credentials } from '@aws-sdk/types'; +import { logger } from '../../../logger'; +import { regEx } from '../../../util/regex'; + +let iam: IAMClient; + +export function initIamClient(region: string, credentials: Credentials): void { + if (!iam) { + iam = new IAMClient({ + region, + credentials, + }); + } +} + +const userRe = regEx(/User:\s*(?<arn>[^ ]+).*/); + +/** + * This method will throw an exception only in case we have no connection + * 1) there is a connection and we return user.arn. + * 2) there is a connection but no permission for the current user, we still get his user arn in the error message + * 3) there is a problem in the connection to the aws api, then throw an error with the err + */ +export async function getUserArn(): Promise<string> { + const cmd = new GetUserCommand({}); + let res; + try { + const userRes: GetUserCommandOutput = await iam.send(cmd); + res = userRes?.User?.Arn; + } catch (err) { + const match = userRe.exec(err.message); + if (match) { + logger.warn( + 'It is recommended to add "IAMReadOnlyAccess" policy to this IAM user' + ); + res = match.groups?.arn; + } + if (!res) { + logger.warn( + 'Failed to get IAM user info, Make sure your user has "IAMReadOnlyAccess" policy' + ); + throw err; + } + } + + return res ?? ''; +} diff --git a/lib/modules/platform/codecommit/index.md b/lib/modules/platform/codecommit/index.md new file mode 100644 index 0000000000000000000000000000000000000000..8df6f9d61151f04842432b498996a9b196a79cbd --- /dev/null +++ b/lib/modules/platform/codecommit/index.md @@ -0,0 +1,65 @@ +# AWS CodeCommit + +<!-- prettier-ignore --> +!!! warning "This feature is flagged as experimental" + Experimental features might be changed or even removed at any time, To track this feature visit the following GitHub issue [#2868](https://github.com/renovatebot/renovate/issues/2868) + +## Authentication + +First, you need to obtain an AWS [IAM Access Key id and a Secret access key id](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html) + +Let Renovate use AWS CodeCommit access keys by doing one of the following: + +1. Set a Renovate configuration file - config.js and set: + + - `endpoint:` the url endpoint e.g `https://git-codecommit.us-east-1.amazonaws.com/` + - `username:` AWS IAM access key id + - `password:` AWS Secret access key + +2. Set environment variables: + - `AWS_REGION:` the region e.g `us-east-1` + - `AWS_ACCESS_KEY_ID:` your IAM Access key id + - `AWS_SECRET_ACCESS_KEY:` your IAM Secret access key id + +--- + +- `AWS_SESSION_TOKEN`: your AWS Session token if you have one + +## AWS IAM security policies + +- Make sure to attach the [AWSCodeCommitFullAccess](https://docs.aws.amazon.com/codecommit/latest/userguide/security-iam-awsmanpol.html#managed-policies-full) policy to your IAM User. +- It is recommended to also attach the [IAMReadOnlyAccess](https://docs.aws.amazon.com/IAM/latest/UserGuide/security-iam-awsmanpol.html) policy to your IAM User + +## Running Renovate + +Set up a global configuration file (config.js) for running Renovate on CodeCommit: + +- Set `platform: 'codecommit'` +- Set `repositories: ['{repository names separated by comma}']`, or alternatively use Renovate’s [autodiscover](https://docs.renovatebot.com/self-hosted-configuration/#autodiscover) + +Run Renovate with the configuration file, and it will create an onboarding Pull Request in your set repositories. + +## Unsupported platform features/concepts + +- adding assignees to PRs not supported +- auto-merge not supported +- rebaseLabel isn't supported (request a rebase for Renovate) + +## recommendations + +- We recommend limiting Open Renovate PRs using `prConcurrentLimit` +- Due to current platform limitations, if you close a PR and don’t wish for Renovate to recreate if, use [package rules](https://docs.renovatebot.com/configuration-options/#packagerules) with the `"enabled": false` key. + +Here's an example config.js: + +```js +module.exports = { + endpoint: 'https://git-codecommit.us-east-1.amazonaws.com/', + platform: 'codecommit', + repositories: ['abc/def', 'abc/ghi'], + username: 'ACCESS_KEY_ID_GOES_HERE', + password: 'SECRET_ACCESS_KEY_GOES_HERE', + gitAuthor: 'your_email@domain', + prConcurrentLimit: 10, +}; +``` diff --git a/lib/modules/platform/codecommit/index.spec.ts b/lib/modules/platform/codecommit/index.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2eb1aa31cb74f84dac825a68696241c36786ad3a --- /dev/null +++ b/lib/modules/platform/codecommit/index.spec.ts @@ -0,0 +1,1301 @@ +import { + CodeCommitClient, + CreatePullRequestApprovalRuleCommand, + CreatePullRequestCommand, + DeleteCommentContentCommand, + DescribePullRequestEventsCommand, + GetCommentsForPullRequestCommand, + GetFileCommand, + GetPullRequestCommand, + GetRepositoryCommand, + ListPullRequestsCommand, + ListRepositoriesCommand, + // MergeBranchesBySquashCommand, + PostCommentForPullRequestCommand, + UpdatePullRequestDescriptionCommand, + UpdatePullRequestStatusCommand, + UpdatePullRequestTitleCommand, +} from '@aws-sdk/client-codecommit'; +import { GetUserCommand, IAMClient } from '@aws-sdk/client-iam'; +import { mockClient } from 'aws-sdk-client-mock'; +import { logger } from '../../../../test/util'; +import { + PLATFORM_BAD_CREDENTIALS, + REPOSITORY_EMPTY, + REPOSITORY_NOT_FOUND, +} from '../../../constants/error-messages'; +import { PrState } from '../../../types'; +import * as git from '../../../util/git'; +import type { Platform } from '../types'; +import { CodeCommitPr, config } from './index'; + +const codeCommitClient = mockClient(CodeCommitClient); +const iamClient = mockClient(IAMClient); + +describe('modules/platform/codecommit/index', () => { + let codeCommit: Platform; + + beforeAll(async () => { + codeCommit = await import('.'); + iamClient.on(GetUserCommand).resolves({ + User: { + Arn: 'aws:arn:example:123456', + UserName: 'someone', + UserId: 'something', + Path: 'somewhere', + CreateDate: new Date(), + }, + }); + await codeCommit.initPlatform({ + endpoint: 'https://git-codecommit.eu-central-1.amazonaws.com/', + username: 'accessKeyId', + password: 'SecretAccessKey', + }); + }); + + beforeEach(() => { + codeCommitClient.reset(); + config.prList = undefined; + config.repository = undefined; + }); + + it('validates massageMarkdown functionality', () => { + const newStr = codeCommit.massageMarkdown( + '<details><summary>foo</summary>bar</details>text<details>\n<!--renovate-debug:hiddenmessage123-->' + ); + expect(newStr).toBe( + '**foo**bartext\n[//]: # (<!--renovate-debug:hiddenmessage123-->)' + ); + }); + + describe('initPlatform()', () => { + it('should throw if no username/password', async () => { + const err = new Error( + 'Init: You must configure a AWS user(accessKeyId), password(secretAccessKey) and endpoint/AWS_REGION' + ); + await expect(codeCommit.initPlatform({})).rejects.toThrow(err); + }); + + it('should show warning message if custom endpoint', async () => { + const err = new Error( + 'Init: You must configure a AWS user(accessKeyId), password(secretAccessKey) and endpoint/AWS_REGION' + ); + await expect( + codeCommit.initPlatform({ + endpoint: 'endpoint', + username: 'abc', + password: '123', + }) + ).rejects.toThrow(err); + + expect(logger.logger.warn).toHaveBeenCalledWith( + "Can't parse region, make sure your endpoint is correct" + ); + }); + + it('should init', async () => { + expect( + await codeCommit.initPlatform({ + endpoint: 'https://git-codecommit.REGION.amazonaws.com/', + username: 'abc', + password: '123', + }) + ).toEqual({ + endpoint: 'https://git-codecommit.REGION.amazonaws.com/', + }); + }); + + it('should init with env vars', async () => { + const temp = process.env.AWS_REGION; + process.env.AWS_REGION = 'REGION'; + await expect( + codeCommit.initPlatform({ + username: 'abc', + password: '123', + }) + ).resolves.toEqual({ + endpoint: 'https://git-codecommit.REGION.amazonaws.com/', + }); + process.env.AWS_REGION = temp; + }); + }); + + describe('initRepos()', () => { + it('fails to git.initRepo', async () => { + jest.spyOn(git, 'initRepo').mockImplementationOnce(() => { + throw new Error('any error'); + }); + codeCommitClient.on(GetRepositoryCommand).resolvesOnce({}); + + await expect( + codeCommit.initRepo({ repository: 'repositoryName' }) + ).rejects.toThrow(new Error(PLATFORM_BAD_CREDENTIALS)); + }); + + it('fails on getRepositoryInfo', async () => { + jest.spyOn(git, 'initRepo').mockReturnValueOnce(Promise.resolve()); + codeCommitClient + .on(GetRepositoryCommand) + .rejectsOnce(new Error('Could not find repository')); + await expect( + codeCommit.initRepo({ repository: 'repositoryName' }) + ).rejects.toThrow(new Error(REPOSITORY_NOT_FOUND)); + }); + + it('getRepositoryInfo returns bad results', async () => { + jest.spyOn(git, 'initRepo').mockReturnValueOnce(Promise.resolve()); + codeCommitClient.on(GetRepositoryCommand).resolvesOnce({}); + await expect( + codeCommit.initRepo({ repository: 'repositoryName' }) + ).rejects.toThrow(new Error(REPOSITORY_NOT_FOUND)); + }); + + it('getRepositoryInfo returns bad results 2', async () => { + jest.spyOn(git, 'initRepo').mockReturnValueOnce(Promise.resolve()); + codeCommitClient + .on(GetRepositoryCommand) + .resolvesOnce({ repositoryMetadata: {} }); + await expect( + codeCommit.initRepo({ repository: 'repositoryName' }) + ).rejects.toThrow(new Error(REPOSITORY_EMPTY)); + }); + + it('initiates repo successfully', async () => { + jest.spyOn(git, 'initRepo').mockReturnValueOnce(Promise.resolve()); + codeCommitClient.on(GetRepositoryCommand).resolvesOnce({ + repositoryMetadata: { + defaultBranch: 'main', + repositoryId: 'id', + }, + }); + await expect( + codeCommit.initRepo({ repository: 'repositoryName' }) + ).resolves.toEqual({ + repoFingerprint: + 'f0bcfd81abefcdf9ae5e5de58d1a868317503ea76422309bc212d1ef25a1e67789d0bfa752a7e2abd4510f4f3e4f60cdaf6202a42883fb97bb7110ab3600785e', + defaultBranch: 'main', + isFork: false, + }); + }); + }); + + describe('getRepos()', () => { + it('returns repos', async () => { + const result = { + repositories: [ + { + repositoryId: 'id', + repositoryName: 'repoName', + }, + ], + }; + codeCommitClient.on(ListRepositoriesCommand).resolvesOnce(result); + + const res = await codeCommit.getRepos(); + expect(res).toEqual(['repoName']); + }); + + it('returns empty if error', async () => { + codeCommitClient + .on(ListRepositoriesCommand) + .rejectsOnce(new Error('something')); + const res = await codeCommit.getRepos(); + expect(res).toEqual([]); + }); + }); + + describe('getRepoForceRebase()', () => { + it('Always return false, since CodeCommit does not support force rebase', async () => { + const actual = await codeCommit.getRepoForceRebase(); + expect(actual).toBeFalse(); + }); + }); + + describe('getPrList()', () => { + it('gets PR list by author', async () => { + codeCommitClient + .on(ListPullRequestsCommand) + .resolvesOnce({ pullRequestIds: ['1', '2'] }); + const prRes = { + pullRequest: { + title: 'someTitle', + pullRequestStatus: 'OPEN', + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + }, + ], + }, + }; + codeCommitClient.on(GetPullRequestCommand).resolvesOnce({}); + codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + const res = await codeCommit.getPrList(); + expect(res).toMatchObject([ + { + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'open', + number: 1, + title: 'someTitle', + }, + ]); + codeCommitClient + .on(GetPullRequestCommand) + .rejectsOnce(new Error('failed connection')); + // test cache + const res2 = await codeCommit.getPrList(); + expect(res2).toMatchObject([ + { + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'open', + number: 1, + title: 'someTitle', + }, + ]); + }); + + it('checks if nullcheck works for list prs', async () => { + codeCommitClient.on(ListPullRequestsCommand).resolvesOnce({}); + const res = await codeCommit.getPrList(); + expect(res).toEqual([]); + }); + }); + + describe('findPr()', () => { + it('throws error on findPr', async () => { + const err = new Error('failed'); + codeCommitClient.on(ListPullRequestsCommand).rejectsOnce(err); + const res = await codeCommit.findPr({ + branchName: 'sourceBranch', + prTitle: 'someTitle', + state: PrState.Open, + }); + expect(res).toBeNull(); + expect(logger.logger.error).toHaveBeenCalledWith({ err }, 'findPr error'); + }); + + it('finds pr', async () => { + codeCommitClient + .on(ListPullRequestsCommand) + .resolvesOnce({ pullRequestIds: ['1'] }); + const prRes = { + pullRequest: { + title: 'someTitle', + pullRequestStatus: 'OPEN', + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + }, + ], + }, + }; + codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + const res = await codeCommit.findPr({ + branchName: 'sourceBranch', + prTitle: 'someTitle', + state: PrState.Open, + }); + expect(res).toMatchObject({ + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'open', + number: 1, + title: 'someTitle', + }); + }); + + it('finds any pr with that title in regardless of state', async () => { + codeCommitClient + .on(ListPullRequestsCommand) + .resolvesOnce({ pullRequestIds: ['1'] }); + const prRes = { + pullRequest: { + title: 'someTitle', + pullRequestStatus: 'OPEN', + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + }, + ], + }, + }; + codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + const res = await codeCommit.findPr({ + branchName: 'sourceBranch', + prTitle: 'someTitle', + state: PrState.All, + }); + expect(res).toMatchObject({ + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'open', + number: 1, + title: 'someTitle', + }); + }); + + it('finds closed/merged pr', async () => { + codeCommitClient + .on(ListPullRequestsCommand) + .resolvesOnce({ pullRequestIds: ['1'] }); + const prRes = { + pullRequest: { + title: 'someTitle', + pullRequestStatus: PrState.NotOpen, + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + }, + ], + }, + }; + codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + const res = await codeCommit.findPr({ + branchName: 'sourceBranch', + prTitle: 'someTitle', + state: PrState.NotOpen, + }); + expect(res).toMatchObject({ + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'closed', + number: 1, + title: 'someTitle', + }); + }); + + it('finds any pr', async () => { + codeCommitClient + .on(ListPullRequestsCommand) + .resolvesOnce({ pullRequestIds: ['1'] }); + const prRes = { + pullRequest: { + title: 'someTitle', + pullRequestStatus: PrState.Closed, + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + }, + ], + }, + }; + codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + const res = await codeCommit.findPr({ + branchName: 'sourceBranch', + prTitle: 'someTitle', + }); + expect(res).toMatchObject({ + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'closed', + number: 1, + title: 'someTitle', + }); + }); + + it('returns empty list in case prs dont exist yet', async () => { + const res = await codeCommit.findPr({ + branchName: 'sourceBranch', + prTitle: 'someTitle', + state: PrState.Open, + }); + expect(res).toBeNull(); + }); + }); + + describe('getBranchPr()', () => { + it('codecommit find PR for branch', async () => { + codeCommitClient + .on(ListPullRequestsCommand) + .resolvesOnce({ pullRequestIds: ['1'] }); + const prRes = { + pullRequest: { + title: 'someTitle', + pullRequestStatus: 'OPEN', + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + }, + ], + }, + }; + codeCommitClient.on(GetPullRequestCommand).resolves(prRes); + const res = await codeCommit.getBranchPr('sourceBranch'); + expect(res).toMatchObject({ + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'open', + number: 1, + title: 'someTitle', + }); + }); + + it('returns null if no PR for branch', async () => { + codeCommitClient + .on(ListPullRequestsCommand) + .resolvesOnce({ pullRequestIds: ['1'] }); + const prRes = { + pullRequest: { + title: 'someTitle', + pullRequestStatus: 'OPEN', + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + }, + ], + }, + }; + codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + const res = await codeCommit.getBranchPr('branch_without_pr'); + expect(res).toBeNull(); + }); + }); + + describe('getPr()', () => { + it('gets pr', async () => { + const prRes = { + pullRequest: { + title: 'someTitle', + description: 'body', + pullRequestStatus: 'OPEN', + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + }, + ], + }, + }; + + codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + + const res = await codeCommit.getPr(1); + expect(res).toMatchObject({ + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'open', + number: 1, + title: 'someTitle', + }); + }); + + it('gets closed pr', async () => { + const prRes = { + pullRequest: { + title: 'someTitle', + pullRequestStatus: 'CLOSED', + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + }, + ], + }, + }; + + codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + + const res = await codeCommit.getPr(1); + expect(res).toMatchObject({ + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'closed', + number: 1, + title: 'someTitle', + }); + }); + + it('gets merged pr', async () => { + const prRes = { + pullRequest: { + title: 'someTitle', + pullRequestStatus: 'OPEN', + pullRequestTargets: [ + { + sourceReference: 'refs/heads/sourceBranch', + destinationReference: 'refs/heads/targetBranch', + mergeMetadata: { + isMerged: true, + }, + }, + ], + }, + }; + + codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + + const res = await codeCommit.getPr(1); + expect(res).toMatchObject({ + sourceBranch: 'refs/heads/sourceBranch', + targetBranch: 'refs/heads/targetBranch', + state: 'merged', + number: 1, + title: 'someTitle', + }); + }); + + it('returns null in case input is null', async () => { + codeCommitClient + .on(GetPullRequestCommand) + .rejectsOnce(new Error('bad creds')); + const res = await codeCommit.getPr(1); + expect(res).toBeNull(); + }); + }); + + describe('getJsonFile()', () => { + it('returns file content', async () => { + const data = { foo: 'bar' }; + const uint8arrData = new Uint8Array(Buffer.from(JSON.stringify(data))); + codeCommitClient + .on(GetFileCommand) + .resolvesOnce({ fileContent: uint8arrData }); + const res = await codeCommit.getJsonFile('file.json'); + expect(res).toEqual(data); + }); + + it('returns file content in json5 format', async () => { + const json5Data = ` + { + // json5 comment + foo: 'bar' + } + `; + const uint8arrData = new Uint8Array(Buffer.from(json5Data)); + codeCommitClient + .on(GetFileCommand) + .resolvesOnce({ fileContent: uint8arrData }); + const res = await codeCommit.getJsonFile('file.json'); + expect(res).toEqual({ foo: 'bar' }); + }); + }); + + describe('getRawFile()', () => { + it('returns file content', async () => { + const data = { foo: 'bar' }; + const uint8arrData = new Uint8Array(Buffer.from(JSON.stringify(data))); + codeCommitClient + .on(GetFileCommand) + .resolvesOnce({ fileContent: uint8arrData }); + const res = await codeCommit.getRawFile('file.json'); + expect(res).toBe('{"foo":"bar"}'); + }); + + it('returns null', async () => { + codeCommitClient + .on(GetFileCommand) + .resolvesOnce({ fileContent: undefined }); + const res = await codeCommit.getRawFile('file.json'); + expect(res).toBeNull(); + }); + + it('returns file content in json5 format', async () => { + const json5Data = ` + { + // json5 comment + foo: 'bar' + } + `; + const uint8arrData = new Uint8Array(Buffer.from(json5Data)); + codeCommitClient + .on(GetFileCommand) + .resolvesOnce({ fileContent: uint8arrData }); + const res = await codeCommit.getRawFile('file.json'); + expect(res).toBe(` + { + // json5 comment + foo: 'bar' + } + `); + }); + }); + + describe('createPr()', () => { + it('posts PR', async () => { + const prRes = { + pullRequest: { + pullRequestId: '1', + pullRequestStatus: 'OPEN', + title: 'someTitle', + description: 'mybody', + }, + }; + + codeCommitClient.on(CreatePullRequestCommand).resolvesOnce(prRes); + const pr = await codeCommit.createPr({ + sourceBranch: 'sourceBranch', + targetBranch: 'targetBranch', + prTitle: 'mytitle', + prBody: 'mybody', + }); + + expect(pr).toMatchObject({ + number: 1, + state: 'open', + title: 'someTitle', + sourceBranch: 'sourceBranch', + targetBranch: 'targetBranch', + sourceRepo: undefined, + body: 'mybody', + }); + }); + + it('doesnt return a title', async () => { + const prRes = { + pullRequest: { + pullRequestId: '1', + pullRequestStatus: 'OPEN', + }, + }; + + codeCommitClient.on(CreatePullRequestCommand).resolvesOnce(prRes); + + await expect( + codeCommit.createPr({ + sourceBranch: 'sourceBranch', + targetBranch: 'targetBranch', + prTitle: 'mytitle', + prBody: 'mybody', + }) + ).rejects.toThrow(new Error('Could not create pr, missing PR info')); + }); + }); + + describe('updatePr()', () => { + it('updates PR', async () => { + codeCommitClient.on(UpdatePullRequestDescriptionCommand).resolvesOnce({}); + codeCommitClient.on(UpdatePullRequestTitleCommand).resolvesOnce({}); + codeCommitClient.on(UpdatePullRequestStatusCommand).resolvesOnce({}); + await expect( + codeCommit.updatePr({ + number: 1, + prTitle: 'title', + prBody: 'body', + state: PrState.Open, + }) + ).toResolve(); + }); + + it('updates PR body if cache is not the same', async () => { + config.prList = []; + const pr: CodeCommitPr = { + number: 1, + state: 'open', + title: 'someTitle', + sourceBranch: 'sourceBranch', + targetBranch: 'targetBranch', + sourceRepo: undefined, + body: 'some old description', + }; + config.prList.push(pr); + codeCommitClient.on(UpdatePullRequestDescriptionCommand).resolvesOnce({}); + codeCommitClient.on(UpdatePullRequestTitleCommand).resolvesOnce({}); + codeCommitClient.on(UpdatePullRequestStatusCommand).resolvesOnce({}); + await expect( + codeCommit.updatePr({ + number: 1, + prTitle: 'title', + prBody: 'new description', + state: PrState.Open, + }) + ).toResolve(); + }); + + it('updates PR body does not update if cache is the same', async () => { + config.prList = []; + const pr: CodeCommitPr = { + number: 1, + state: 'open', + title: 'someTitle', + sourceBranch: 'sourceBranch', + targetBranch: 'targetBranch', + sourceRepo: undefined, + body: 'new description', + }; + config.prList.push(pr); + codeCommitClient.on(UpdatePullRequestTitleCommand).resolvesOnce({}); + codeCommitClient.on(UpdatePullRequestStatusCommand).resolvesOnce({}); + await expect( + codeCommit.updatePr({ + number: 1, + prTitle: 'title', + prBody: 'new description', + state: PrState.Open, + }) + ).toResolve(); + }); + + it('updates PR regardless of status failure', async () => { + codeCommitClient.on(UpdatePullRequestDescriptionCommand).resolvesOnce({}); + codeCommitClient.on(UpdatePullRequestTitleCommand).resolvesOnce({}); + codeCommitClient + .on(UpdatePullRequestStatusCommand) + .rejectsOnce(new Error('update status failure')); + await expect( + codeCommit.updatePr({ + number: 1, + prTitle: 'title', + prBody: 'body', + state: PrState.Open, + }) + ).toResolve(); + }); + + it('updates PR with status closed', async () => { + codeCommitClient.on(UpdatePullRequestDescriptionCommand).resolvesOnce({}); + codeCommitClient.on(UpdatePullRequestTitleCommand).resolvesOnce({}); + codeCommitClient.on(UpdatePullRequestStatusCommand).resolvesOnce({}); + await expect( + codeCommit.updatePr({ + number: 1, + prTitle: 'title', + prBody: 'body', + state: PrState.Closed, + }) + ).toResolve(); + }); + }); + + // eslint-disable-next-line jest/no-commented-out-tests + // describe('mergePr()', () => { + // eslint-disable-next-line jest/no-commented-out-tests + // it('checks that rebase is not supported', async () => { + // expect( + // await codeCommit.mergePr({ + // branchName: 'branch', + // id: 1, + // strategy: 'rebase', + // }) + // ).toBeFalse(); + // }); + + // eslint-disable-next-line jest/no-commented-out-tests + // it('posts Merge with auto', async () => { + // const prRes = { + // pullRequest: { + // title: 'someTitle', + // pullRequestStatus: 'OPEN', + // pullRequestTargets: [ + // { + // sourceReference: 'refs/heads/sourceBranch', + // destinationReference: 'refs/heads/targetBranch', + // }, + // ], + // }, + // }; + // codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + // codeCommitClient.on(MergeBranchesBySquashCommand).resolvesOnce({}); + // + // const updateStatusRes = { + // pullRequest: { + // pullRequestStatus: 'OPEN', + // }, + // }; + // codeCommitClient + // .on(UpdatePullRequestStatusCommand) + // .resolvesOnce(updateStatusRes); + // expect( + // await codeCommit.mergePr({ + // branchName: 'branch', + // id: 1, + // strategy: 'auto', + // }) + // ).toBeTrue(); + // }); + // + // eslint-disable-next-line jest/no-commented-out-tests + // it('posts Merge with squash', async () => { + // const prRes = { + // pullRequest: { + // title: 'someTitle', + // pullRequestStatus: 'OPEN', + // pullRequestTargets: [ + // { + // sourceReference: 'refs/heads/sourceBranch', + // destinationReference: 'refs/heads/targetBranch', + // }, + // ], + // }, + // }; + // codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + // codeCommitClient.on(MergeBranchesBySquashCommand).resolvesOnce({}); + // const updateStatusRes = { + // pullRequest: { + // pullRequestStatus: 'OPEN', + // }, + // }; + // codeCommitClient + // .on(UpdatePullRequestStatusCommand) + // .resolvesOnce(updateStatusRes); + // expect( + // await codeCommit.mergePr({ + // branchName: 'branch', + // id: 5, + // strategy: 'squash', + // }) + // ).toBeTrue(); + // }); + + // eslint-disable-next-line jest/no-commented-out-tests + // it('posts Merge with fast-forward', async () => { + // const prRes = { + // pullRequest: { + // title: 'someTitle', + // pullRequestStatus: 'OPEN', + // pullRequestTargets: [ + // { + // sourceReference: 'refs/heads/sourceBranch', + // destinationReference: 'refs/heads/targetBranch', + // }, + // ], + // }, + // }; + // codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + // codeCommitClient.on(MergeBranchesBySquashCommand).resolvesOnce({}); + // const updateStatusRes = { + // pullRequest: { + // pullRequestStatus: 'OPEN', + // }, + // }; + // codeCommitClient + // .on(UpdatePullRequestStatusCommand) + // .resolvesOnce(updateStatusRes); + // expect( + // await codeCommit.mergePr({ + // branchName: 'branch', + // id: 1, + // strategy: 'fast-forward', + // }) + // ).toBe(true); + // }); + + // eslint-disable-next-line jest/no-commented-out-tests + // it('checks that merge-commit is not supported', async () => { + // const prRes = { + // pullRequest: { + // title: 'someTitle', + // pullRequestStatus: 'OPEN', + // pullRequestTargets: [ + // { + // sourceReference: 'refs/heads/sourceBranch', + // destinationReference: 'refs/heads/targetBranch', + // }, + // ], + // }, + // }; + // codeCommitClient.on(GetPullRequestCommand).resolvesOnce(prRes); + // expect( + // await codeCommit.mergePr({ + // branchName: 'branch', + // id: 1, + // strategy: 'merge-commit', + // }) + // ).toBeFalse(); + // }); + // }); + + describe('ensureComment', () => { + it('adds comment if missing', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + comments: [ + { + commentId: '1', + content: 'my comment content', + }, + ], + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + + const eventsRes = { + pullRequestEvents: [ + { + pullRequestSourceReferenceUpdatedEventMetadata: { + beforeCommitId: 'beforeCid', + afterCommitId: 'afterCid', + }, + }, + ], + }; + codeCommitClient + .on(DescribePullRequestEventsCommand) + .resolvesOnce(eventsRes); + codeCommitClient.on(PostCommentForPullRequestCommand).resolvesOnce({}); + const res = await codeCommit.ensureComment({ + number: 42, + topic: 'some-subject', + content: 'some\ncontent', + }); + expect(res).toBeTrue(); + expect(logger.logger.info).toHaveBeenCalledWith( + { repository: undefined, prNo: 42, topic: 'some-subject' }, + 'Comment added' + ); + }); + + it('updates comment if different content', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + comments: [ + { + commentId: '1', + content: '### some-subject\n\n - my comment content', + }, + ], + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + codeCommitClient.on(PostCommentForPullRequestCommand).resolvesOnce({}); + + const res = await codeCommit.ensureComment({ + number: 42, + topic: 'some-subject', + content: 'some\ncontent', + }); + expect(res).toBeTrue(); + expect(logger.logger.debug).toHaveBeenCalledWith( + { repository: undefined, prNo: 42, topic: 'some-subject' }, + 'Comment updated' + ); + }); + + it('does nothing if comment exists and is the same', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + comments: [ + { + commentId: '1', + content: '### some-subject\n\nmy comment content', + }, + ], + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + const res = await codeCommit.ensureComment({ + number: 42, + topic: 'some-subject', + content: 'my comment content', + }); + expect(res).toBeTrue(); + expect(logger.logger.debug).toHaveBeenCalledWith( + { repository: undefined, prNo: 42, topic: 'some-subject' }, + 'Comment is already update-to-date' + ); + }); + + it('does nothing if comment exists and is the same when there is no topic', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + comments: [ + { + commentId: '1', + content: 'my comment content', + }, + ], + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + const res = await codeCommit.ensureComment({ + number: 42, + topic: null, + content: 'my comment content', + }); + expect(res).toBeTrue(); + expect(logger.logger.debug).toHaveBeenCalledWith( + { repository: undefined, prNo: 42, topic: null }, + 'Comment is already update-to-date' + ); + }); + + it('throws an exception in case of api failed connection ', async () => { + const err = new Error('some error'); + codeCommitClient.on(GetCommentsForPullRequestCommand).rejectsOnce(err); + + const res = await codeCommit.ensureComment({ + number: 42, + topic: null, + content: 'my comment content', + }); + expect(res).toBeFalse(); + expect(logger.logger.debug).toHaveBeenCalledWith( + { err }, + 'Unable to retrieve pr comments' + ); + }); + + it('fails at null check for response', async () => { + codeCommitClient.on(GetCommentsForPullRequestCommand).resolvesOnce({}); + const res = await codeCommit.ensureComment({ + number: 42, + topic: null, + content: 'my comment content', + }); + expect(res).toBeFalse(); + }); + + it('doesnt find comments obj', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + const res = await codeCommit.ensureComment({ + number: 42, + topic: null, + content: 'my comment content', + }); + expect(res).toBeFalse(); + }); + + it('doesnt find events data', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + codeCommitClient.on(DescribePullRequestEventsCommand).resolvesOnce({}); + codeCommitClient.on(PostCommentForPullRequestCommand).resolvesOnce({}); + const res = await codeCommit.ensureComment({ + number: 42, + topic: null, + content: 'my comment content', + }); + + expect(res).toBeFalse(); + }); + + it('doesnt find event before/after', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + const eventsRes = { + pullRequestEvents: [ + { + pullRequestSourceReferenceUpdatedEventMetadata: {}, + }, + ], + }; + codeCommitClient + .on(DescribePullRequestEventsCommand) + .resolvesOnce(eventsRes); + codeCommitClient.on(PostCommentForPullRequestCommand).resolvesOnce({}); + const res = await codeCommit.ensureComment({ + number: 42, + topic: null, + content: 'my comment content', + }); + + expect(res).toBeFalse(); + }); + }); + + describe('ensureCommentRemoval', () => { + it('deletes comment by topic if found', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + comments: [ + { + commentId: '1', + content: '### some-subject\n\nmy comment content', + }, + ], + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + codeCommitClient.on(DeleteCommentContentCommand).resolvesOnce({}); + await codeCommit.ensureCommentRemoval({ + type: 'by-topic', + number: 42, + topic: 'some-subject', + }); + expect(logger.logger.debug).toHaveBeenCalledWith( + 'comment "some-subject" in PR #42 was removed' + ); + }); + + it('doesnt find commentsForPullRequestData', async () => { + codeCommitClient.on(GetCommentsForPullRequestCommand).resolvesOnce({}); + codeCommitClient.on(DeleteCommentContentCommand).resolvesOnce({}); + await codeCommit.ensureCommentRemoval({ + type: 'by-topic', + number: 42, + topic: 'some-subject', + }); + expect(logger.logger.debug).toHaveBeenCalledWith( + 'commentsForPullRequestData not found' + ); + }); + + it('doesnt find comment obj', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + codeCommitClient.on(DeleteCommentContentCommand).resolvesOnce({}); + await codeCommit.ensureCommentRemoval({ + type: 'by-topic', + number: 42, + topic: 'some-subject', + }); + expect(logger.logger.debug).toHaveBeenCalledWith( + 'comments object not found under commentsForPullRequestData' + ); + }); + + it('deletes comment by content if found', async () => { + const commentsRes = { + commentsForPullRequestData: [ + { + pullRequestId: '1', + repositoryName: 'someRepo', + beforeCommitId: 'beforeCommitId', + afterCommitId: 'afterCommitId', + comments: [ + { + commentId: '1', + content: 'my comment content', + }, + ], + }, + ], + }; + codeCommitClient + .on(GetCommentsForPullRequestCommand) + .resolvesOnce(commentsRes); + codeCommitClient.on(DeleteCommentContentCommand).resolvesOnce({}); + await codeCommit.ensureCommentRemoval({ + type: 'by-content', + number: 42, + content: 'my comment content', + }); + expect(logger.logger.debug).toHaveBeenCalledWith( + 'comment "my comment content" in PR #42 was removed' + ); + }); + + it('throws exception in case failed api connection', async () => { + const err = new Error('some error'); + codeCommitClient.on(GetCommentsForPullRequestCommand).rejectsOnce(err); + await codeCommit.ensureCommentRemoval({ + type: 'by-content', + number: 42, + content: 'my comment content', + }); + expect(logger.logger.debug).toHaveBeenCalledWith( + { err }, + 'Unable to retrieve pr comments' + ); + }); + }); + + describe('addReviewers', () => { + it('checks that the function resolves', async () => { + const res = { + approvalRule: { + approvalRuleName: 'Assignees By Renovate', + lastModifiedDate: new Date(), + ruleContentSha256: '7c44e6ebEXAMPLE', + creationDate: new Date(), + approvalRuleId: 'aac33506-EXAMPLE', + approvalRuleContent: + '{"Version": "2018-11-08","Statements": [{"Type": "Approvers","NumberOfApprovalsNeeded": 1,"ApprovalPoolMembers": ["arn:aws:iam::someUser:user/ReviewerUser"]}]}', + lastModifiedUser: 'arn:aws:iam::someUser:user/ReviewerUser', + }, + }; + codeCommitClient + .on(CreatePullRequestApprovalRuleCommand) + .resolvesOnce(res); + await expect( + codeCommit.addReviewers(13, ['arn:aws:iam::someUser:user/ReviewerUser']) + ).toResolve(); + expect(logger.logger.debug).toHaveBeenCalledWith( + res, + 'Approval Rule Added to PR #13:' + ); + }); + }); +}); diff --git a/lib/modules/platform/codecommit/index.ts b/lib/modules/platform/codecommit/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..82a82c14c0cff7e8e8090b7be2143b379699fa0e --- /dev/null +++ b/lib/modules/platform/codecommit/index.ts @@ -0,0 +1,745 @@ +import { Buffer } from 'buffer'; +import { + GetCommentsForPullRequestOutput, + ListRepositoriesOutput, + PullRequestStatusEnum, +} from '@aws-sdk/client-codecommit'; +import type { Credentials } from '@aws-sdk/types'; +import JSON5 from 'json5'; + +import { + PLATFORM_BAD_CREDENTIALS, + REPOSITORY_EMPTY, + REPOSITORY_NOT_FOUND, +} from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import { BranchStatus, PrState, VulnerabilityAlert } from '../../../types'; +import * as git from '../../../util/git'; +import { regEx } from '../../../util/regex'; +import { sanitize } from '../../../util/sanitize'; +import type { + BranchStatusConfig, + CreatePRConfig, + EnsureCommentConfig, + EnsureCommentRemovalConfig, + EnsureIssueConfig, + EnsureIssueResult, + FindPRConfig, + Issue, + MergePRConfig, + PlatformParams, + PlatformResult, + Pr, + RepoParams, + RepoResult, + UpdatePrConfig, +} from '../types'; +import { getNewBranchName, repoFingerprint } from '../util'; +import { smartTruncate } from '../utils/pr-body'; +import { getCodeCommitUrl } from './codecommit-client'; +import * as client from './codecommit-client'; +import { getUserArn, initIamClient } from './iam-client'; + +export interface CodeCommitPr extends Pr { + body: string; +} + +interface Config { + repository?: string; + defaultBranch?: string; + region?: string; + prList?: CodeCommitPr[]; + credentials?: Credentials; + userArn?: string; +} + +export const config: Config = {}; + +export async function initPlatform({ + endpoint, + username, + password, +}: PlatformParams): Promise<PlatformResult> { + let accessKeyId = username; + let secretAccessKey = password; + let region; + + if (!accessKeyId) { + accessKeyId = process.env.AWS_ACCESS_KEY_ID; + } + if (!secretAccessKey) { + secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + } + if (endpoint) { + const regionReg = regEx(/.*codecommit\.(?<region>.+)\.amazonaws\.com/); + const codeCommitMatch = regionReg.exec(endpoint); + region = codeCommitMatch?.groups?.region; + if (!region) { + logger.warn("Can't parse region, make sure your endpoint is correct"); + } + } else { + region = process.env.AWS_REGION; + } + + if (!accessKeyId || !secretAccessKey || !region) { + throw new Error( + 'Init: You must configure a AWS user(accessKeyId), password(secretAccessKey) and endpoint/AWS_REGION' + ); + } + + config.region = region; + const credentials: Credentials = { + accessKeyId, + secretAccessKey, + sessionToken: process.env.AWS_SESSION_TOKEN, + }; + config.credentials = credentials; + + // If any of the below fails, it will throw an exception stopping the program. + client.buildCodeCommitClient(region, credentials); + // To check if we have permission to codecommit + await client.listRepositories(); + + initIamClient(region, credentials); + config.userArn = await getUserArn(); + + const platformConfig: PlatformResult = { + endpoint: endpoint ?? `https://git-codecommit.${region}.amazonaws.com/`, + }; + return Promise.resolve(platformConfig); +} + +export async function initRepo({ + repository, + endpoint, +}: RepoParams): Promise<RepoResult> { + logger.debug(`initRepo("${repository}")`); + + config.repository = repository; + + let repo; + try { + repo = await client.getRepositoryInfo(repository); + } catch (err) { + logger.error({ err }, 'Could not find repository'); + throw new Error(REPOSITORY_NOT_FOUND); + } + + const url = getCodeCommitUrl(config.region!, repository, config.credentials!); + try { + await git.initRepo({ + url, + }); + } catch (err) { + logger.debug({ err }, 'Failed to git init'); + throw new Error(PLATFORM_BAD_CREDENTIALS); + } + + if (!repo?.repositoryMetadata) { + logger.error({ repository }, 'Could not find repository'); + throw new Error(REPOSITORY_NOT_FOUND); + } + + logger.debug({ repositoryDetails: repo }, 'Repository details'); + const metadata = repo.repositoryMetadata; + + if (!metadata.defaultBranch || !metadata.repositoryId) { + logger.debug('Repo is empty'); + throw new Error(REPOSITORY_EMPTY); + } + + const defaultBranch = metadata.defaultBranch; + config.defaultBranch = defaultBranch; + logger.debug(`${repository} default branch = ${defaultBranch}`); + + return { + repoFingerprint: repoFingerprint(metadata.repositoryId, endpoint), + defaultBranch, + isFork: false, + }; +} + +export async function getPrList(): Promise<CodeCommitPr[]> { + logger.debug('getPrList()'); + + if (config.prList) { + return config.prList; + } + + const listPrsResponse = await client.listPullRequests( + config.repository!, + config.userArn! + ); + const fetchedPrs: CodeCommitPr[] = []; + + if (listPrsResponse && !listPrsResponse.pullRequestIds) { + return fetchedPrs; + } + + const prIds = listPrsResponse.pullRequestIds ?? []; + + for (const prId of prIds) { + const prRes = await client.getPr(prId); + + if (!prRes?.pullRequest) { + continue; + } + const prInfo = prRes.pullRequest; + const pr: CodeCommitPr = { + targetBranch: prInfo.pullRequestTargets![0].destinationReference!, + sourceBranch: prInfo.pullRequestTargets![0].sourceReference!, + state: + prInfo.pullRequestStatus === PullRequestStatusEnum.OPEN + ? PrState.Open + : PrState.Closed, + number: Number.parseInt(prId), + title: prInfo.title!, + body: prInfo.description!, + }; + fetchedPrs.push(pr); + } + + config.prList = fetchedPrs; + + logger.debug({ length: fetchedPrs.length }, 'Retrieved Pull Requests'); + return fetchedPrs; +} + +export async function findPr({ + branchName, + prTitle, + state = PrState.All, +}: FindPRConfig): Promise<CodeCommitPr | null> { + let prsFiltered: CodeCommitPr[] = []; + try { + const prs = await getPrList(); + const refsHeadBranchName = getNewBranchName(branchName); + prsFiltered = prs.filter( + (item) => item.sourceBranch === refsHeadBranchName + ); + + if (prTitle) { + prsFiltered = prsFiltered.filter((item) => item.title === prTitle); + } + + switch (state) { + case PrState.All: + break; + case PrState.NotOpen: + prsFiltered = prsFiltered.filter((item) => item.state !== PrState.Open); + break; + default: + prsFiltered = prsFiltered.filter((item) => item.state === PrState.Open); + break; + } + } catch (err) { + logger.error({ err }, 'findPr error'); + } + if (prsFiltered.length === 0) { + return null; + } + return prsFiltered[0]; +} + +export async function getBranchPr( + branchName: string +): Promise<CodeCommitPr | null> { + logger.debug(`getBranchPr(${branchName})`); + const existingPr = await findPr({ + branchName, + state: PrState.Open, + }); + return existingPr ? getPr(existingPr.number) : null; +} + +export async function getPr( + pullRequestId: number +): Promise<CodeCommitPr | null> { + logger.debug(`getPr(${pullRequestId})`); + const prRes = await client.getPr(`${pullRequestId}`); + + if (!prRes?.pullRequest) { + return null; + } + + const prInfo = prRes.pullRequest; + let prState: PrState; + if (prInfo.pullRequestTargets![0].mergeMetadata?.isMerged) { + prState = PrState.Merged; + } else { + prState = + prInfo.pullRequestStatus === PullRequestStatusEnum.OPEN + ? PrState.Open + : PrState.Closed; + } + + return { + sourceBranch: prInfo.pullRequestTargets![0].sourceReference!, + state: prState, + number: pullRequestId, + title: prInfo.title!, + targetBranch: prInfo.pullRequestTargets![0].destinationReference!, + sha: prInfo.revisionId, + body: prInfo.description!, + }; +} + +export async function getRepos(): Promise<string[]> { + logger.debug('Autodiscovering AWS CodeCommit repositories'); + + let reposRes: ListRepositoriesOutput; + try { + reposRes = await client.listRepositories(); + //todo do we need pagination? maximum number of repos is 1000 without pagination, also the same for free account + } catch (error) { + logger.error({ error }, 'Could not retrieve repositories'); + return []; + } + + const res: string[] = []; + + const repoNames = reposRes?.repositories ?? []; + + for (const repo of repoNames) { + if (repo.repositoryName) { + res.push(repo.repositoryName); + } + } + + return res; +} + +export function massageMarkdown(input: string): string { + // Remove any HTML we use + return input + .replace( + 'you tick the rebase/retry checkbox', + 'rename PR to start with "rebase!"' + ) + .replace(regEx(/<\/?summary>/g), '**') + .replace(regEx(/<\/?details>/g), '') + .replace(regEx(`\n---\n\n.*?<!-- rebase-check -->.*?\n`), '') + .replace(regEx(/\]\(\.\.\/pull\//g), '](../../pull-requests/') + .replace( + regEx(/(?<hiddenComment><!--renovate-(?:debug|config-hash):.*?-->)/g), + '[//]: # ($<hiddenComment>)' + ); +} + +export async function getJsonFile( + fileName: string, + repoName?: string, + branchOrTag?: string +): Promise<any | null> { + const raw = await getRawFile(fileName, repoName, branchOrTag); + return raw ? JSON5.parse(raw) : null; +} + +export async function getRawFile( + fileName: string, + repoName?: string, + branchOrTag?: string +): Promise<string | null> { + const fileRes = await client.getFile( + repoName ?? config.repository, + fileName, + branchOrTag + ); + if (!fileRes?.fileContent) { + return null; + } + const buf = Buffer.from(fileRes.fileContent); + return buf.toString(); +} + +/* istanbul ignore next */ +export function getRepoForceRebase(): Promise<boolean> { + return Promise.resolve(false); +} + +const AMAZON_MAX_BODY_LENGTH = 10239; + +export async function createPr({ + sourceBranch, + targetBranch, + prTitle: title, + prBody: body, +}: CreatePRConfig): Promise<CodeCommitPr> { + const description = smartTruncate(sanitize(body), AMAZON_MAX_BODY_LENGTH); + + const prCreateRes = await client.createPr( + title, + sanitize(description), + sourceBranch, + targetBranch, + config.repository + ); + + if ( + !prCreateRes.pullRequest?.title || + !prCreateRes.pullRequest?.pullRequestId || + !prCreateRes.pullRequest?.description + ) { + throw new Error('Could not create pr, missing PR info'); + } + + return { + number: Number.parseInt(prCreateRes.pullRequest.pullRequestId), + state: PrState.Open, + title: prCreateRes.pullRequest.title, + sourceBranch, + targetBranch, + sourceRepo: config.repository, + body: prCreateRes.pullRequest.description, + }; +} + +export async function updatePr({ + number: prNo, + prTitle: title, + prBody: body, + state, +}: UpdatePrConfig): Promise<void> { + logger.debug(`updatePr(${prNo}, ${title}, body)`); + + let cachedPr: CodeCommitPr | undefined = undefined; + const cachedPrs = config.prList ?? []; + for (const p of cachedPrs) { + if (p.number === prNo) { + cachedPr = p; + } + } + + if (body && cachedPr?.body !== body) { + await client.updatePrDescription( + `${prNo}`, + smartTruncate(sanitize(body), AMAZON_MAX_BODY_LENGTH) + ); + } + + if (title && cachedPr?.title !== title) { + await client.updatePrTitle(`${prNo}`, title); + } + + const prStatusInput = + state === PrState.Closed + ? PullRequestStatusEnum.CLOSED + : PullRequestStatusEnum.OPEN; + if (cachedPr?.state !== prStatusInput) { + try { + await client.updatePrStatus(`${prNo}`, prStatusInput); + } catch (err) { + // safety check + // do nothing, it's ok to fail sometimes when trying to update from open to open or from closed to closed. + } + } +} + +// Auto-Merge not supported currently. +/* istanbul ignore next */ +export async function mergePr({ + branchName, + id: prNo, + strategy, +}: MergePRConfig): Promise<boolean> { + logger.debug(`mergePr(${prNo}, ${branchName!})`); + await client.getPr(`${prNo}`); + return Promise.resolve(false); + // + // // istanbul ignore if + // if (!prOut) { + // return false; + // } + // const pReq = prOut.pullRequest; + // const targets = pReq?.pullRequestTargets; + // + // // istanbul ignore if + // if (!targets) { + // return false; + // } + // + // if (strategy === 'rebase') { + // logger.warn('CodeCommit does not support a "rebase" strategy.'); + // return false; + // } + // + // try { + // if (strategy === 'auto' || strategy === 'squash') { + // await client.squashMerge( + // targets[0].repositoryName!, + // targets[0].sourceReference!, + // targets[0].destinationReference!, + // pReq?.title + // ); + // } else if (strategy === 'fast-forward') { + // await client.fastForwardMerge( + // targets[0].repositoryName!, + // targets[0].sourceReference!, + // targets[0].destinationReference! + // ); + // } else { + // logger.debug(`unsupported strategy`); + // return false; + // } + // } catch (err) { + // logger.debug({ err }, `PR merge error`); + // logger.info({ pr: prNo }, 'PR automerge failed'); + // return false; + // } + // + // logger.trace(`Updating PR ${prNo} to status ${PullRequestStatusEnum.CLOSED}`); + // + // try { + // const response = await client.updatePrStatus( + // `${prNo}`, + // PullRequestStatusEnum.CLOSED + // ); + // const isClosed = + // response.pullRequest?.pullRequestStatus === PullRequestStatusEnum.CLOSED; + // + // if (!isClosed) { + // logger.warn( + // { + // pullRequestId: prNo, + // status: response.pullRequest?.pullRequestStatus, + // }, + // `Expected PR to have status` + // ); + // } + // return true; + // } catch (err) { + // logger.debug({ err }, 'Failed to set the PR as Closed.'); + // return false; + // } +} + +export async function addReviewers( + prNo: number, + reviewers: string[] +): Promise<void> { + const numberOfApprovers = reviewers.length; + const approvalRuleContents = `{"Version":"2018-11-08","Statements": [{"Type": "Approvers","NumberOfApprovalsNeeded":${numberOfApprovers},"ApprovalPoolMembers": ${JSON.stringify( + reviewers + )}}]}`; + const res = await client.createPrApprovalRule( + `${prNo}`, + approvalRuleContents + ); + if (res) { + const approvalRule = res.approvalRule; + logger.debug({ approvalRule }, `Approval Rule Added to PR #${prNo}:`); + } +} + +/* istanbul ignore next */ +export function addAssignees(iid: number, assignees: string[]): Promise<void> { + // CodeCommit does not support adding reviewers + return Promise.resolve(); +} + +/* istanbul ignore next */ +export function findIssue(title: string): Promise<Issue | null> { + // CodeCommit does not have issues + return Promise.resolve(null); +} + +/* istanbul ignore next */ +export function ensureIssue({ + title, +}: EnsureIssueConfig): Promise<EnsureIssueResult | null> { + // CodeCommit does not have issues + return Promise.resolve(null); +} + +/* istanbul ignore next */ +export function getIssueList(): Promise<Issue[]> { + // CodeCommit does not have issues + return Promise.resolve([]); +} + +/* istanbul ignore next */ +export function ensureIssueClosing(title: string): Promise<void> { + // CodeCommit does not have issues + return Promise.resolve(); +} + +/* istanbul ignore next */ +export function deleteLabel(prNumber: number, label: string): Promise<void> { + return Promise.resolve(); +} + +/* istanbul ignore next */ +export function getVulnerabilityAlerts(): Promise<VulnerabilityAlert[]> { + return Promise.resolve([]); +} + +// Returns the combined status for a branch. +/* istanbul ignore next */ +export function getBranchStatus(branchName: string): Promise<BranchStatus> { + logger.debug(`getBranchStatus(${branchName})`); + logger.debug( + 'returning branch status yellow, because getBranchStatus isnt supported on aws yet' + ); + return Promise.resolve(BranchStatus.yellow); +} + +/* istanbul ignore next */ +export function getBranchStatusCheck( + branchName: string, + context: string +): Promise<BranchStatus | null> { + logger.debug(`getBranchStatusCheck(${branchName}, context=${context})`); + logger.debug( + 'returning null, because getBranchStatusCheck is not supported on aws yet' + ); + return Promise.resolve(null); +} + +/* istanbul ignore next */ +export function setBranchStatus({ + branchName, + context, + description, + state, + url: targetUrl, +}: BranchStatusConfig): Promise<void> { + return Promise.resolve(); +} + +export async function ensureComment({ + number, + topic, + content, +}: EnsureCommentConfig): Promise<boolean> { + logger.debug(`ensureComment(${number}, ${topic!}, content)`); + const header = topic ? `### ${topic}\n\n` : ''; + const body = `${header}${sanitize(content)}`; + let prCommentsResponse: GetCommentsForPullRequestOutput; + try { + prCommentsResponse = await client.getPrComments( + config.repository!, + `${number}` + ); + } catch (err) { + logger.debug({ err }, 'Unable to retrieve pr comments'); + return false; + } + + let commentId: string | undefined = undefined; + let commentNeedsUpdating = false; + + if (!prCommentsResponse?.commentsForPullRequestData) { + return false; + } + + for (const commentObj of prCommentsResponse.commentsForPullRequestData) { + if (!commentObj?.comments) { + continue; + } + const firstCommentContent = commentObj.comments[0].content; + if ( + (topic && firstCommentContent?.startsWith(header)) || + (!topic && firstCommentContent === body) + ) { + commentId = commentObj.comments[0].commentId; + commentNeedsUpdating = firstCommentContent !== body; + break; + } + } + + if (!commentId) { + const prEvent = await client.getPrEvents(`${number}`); + + if (!prEvent?.pullRequestEvents) { + return false; + } + + const event = + prEvent.pullRequestEvents[0] + .pullRequestSourceReferenceUpdatedEventMetadata; + + if (!event?.beforeCommitId || !event?.afterCommitId) { + return false; + } + + await client.createPrComment( + `${number}`, + config.repository, + body, + event.beforeCommitId, + event.afterCommitId + ); + logger.info( + { repository: config.repository, prNo: number, topic }, + 'Comment added' + ); + } else if (commentNeedsUpdating && commentId) { + await client.updateComment(commentId, body); + + logger.debug( + { repository: config.repository, prNo: number, topic }, + 'Comment updated' + ); + } else { + logger.debug( + { repository: config.repository, prNo: number, topic }, + 'Comment is already update-to-date' + ); + } + + return true; +} + +export async function ensureCommentRemoval( + removeConfig: EnsureCommentRemovalConfig +): Promise<void> { + const { number: prNo } = removeConfig; + const key = + removeConfig.type === 'by-topic' + ? removeConfig.topic + : removeConfig.content; + logger.debug(`Ensuring comment "${key}" in #${prNo} is removed`); + + let prCommentsResponse: GetCommentsForPullRequestOutput; + try { + prCommentsResponse = await client.getPrComments( + config.repository!, + `${prNo}` + ); + } catch (err) { + logger.debug({ err }, 'Unable to retrieve pr comments'); + return; + } + + if (!prCommentsResponse?.commentsForPullRequestData) { + logger.debug('commentsForPullRequestData not found'); + return; + } + + let commentIdToRemove: string | undefined; + for (const commentObj of prCommentsResponse.commentsForPullRequestData) { + if (!commentObj?.comments) { + logger.debug( + 'comments object not found under commentsForPullRequestData' + ); + continue; + } + + for (const comment of commentObj.comments) { + if ( + (removeConfig.type === 'by-topic' && + comment.content?.startsWith(`### ${removeConfig.topic}\n\n`)) || + (removeConfig.type === 'by-content' && + removeConfig.content === comment.content?.trim()) + ) { + commentIdToRemove = comment.commentId; + break; + } + } + if (commentIdToRemove) { + await client.deleteComment(commentIdToRemove); + logger.debug(`comment "${key}" in PR #${prNo} was removed`); + break; + } + } +} diff --git a/package.json b/package.json index fe0f0a69198e31394d06512a4f2f7339e2f28703..0b93ce3c4bbc5b0600b99a877a00276a26bddb4e 100644 --- a/package.json +++ b/package.json @@ -135,8 +135,10 @@ "node": ">=16.13.0" }, "dependencies": { + "@aws-sdk/client-codecommit": "3.154.0", "@aws-sdk/client-ec2": "3.155.0", "@aws-sdk/client-ecr": "3.154.0", + "@aws-sdk/client-iam": "3.154.0", "@aws-sdk/client-rds": "3.154.0", "@aws-sdk/client-s3": "3.154.0", "@breejs/later": "4.1.0", @@ -152,6 +154,7 @@ "agentkeepalive": "4.2.1", "aggregate-error": "3.1.0", "auth-header": "1.0.0", + "aws4": "1.11.0", "azure-devops-node-api": "11.2.0", "bunyan": "1.8.15", "cacache": "17.0.0", @@ -234,6 +237,7 @@ "@renovate/eslint-plugin": "https://github.com/renovatebot/eslint-plugin#v0.0.4", "@semantic-release/exec": "6.0.3", "@types/auth-header": "1.0.2", + "@types/aws4": "1.11.2", "@types/bunyan": "1.8.8", "@types/cacache": "15.0.1", "@types/callsite": "1.0.31", diff --git a/yarn.lock b/yarn.lock index fe1acef7c8d569fff0a2c2d2af46960466301984..e3f08ff25974355faf89b48de04310f3421b5ba9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -140,6 +140,47 @@ dependencies: tslib "^2.3.1" +"@aws-sdk/client-codecommit@3.154.0": + version "3.154.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-codecommit/-/client-codecommit-3.154.0.tgz#4b78f57b4da47f36a817f42c8df19ce62bae8464" + integrity sha512-7rm5+OcZ08nAfPRAB2ru8ZvRObFRpipdcOMzRSYOrJmg3CpvK2zf4E2As14q1yBmrnPnExG5x0bSSkfBsVewyQ== + dependencies: + "@aws-crypto/sha256-browser" "2.0.0" + "@aws-crypto/sha256-js" "2.0.0" + "@aws-sdk/client-sts" "3.154.0" + "@aws-sdk/config-resolver" "3.130.0" + "@aws-sdk/credential-provider-node" "3.154.0" + "@aws-sdk/fetch-http-handler" "3.131.0" + "@aws-sdk/hash-node" "3.127.0" + "@aws-sdk/invalid-dependency" "3.127.0" + "@aws-sdk/middleware-content-length" "3.127.0" + "@aws-sdk/middleware-host-header" "3.127.0" + "@aws-sdk/middleware-logger" "3.127.0" + "@aws-sdk/middleware-recursion-detection" "3.127.0" + "@aws-sdk/middleware-retry" "3.127.0" + "@aws-sdk/middleware-serde" "3.127.0" + "@aws-sdk/middleware-signing" "3.130.0" + "@aws-sdk/middleware-stack" "3.127.0" + "@aws-sdk/middleware-user-agent" "3.127.0" + "@aws-sdk/node-config-provider" "3.127.0" + "@aws-sdk/node-http-handler" "3.127.0" + "@aws-sdk/protocol-http" "3.127.0" + "@aws-sdk/smithy-client" "3.142.0" + "@aws-sdk/types" "3.127.0" + "@aws-sdk/url-parser" "3.127.0" + "@aws-sdk/util-base64-browser" "3.109.0" + "@aws-sdk/util-base64-node" "3.55.0" + "@aws-sdk/util-body-length-browser" "3.154.0" + "@aws-sdk/util-body-length-node" "3.55.0" + "@aws-sdk/util-defaults-mode-browser" "3.142.0" + "@aws-sdk/util-defaults-mode-node" "3.142.0" + "@aws-sdk/util-user-agent-browser" "3.127.0" + "@aws-sdk/util-user-agent-node" "3.127.0" + "@aws-sdk/util-utf8-browser" "3.109.0" + "@aws-sdk/util-utf8-node" "3.109.0" + tslib "^2.3.1" + uuid "^8.3.2" + "@aws-sdk/client-ec2@3.155.0": version "3.155.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-ec2/-/client-ec2-3.155.0.tgz#1b10868bb603dda2071c2baac0c5eb57d80af7c8" @@ -226,6 +267,49 @@ "@aws-sdk/util-waiter" "3.127.0" tslib "^2.3.1" +"@aws-sdk/client-iam@3.154.0": + version "3.154.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-iam/-/client-iam-3.154.0.tgz#0a6f11ee729e9d21e6357b9f075f9bac3f3c8fe2" + integrity sha512-+lZCo4NkICCjho+D0Tajaowukd5hHYAFX5nzjJm/Yvmnc/rS1HGuKw7+3OXMiu2NebEjl3k23JVcvbaZMdFJ0w== + dependencies: + "@aws-crypto/sha256-browser" "2.0.0" + "@aws-crypto/sha256-js" "2.0.0" + "@aws-sdk/client-sts" "3.154.0" + "@aws-sdk/config-resolver" "3.130.0" + "@aws-sdk/credential-provider-node" "3.154.0" + "@aws-sdk/fetch-http-handler" "3.131.0" + "@aws-sdk/hash-node" "3.127.0" + "@aws-sdk/invalid-dependency" "3.127.0" + "@aws-sdk/middleware-content-length" "3.127.0" + "@aws-sdk/middleware-host-header" "3.127.0" + "@aws-sdk/middleware-logger" "3.127.0" + "@aws-sdk/middleware-recursion-detection" "3.127.0" + "@aws-sdk/middleware-retry" "3.127.0" + "@aws-sdk/middleware-serde" "3.127.0" + "@aws-sdk/middleware-signing" "3.130.0" + "@aws-sdk/middleware-stack" "3.127.0" + "@aws-sdk/middleware-user-agent" "3.127.0" + "@aws-sdk/node-config-provider" "3.127.0" + "@aws-sdk/node-http-handler" "3.127.0" + "@aws-sdk/protocol-http" "3.127.0" + "@aws-sdk/smithy-client" "3.142.0" + "@aws-sdk/types" "3.127.0" + "@aws-sdk/url-parser" "3.127.0" + "@aws-sdk/util-base64-browser" "3.109.0" + "@aws-sdk/util-base64-node" "3.55.0" + "@aws-sdk/util-body-length-browser" "3.154.0" + "@aws-sdk/util-body-length-node" "3.55.0" + "@aws-sdk/util-defaults-mode-browser" "3.142.0" + "@aws-sdk/util-defaults-mode-node" "3.142.0" + "@aws-sdk/util-user-agent-browser" "3.127.0" + "@aws-sdk/util-user-agent-node" "3.127.0" + "@aws-sdk/util-utf8-browser" "3.109.0" + "@aws-sdk/util-utf8-node" "3.109.0" + "@aws-sdk/util-waiter" "3.127.0" + entities "2.2.0" + fast-xml-parser "3.19.0" + tslib "^2.3.1" + "@aws-sdk/client-rds@3.154.0": version "3.154.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-rds/-/client-rds-3.154.0.tgz#032fe8e4fd94f1e0bd55523f4c631fbac9ccb0cf" @@ -2609,6 +2693,13 @@ resolved "https://registry.yarnpkg.com/@types/auth-header/-/auth-header-1.0.2.tgz#45879542c5c754debbb753b1491bbf690888b4ef" integrity sha512-KWpTfyz+F5GtURfp7W9c4ubFSXaPAvb1dUN5MlU3xSvlNIYhFrmrTNE7vd6SUOfSOO7FI/ePe03Y/KCPM/YOoA== +"@types/aws4@1.11.2": + version "1.11.2" + resolved "https://registry.yarnpkg.com/@types/aws4/-/aws4-1.11.2.tgz#7700aabe4646f8868b5d2b20820d9583225e7b78" + integrity sha512-x0f96eBPrCCJzJxdPbUvDFRva4yPpINJzTuXXpmS2j9qLUpF2nyGzvXPlRziuGbCsPukwY4JfuO+8xwsoZLzGw== + dependencies: + "@types/node" "*" + "@types/babel__core@^7.1.14": version "7.1.19" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" @@ -3446,6 +3537,11 @@ aws-sdk-client-mock@2.0.0: sinon "^11.1.1" tslib "^2.1.0" +aws4@1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + azure-devops-node-api@11.2.0: version "11.2.0" resolved "https://registry.yarnpkg.com/azure-devops-node-api/-/azure-devops-node-api-11.2.0.tgz#bf04edbef60313117a0507415eed4790a420ad6b" @@ -9862,9 +9958,9 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== util@^0.12.4: - version "0.12.5" - resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" - integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== + version "0.12.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253" + integrity sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw== dependencies: inherits "^2.0.3" is-arguments "^1.0.4"