import { Buffer } from 'node:buffer'; import type { GetCommentsForPullRequestOutput, ListRepositoriesOutput, } from '@aws-sdk/client-codecommit'; import { PullRequestStatusEnum } from '@aws-sdk/client-codecommit'; import { PLATFORM_BAD_CREDENTIALS, REPOSITORY_EMPTY, REPOSITORY_NOT_FOUND, } from '../../../constants/error-messages.ts'; import { logger } from '../../../logger/index.ts'; import type { BranchStatus, PrState } from '../../../types/index.ts'; import { coerceArray } from '../../../util/array.ts'; import { parseJson } from '../../../util/common.ts'; import * as git from '../../../util/git/index.ts'; import { regEx } from '../../../util/regex.ts'; import { sanitize } from '../../../util/sanitize.ts'; import type { BranchStatusConfig, CreatePRConfig, EnsureCommentConfig, EnsureCommentRemovalConfig, EnsureIssueConfig, EnsureIssueResult, FindPRConfig, Issue, MergePRConfig, PlatformParams, PlatformResult, RepoParams, RepoResult, UpdatePrConfig, } from '../types.ts'; import { getNewBranchName, repoFingerprint } from '../util.ts'; import { smartTruncate } from '../utils/pr-body.ts'; import * as client from './codecommit-client.ts'; import type { CodeCommitPr } from './types.ts'; interface Config { repository?: string; defaultBranch?: string; region?: string; prList?: CodeCommitPr[]; } export const id = 'codecommit'; export const experimental = true; const platformConfig = { endpoint: 'https://git-codecommit.us-east-1.amazonaws.com', }; let config: Config = {}; export async function initPlatform({ endpoint, username, password, token: awsToken, }: PlatformParams): Promise { const accessKeyId = username; const secretAccessKey = password; const env = process.env; let region: string | undefined; if (accessKeyId) { env.AWS_ACCESS_KEY_ID = accessKeyId; } if (secretAccessKey) { env.AWS_SECRET_ACCESS_KEY = secretAccessKey; } if (awsToken) { env.AWS_SESSION_TOKEN = awsToken; } if (endpoint) { const regionReg = regEx(/.*codecommit\.(?.+)\.amazonaws\.com/); const codeCommitMatch = regionReg.exec(endpoint); region = codeCommitMatch?.groups?.region; if (region) { env.AWS_REGION = region; } else { logger.warn("Can't parse region, make sure your endpoint is correct"); } } // If any of the below fails, it will throw an exception stopping the program. client.buildCodeCommitClient(); // To check if we have permission to codecommit, throws exception if failed. await client.listRepositories(); platformConfig.endpoint = endpoint ?? `https://git-codecommit.${env.AWS_REGION ?? 'us-east-1'}.amazonaws.com/`; const platformResult: PlatformResult = { endpoint: platformConfig.endpoint, }; return platformResult; } export async function initRepo({ repository, }: RepoParams): Promise { logger.debug(`initRepo("${repository}")`); config = { repository }; let repo; try { repo = await client.getRepositoryInfo(repository); } catch (err) { logger.error({ err }, 'Could not find repository'); throw new Error(REPOSITORY_NOT_FOUND); } 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; const url = client.getCodeCommitUrl(metadata, repository); try { await git.initRepo({ url, }); } catch (err) { logger.debug({ err }, 'Failed to git init'); throw new Error(PLATFORM_BAD_CREDENTIALS); } 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, platformConfig.endpoint, ), defaultBranch, isFork: false, }; } export async function getPrList(): Promise { logger.debug('getPrList()'); if (config.prList) { return config.prList; } const listPrsResponse = await client.listPullRequests(config.repository!); const fetchedPrs: CodeCommitPr[] = []; if (listPrsResponse && !listPrsResponse.pullRequestIds) { return fetchedPrs; } const prIds = coerceArray(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!, destinationCommit: prInfo.pullRequestTargets![0].destinationCommit!, sourceCommit: prInfo.pullRequestTargets![0].sourceCommit!, state: prInfo.pullRequestStatus === PullRequestStatusEnum.OPEN ? 'open' : 'closed', number: Number.parseInt(prId, 10), title: prInfo.title!, body: prInfo.description!, createdAt: prInfo.creationDate?.toISOString(), }; fetchedPrs.push(pr); } config.prList = fetchedPrs; logger.debug(`Retrieved Pull Requests, count: ${fetchedPrs.length}`); return fetchedPrs; } export async function findPr({ branchName, prTitle, state = 'all', }: FindPRConfig): Promise { 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.toUpperCase() === prTitle.toUpperCase(), ); } switch (state) { case 'all': break; case '!open': prsFiltered = prsFiltered.filter((item) => item.state !== 'open'); break; default: prsFiltered = prsFiltered.filter((item) => item.state === '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 { logger.debug(`getBranchPr(${branchName})`); const existingPr = await findPr({ branchName, state: 'open', }); return existingPr ? getPr(existingPr.number) : null; } export async function getPr( pullRequestId: number, ): Promise { 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 = 'merged'; } else { prState = prInfo.pullRequestStatus === PullRequestStatusEnum.OPEN ? 'open' : 'closed'; } return { sourceBranch: prInfo.pullRequestTargets![0].sourceReference!, sourceCommit: prInfo.pullRequestTargets![0].sourceCommit!, state: prState, number: pullRequestId, title: prInfo.title!, targetBranch: prInfo.pullRequestTargets![0].destinationReference!, destinationCommit: prInfo.pullRequestTargets![0].destinationCommit!, body: prInfo.description!, }; } export async function getRepos(): Promise { 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 (err) { logger.error({ err }, 'Could not retrieve repositories'); return []; } const res: string[] = []; const repoNames = coerceArray(reposRes?.repositories); for (const repo of repoNames) { // v8 ignore else -- TODO: add test #40625 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', 'PR is renamed to start with "rebase!"', ) .replace( 'checking the rebase/retry box above', 'renaming the PR to start with "rebase!"', ) .replace(regEx(/<\/?summary>/g), '**') .replace(regEx(/<\/?details>/g), '') .replace(regEx(`\n---\n\n.*?.*?\n`), '') .replace(regEx(/\]\(\.\.\/issues\//g), '](#') .replace(regEx(/\]\(\.\.\/pull\//g), '](../../pull-requests/') .replace( regEx(/(?)/g), '[//]: # ($)', ); } /** * Unsed, no Dashboard */ export function maxBodyLength(): number { return Infinity; } export async function getJsonFile( fileName: string, repoName?: string, branchOrTag?: string, ): Promise { const raw = await getRawFile(fileName, repoName, branchOrTag); return parseJson(raw, fileName); } export async function getRawFile( fileName: string, repoName?: string, branchOrTag?: string, ): Promise { const fileRes = await client.getFile( repoName ?? config.repository, fileName, branchOrTag, ); if (!fileRes?.fileContent) { return null; } const buf = Buffer.from(fileRes.fileContent); return buf.toString(); } const AMAZON_MAX_BODY_LENGTH = 10239; export async function createPr({ sourceBranch, targetBranch, prTitle: title, prBody: body, }: CreatePRConfig): Promise { 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 || !prCreateRes.pullRequest?.pullRequestTargets?.length ) { throw new Error('Could not create pr, missing PR info'); } return { number: Number.parseInt(prCreateRes.pullRequest.pullRequestId, 10), state: 'open', title: prCreateRes.pullRequest.title, sourceBranch, targetBranch, sourceCommit: prCreateRes.pullRequest.pullRequestTargets[0].sourceCommit!, destinationCommit: prCreateRes.pullRequest.pullRequestTargets[0].destinationCommit!, sourceRepo: config.repository, body: prCreateRes.pullRequest.description, }; } export async function updatePr({ number: prNo, prTitle: title, prBody: body, state, }: UpdatePrConfig): Promise { logger.debug(`updatePr(${prNo}, ${title}, body)`); let cachedPr: CodeCommitPr | undefined = undefined; const cachedPrs = coerceArray(config.prList); for (const p of cachedPrs) { // v8 ignore else -- TODO: add test #40625 if (p.number === prNo) { cachedPr = p; } } // v8 ignore else -- TODO: add test #40625 if (body && cachedPr?.body !== body) { await client.updatePrDescription( `${prNo}`, smartTruncate(sanitize(body), AMAZON_MAX_BODY_LENGTH), ); } // v8 ignore else -- TODO: add test #40625 if (title && cachedPr?.title !== title) { await client.updatePrTitle(`${prNo}`, title); } const prStatusInput = state === 'closed' ? PullRequestStatusEnum.CLOSED : PullRequestStatusEnum.OPEN; // v8 ignore else -- TODO: add test #40625 if (cachedPr?.state !== prStatusInput) { try { await client.updatePrStatus(`${prNo}`, prStatusInput); } catch { // 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. /* v8 ignore next -- auto-merge is not supported on CodeCommit, so this stub is unexercised */ export async function mergePr({ branchName, id: prNo, }: MergePRConfig): Promise { logger.debug(`mergePr(${prNo}, ${branchName!})`); await client.getPr(`${prNo}`); return Promise.resolve(false); // // if (!prOut) { // return false; // } // const pReq = prOut.pullRequest; // const targets = pReq?.pullRequestTargets; // // 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 { 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, ); // v8 ignore else -- TODO: add test #40625 if (res) { const approvalRule = res.approvalRule; logger.debug({ approvalRule }, `Approval Rule Added to PR #${prNo}:`); } } /* v8 ignore next -- no-op stub: CodeCommit does not support adding assignees */ export function addAssignees( _iid: number, _assignees: string[], ): Promise { // CodeCommit does not support adding reviewers return Promise.resolve(); } /* v8 ignore next -- no-op stub: CodeCommit has no issues */ export function findIssue(_title: string): Promise { // CodeCommit does not have issues return Promise.resolve(null); } /* v8 ignore next -- no-op stub: CodeCommit has no issues */ export function ensureIssue( _cfg: EnsureIssueConfig, ): Promise { // CodeCommit does not have issues return Promise.resolve(null); } /* v8 ignore next -- no-op stub: CodeCommit has no issues */ export function getIssueList(): Promise { // CodeCommit does not have issues return Promise.resolve([]); } /* v8 ignore next -- no-op stub: CodeCommit has no issues */ export function ensureIssueClosing(_title: string): Promise { // CodeCommit does not have issues return Promise.resolve(); } /* v8 ignore next -- no-op stub: label deletion is not implemented for CodeCommit */ export function deleteLabel(_prNumber: number, _label: string): Promise { return Promise.resolve(); } // Returns the combined status for a branch. /* v8 ignore next -- stub: branch statuses are not supported on CodeCommit yet, always yellow */ export function getBranchStatus(branchName: string): Promise { logger.debug(`getBranchStatus(${branchName})`); logger.debug( 'returning branch status yellow, because getBranchStatus isnt supported on aws yet', ); return Promise.resolve('yellow'); } /* v8 ignore next -- stub: branch status checks are not supported on CodeCommit yet, always null */ export function getBranchStatusCheck( branchName: string, context: string, ): Promise { logger.debug(`getBranchStatusCheck(${branchName}, context=${context})`); logger.debug( 'returning null, because getBranchStatusCheck is not supported on aws yet', ); return Promise.resolve(null); } /* v8 ignore next -- no-op stub: setting branch status is not supported on CodeCommit */ export function setBranchStatus(_cfg: BranchStatusConfig): Promise { return Promise.resolve(); } export async function ensureComment({ number, topic, content, }: EnsureCommentConfig): Promise { 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(`${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)) === true || (!topic && firstCommentContent === body) ) { commentId = commentObj.comments[0].commentId; commentNeedsUpdating = firstCommentContent !== body; break; } } if (!commentId) { const prs = await getPrList(); const thisPr = prs.filter((item) => item.number === number); if (!thisPr[0].sourceCommit || !thisPr[0].destinationCommit) { return false; } await client.createPrComment( `${number}`, config.repository, body, thisPr[0].destinationCommit, thisPr[0].sourceCommit, ); 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 up-to-date', ); } return true; } export async function ensureCommentRemoval( removeConfig: EnsureCommentRemovalConfig, ): Promise { 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(`${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) { // v8 ignore else -- TODO: add test #40625 if ( (removeConfig.type === 'by-topic' && comment.content?.startsWith(`### ${removeConfig.topic}\n\n`)) === true || (removeConfig.type === 'by-content' && removeConfig.content === comment.content?.trim()) ) { commentIdToRemove = comment.commentId; break; } } // v8 ignore else -- TODO: add test #40625 if (commentIdToRemove) { await client.deleteComment(commentIdToRemove); logger.debug(`comment "${key}" in PR #${prNo} was removed`); break; } } }