import { isTruthy, isUndefined } from '@sindresorhus/is'; import semver from 'semver'; import { logger } from '../../../logger/index.ts'; import type { BranchStatus } from '../../../types/index.ts'; import { parseJson } from '../../../util/common.ts'; import { getEnv } from '../../../util/env.ts'; import * as git from '../../../util/git/index.ts'; import type { VirtualBranch } from '../../../util/git/types.ts'; import { setBaseUrl } from '../../../util/http/gerrit.ts'; import { coerceObject } from '../../../util/object.ts'; import { regEx } from '../../../util/regex.ts'; import { toLongCommitSha } from '../../../util/schema-utils/git.ts'; import { ensureTrailingSlash } from '../../../util/url.ts'; import type { BranchStatusConfig, CreatePRConfig, EnsureCommentConfig, EnsureCommentRemovalConfigByContent, EnsureCommentRemovalConfigByTopic, EnsureIssueConfig, EnsureIssueResult, FindPRConfig, Issue, MergePRConfig, PlatformParams, PlatformResult, Pr, RepoParams, RepoResult, UpdatePrConfig, } from '../types.ts'; import { repoFingerprint } from '../util.ts'; import { smartTruncate } from '../utils/pr-body.ts'; import { readOnlyIssueBody } from '../utils/read-only-issue-body.ts'; import { client } from './client.ts'; import type { GerritLabels, GerritProjectInfo } from './schema.ts'; import { configureScm, pushForReview } from './scm.ts'; import { MAX_GERRIT_COMMENT_SIZE, REQUEST_DETAILS_FOR_PRS, TAG_PULL_REQUEST_BODY, extractSourceBranch, getGerritRepoUrl, mapBranchStatusToLabel, mapGerritChangeToPr, } from './utils.ts'; export const id = 'gerrit'; export const experimental = true; const defaults: { endpoint?: string; } = {}; let config: { repository?: string; head?: string; config?: GerritProjectInfo; labels: GerritLabels; gerritUsername?: string; } = { labels: {}, }; export function writeToConfig(newConfig: typeof config): void { config = { ...config, ...newConfig }; } export async function initPlatform({ endpoint, username, password, }: PlatformParams): Promise { logger.debug(`initPlatform(${endpoint!}, ${username!})`); if (!endpoint) { throw new Error('Init: You must configure a Gerrit Server endpoint'); } if (!(username && password)) { throw new Error( 'Init: You must configure a Gerrit Server username/password', ); } config.gerritUsername = username; defaults.endpoint = ensureTrailingSlash(endpoint); setBaseUrl(defaults.endpoint); let gerritVersion: string; try { const env = getEnv(); /* v8 ignore if: experimental feature */ if (env.RENOVATE_X_PLATFORM_VERSION) { gerritVersion = env.RENOVATE_X_PLATFORM_VERSION; } else { gerritVersion = await client.getGerritVersion({ username, password, }); } } catch (err) { logger.debug( { err }, 'Error authenticating with Gerrit. Check your credentials', ); throw new Error('Init: Authentication failure'); } logger.debug(`Gerrit version is: ${gerritVersion}`); // Example: 3.13.0-rc3-148-gb478dbbb57 const parsed = semver.parse(gerritVersion); if (!parsed) { throw new Error(`Unable to parse Gerrit version: ${gerritVersion}`); } gerritVersion = `${parsed.major}.${parsed.minor}.${parsed.patch}`; client.setGerritVersion(gerritVersion); const platformConfig: PlatformResult = { endpoint: defaults.endpoint, }; return platformConfig; } /** * Get all state="ACTIVE" and type="CODE" repositories from gerrit */ export async function getRepos(): Promise { logger.debug(`getRepos()`); return await client.getRepos(); } /** * Clone repository to local directory * @param config */ export async function initRepo({ repository, cloneSubmodules, cloneSubmodulesFilter, gitUrl, }: RepoParams): Promise { logger.debug(`initRepo(${repository}, ${gitUrl})`); const projectInfo = await client.getProjectInfo(repository); const branchInfo = await client.getBranchInfo(repository); config = { ...config, repository, head: branchInfo.revision, config: projectInfo, labels: coerceObject(projectInfo.labels), }; //abandon "open" and "rejected" changes at startup const rejectedChanges = await client.findChanges(repository, { branchName: '', state: 'open', label: '-2', }); for (const change of rejectedChanges) { await client.abandonChange( change._number, 'This change has been abandoned as it was voted with Code-Review -2.', ); logger.info( `Abandoned change ${change._number} with Code-Review -2 in repository ${repository}`, ); } // Collect open Gerrit changes to initialize as virtual branches. // This allows DefaultGitScm to treat them as regular Git branches. const openChanges = await client.findChanges(repository, { branchName: '', state: 'open', requestDetails: ['CURRENT_REVISION', 'COMMIT_FOOTERS'], }); const virtualBranches: Record = {}; for (const change of openChanges) { const branchName = extractSourceBranch(change); if (!branchName) { continue; } const sha = toLongCommitSha(change.current_revision); const ref = change.revisions![sha].ref; virtualBranches[branchName] = { ref, sha }; } const virtualBranchCount = Object.keys(virtualBranches).length; if (virtualBranchCount > 0) { logger.debug( `Will fetch ${virtualBranchCount} Gerrit changes as virtual branches`, ); } const baseUrl = defaults.endpoint!; const url = getGerritRepoUrl( repository, baseUrl, gitUrl, config.gerritUsername!, ); configureScm(repository, config.labels); await git.initRepo({ url, cloneSubmodules, cloneSubmodulesFilter, virtualBranches, }); const repoConfig: RepoResult = { defaultBranch: config.head!, isFork: false, repoFingerprint: repoFingerprint(repository, baseUrl), }; return repoConfig; } export async function findPr(findPRConfig: FindPRConfig): Promise { const change = ( await client.findChanges(config.repository!, { ...findPRConfig, singleChange: true, requestDetails: REQUEST_DETAILS_FOR_PRS, }) ).pop(); return change ? mapGerritChangeToPr(change, { sourceBranch: findPRConfig.branchName, }) : null; } export async function getPr(number: number): Promise { try { const change = await client.getChange(number, REQUEST_DETAILS_FOR_PRS); return mapGerritChangeToPr(change); } catch (err) { if (err.statusCode === 404) { return null; } throw err; } } export async function updatePr(prConfig: UpdatePrConfig): Promise { logger.debug(`updatePr(${prConfig.number}, ${prConfig.prTitle})`); if (prConfig.prBody) { await client.addMessageIfNotAlreadyExists( prConfig.number, prConfig.prBody, TAG_PULL_REQUEST_BODY, ); } await client.setHashtags(prConfig.number, { add: prConfig.addLabels, remove: prConfig.removeLabels, }); if (prConfig.targetBranch) { await client.moveChange(prConfig.number, prConfig.targetBranch); } if (prConfig.state && prConfig.state === 'closed') { await client.abandonChange(prConfig.number); } } export async function createPr(prConfig: CreatePRConfig): Promise { logger.debug( `createPr(${prConfig.sourceBranch}, ${prConfig.prTitle}, ${ prConfig.labels?.toString() ?? '' })`, ); logger.debug( `Pushing commit to refs/for/${prConfig.targetBranch} to create Gerrit change`, ); const pushResult = await pushForReview({ sourceRef: prConfig.sourceBranch, targetBranch: prConfig.targetBranch, files: [], autoApprove: prConfig.platformPrOptions?.autoApprove, labels: prConfig.labels ?? undefined, }); if (!pushResult) { throw new Error( `Failed to push commit to refs/for/${prConfig.targetBranch} to create Gerrit change`, ); } const change = ( await client.findChanges(config.repository!, { branchName: prConfig.sourceBranch, targetBranch: prConfig.targetBranch, state: 'open', singleChange: true, requestDetails: REQUEST_DETAILS_FOR_PRS, }) ).pop(); if (isUndefined(change)) { throw new Error( `Could not find the Gerrit change after pushing to refs/for/${prConfig.targetBranch}`, ); } // Register the virtual branch now that the change exists and we have the ref. const sha = toLongCommitSha(change.current_revision); const ref = change.revisions![sha].ref; await git.setVirtualBranch(prConfig.sourceBranch, ref, sha); await client.addMessage( change._number, prConfig.prBody, TAG_PULL_REQUEST_BODY, ); return mapGerritChangeToPr(change, { sourceBranch: prConfig.sourceBranch, prBody: prConfig.prBody, }); } export async function getBranchPr( branchName: string, targetBranch?: string, ): Promise { const change = await client.getBranchChange(config.repository!, { branchName, state: 'open', targetBranch, requestDetails: REQUEST_DETAILS_FOR_PRS, }); return change ? mapGerritChangeToPr(change, { sourceBranch: branchName, }) : null; } export async function getPrList(): Promise { const changes = await client.findChanges(config.repository!, { branchName: '', requestDetails: REQUEST_DETAILS_FOR_PRS, }); return changes.map((change) => mapGerritChangeToPr(change)).filter(isTruthy); } export async function mergePr(config: MergePRConfig): Promise { logger.debug( `mergePr(${config.id}, ${config.branchName!}, ${config.strategy!})`, ); try { const change = await client.submitChange(config.id); return change.status === 'MERGED'; } catch (err) { if (err.statusCode === 409) { logger.warn( { err }, "Can't submit the change, because the submit rule doesn't allow it.", ); return false; } throw err; } } /** * BranchStatus for Gerrit assumes that the branchName refers to a change. * @param branchName */ export async function getBranchStatus( branchName: string, ): Promise { logger.debug(`getBranchStatus(${branchName})`); const change = ( await client.findChanges(config.repository!, { state: 'open', branchName, singleChange: true, requestDetails: ['LABELS', 'SUBMITTABLE', 'CHECK'], }) ).pop(); if (change) { const hasProblems = change.problems && change.problems.length > 0; if (hasProblems) { return 'red'; } const hasBlockingLabels = Object.values(coerceObject(change.labels)).some( (label) => label.blocking, ); if (hasBlockingLabels) { return 'red'; } // v8 ignore else -- TODO: add test #40625 if (change.submittable) { return 'green'; } } return 'yellow'; } /** * check the gerrit-change for the presence of the corresponding "$context" Gerrit label if configured, * return 'yellow' if not configured or not set * @param branchName * @param context renovate/stability-days || ... */ export async function getBranchStatusCheck( branchName: string, context: string, ): Promise { const labelConfig = config.labels[context]; if (labelConfig) { const change = ( await client.findChanges(config.repository!, { branchName, state: 'open', singleChange: true, requestDetails: ['LABELS'], }) ).pop(); // v8 ignore else -- TODO: add test #40625 if (change) { const label = change.labels![context]; // v8 ignore else -- TODO: add test #40625 if (label) { // Check for rejected or blocking first, as a label could have both rejected and approved if (label.rejected || label.blocking) { return 'red'; } // v8 ignore else -- TODO: add test #40625 if (label.approved) { return 'green'; } } } } return 'yellow'; } /** * Apply the branch state $context to the corresponding gerrit label (if available) * context === "renovate/stability-days" / "renovate/merge-confidence" and state === "green"/... * @param branchStatusConfig */ export async function setBranchStatus( branchStatusConfig: BranchStatusConfig, ): Promise { const label = config.labels[branchStatusConfig.context]; const labelValue = label && mapBranchStatusToLabel(branchStatusConfig.state, label); if (branchStatusConfig.context && labelValue) { const change = ( await client.findChanges(config.repository!, { branchName: branchStatusConfig.branchName, state: 'open', singleChange: true, requestDetails: ['LABELS'], }) ).pop(); const labelKey = branchStatusConfig.context; if (!change?.labels || !Object.hasOwn(change.labels, labelKey)) { return; } await client.setLabel(change._number, labelKey, labelValue); } } export async function getRawFile( fileName: string, repoName?: string, branchOrTag?: string, ): Promise { const repo = repoName ?? config.repository; if (!repo) { logger.debug('No repo so cannot getRawFile'); return null; } // v8 ignore next -- TODO: add test #40625 const branch = branchOrTag ?? (repo === config.repository ? (config.head ?? 'HEAD') : 'HEAD'); const result = await client.getFile(repo, branch, fileName); return result; } 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 addReviewers( number: number, reviewers: string[], ): Promise { await client.addReviewers(number, reviewers); } /** * add "CC" (only one possible) */ export async function addAssignees( number: number, assignees: string[], ): Promise { // v8 ignore else -- TODO: add test #40625 if (assignees.length) { // v8 ignore else -- TODO: add test #40625 if (assignees.length > 1) { logger.debug( `addAssignees(${number}, ${assignees.toString()}) called with more then one assignee! Gerrit only supports one assignee! Using the first from list.`, ); } await client.addAssignee(number, assignees[0]); } } export async function ensureComment( ensureComment: EnsureCommentConfig, ): Promise { logger.debug( `ensureComment(${ensureComment.number}, ${ensureComment.topic!}, ${ ensureComment.content })`, ); await client.addMessageIfNotAlreadyExists( ensureComment.number, ensureComment.content, ensureComment.topic ?? undefined, ); return true; } export function massageMarkdown(prBody: string, rebaseLabel: string): string { return ( smartTruncate(readOnlyIssueBody(prBody), maxBodyLength()) .replace('Branch creation', 'Change creation') .replace( 'close this Pull Request unmerged', 'abandon or vote this change with Code-Review -2', ) .replace( 'Close this PR', 'Abandon or vote this change with Code-Review -2', ) .replace( 'you tick the rebase/retry checkbox', `you add the _${rebaseLabel}_ hashtag to this change`, ) .replace( 'checking the rebase/retry box above', `adding the _${rebaseLabel}_ hashtag to this change`, ) .replace(regEx(/\b(?:Pull Request|PR)/g), 'change') // Remove HTML tags not supported in Gerrit markdown .replace(regEx(/<\/?summary>/g), '**') .replace(regEx(/<\/?(?:details|blockquote)>/g), '') .replace(regEx(`\n---\n\n.*?.*?\n`), '') .replace(regEx(//g), '') // Remove zero-width-space not supported in Gerrit markdown .replace(regEx(/​/g), '') ); } export function maxBodyLength(): number { return MAX_GERRIT_COMMENT_SIZE; } export async function deleteLabel( number: number, label: string, ): Promise { await client.setHashtags(number, { remove: [label] }); } export function ensureCommentRemoval( _ensureCommentRemoval: EnsureCommentRemovalConfigByTopic | EnsureCommentRemovalConfigByContent, ): Promise { return Promise.resolve(); } export function ensureIssueClosing(_title: string): Promise { return Promise.resolve(); } export function ensureIssue( _issueConfig: EnsureIssueConfig, ): Promise { return Promise.resolve(null); } export function findIssue(_title: string): Promise { return Promise.resolve(null); } export function getIssueList(): Promise { return Promise.resolve([]); }