import { isNonEmptyArray } from '@sindresorhus/is'; import semver from 'semver'; import { z } from 'zod/v4'; import { REPOSITORY_ARCHIVED } from '../../../constants/error-messages.ts'; import { logger } from '../../../logger/index.ts'; import { GerritHttp } from '../../../util/http/gerrit.ts'; import type { HttpOptions } from '../../../util/http/types.ts'; import { getQueryString } from '../../../util/url.ts'; import type { GerritAccountInfo, GerritChangeMessageInfo } from './schema.ts'; import { GerritBranchInfo, GerritChange, GerritChangeMessages, GerritChanges, GerritMergeableInfo, GerritProjectInfo, GerritRepos, } from './schema.ts'; import type { GerritFindPRConfig, GerritHashtagsInput, GerritRequestDetail, } from './types.ts'; import { MAX_GERRIT_COMMENT_SIZE, MIN_GERRIT_VERSION, mapPrStateToGerritFilter, } from './utils.ts'; class GerritClient { // memCache is disabled because GerritPrCache will provide a smarter caching private gerritHttp = new GerritHttp({ memCache: false }); private gerritVersion = MIN_GERRIT_VERSION; setGerritVersion(version: string): void { this.gerritVersion = version; } async getGerritVersion( options: Pick, ): Promise { const res = await this.gerritHttp.getJson( 'a/config/server/version', options, z.string(), ); return res.body; } async getRepos(): Promise { const res = await this.gerritHttp.getJson( 'a/projects/?type=CODE&state=ACTIVE', GerritRepos, ); return Object.keys(res.body); } async getProjectInfo(repository: string): Promise { const projectInfo = await this.gerritHttp.getJson( `a/projects/${encodeURIComponent(repository)}`, GerritProjectInfo, ); if (projectInfo.body.state !== 'ACTIVE') { throw new Error(REPOSITORY_ARCHIVED); } return projectInfo.body; } async getBranchInfo(repository: string): Promise { const branchInfo = await this.gerritHttp.getJson( `a/projects/${encodeURIComponent(repository)}/branches/HEAD`, GerritBranchInfo, ); return branchInfo.body; } async getBranchChange( repository: string, config: Pick< GerritFindPRConfig, 'branchName' | 'state' | 'targetBranch' | 'requestDetails' >, ): Promise { const changes = await this.findChanges(repository, { branchName: config.branchName, state: config.state, singleChange: config.targetBranch ? false : true, requestDetails: config.requestDetails, }); if (changes.length === 0) { return null; } if (changes.length === 1) { return changes[0]; } // If multiple changes are found, prefer the one matching the target branch if (config.targetBranch) { const change = changes.find((c) => c.branch === config.targetBranch); if (change) { return change; } } // Otherwise return the first one return changes[0]; } async findChanges( repository: string, findPRConfig: GerritFindPRConfig, ): Promise { const startOffset = findPRConfig.startOffset ?? 0; const pageLimit = findPRConfig.singleChange ? 1 : (findPRConfig.pageLimit ?? 50); const query: Record = { n: pageLimit, }; if (findPRConfig.requestDetails) { query.o = findPRConfig.requestDetails; } const filters = this.buildSearchFilters(repository, findPRConfig); const allChanges: GerritChange[] = []; while (true) { query.S = allChanges.length + startOffset; const queryString = `q=${filters.join('+')}&${getQueryString(query)}`; const changes = await this.gerritHttp.getJson( `a/changes/?${queryString}`, GerritChanges, ); logger.trace( `findChanges(${queryString},start=${query.S},limit=${query.n}) => ${changes.body.length}`, ); const lastChange = changes.body.at(-1); let hasMoreChanges = false; if (lastChange?._more_changes) { hasMoreChanges = true; delete lastChange._more_changes; } allChanges.push(...changes.body); if ( findPRConfig.singleChange || findPRConfig.noPagination || !hasMoreChanges ) { break; } } return allChanges; } async getChange( changeNumber: number, requestDetails?: GerritRequestDetail[], ): Promise { const queryString = getQueryString({ o: requestDetails }); const changes = await this.gerritHttp.getJson( `a/changes/${changeNumber}?${queryString}`, GerritChange, ); return changes.body; } async getMergeableInfo(change: GerritChange): Promise { const mergeable = await this.gerritHttp.getJson( `a/changes/${change._number}/revisions/current/mergeable`, GerritMergeableInfo, ); return mergeable.body; } async abandonChange(changeNumber: number, message?: string): Promise { await this.gerritHttp.postJson(`a/changes/${changeNumber}/abandon`, { body: { message, notify: 'OWNER_REVIEWERS', // Avoids notifying cc's }, }); } async submitChange(changeNumber: number): Promise { const change = await this.gerritHttp.postJson( `a/changes/${changeNumber}/submit`, ); return change.body; } async getMessages(changeNumber: number): Promise { const messages = await this.gerritHttp.getJson( `a/changes/${changeNumber}/messages`, GerritChangeMessages, ); return messages.body; } async addMessage( changeNumber: number, fullMessage: string, tag?: string, ): Promise { const message = this.normalizeMessage(fullMessage); await this.gerritHttp.postJson( `a/changes/${changeNumber}/revisions/current/review`, { body: { message, tag, notify: 'NONE' } }, ); } async checkForExistingMessage( changeNumber: number, newMessage: string, msgType?: string, ): Promise { const messagesToSearch = await this.getMessages(changeNumber); return messagesToSearch.some( (existingMsg) => (msgType === undefined || msgType === existingMsg.tag) && existingMsg.message.includes(newMessage), ); } async addMessageIfNotAlreadyExists( changeNumber: number, message: string, tag?: string, ): Promise { const newMsg = this.normalizeMessage(message); if (!(await this.checkForExistingMessage(changeNumber, newMsg, tag))) { await this.addMessage(changeNumber, newMsg, tag); } } async setLabel( changeNumber: number, label: string, value: number, ): Promise { await this.gerritHttp.postJson( `a/changes/${changeNumber}/revisions/current/review`, { body: { labels: { [label]: value }, notify: 'NONE' } }, ); } async setHashtags( changeNumber: number, hashtagsInput: GerritHashtagsInput, ): Promise { const { add, remove } = hashtagsInput; if (isNonEmptyArray(add) || isNonEmptyArray(remove)) { await this.gerritHttp.postJson(`a/changes/${changeNumber}/hashtags`, { body: { add, remove }, }); } } async addReviewers(changeNumber: number, reviewers: string[]): Promise { await this.gerritHttp.postJson( `a/changes/${changeNumber}/revisions/current/review`, { body: { reviewers: reviewers.map((r) => ({ reviewer: r })), notify: 'OWNER_REVIEWERS', // Avoids notifying cc's }, }, ); } async addAssignee(changeNumber: number, assignee: string): Promise { await this.gerritHttp.putJson( // TODO: use CCs instead as Gerrit 3.8+ no longer supports assignees `a/changes/${changeNumber}/assignee`, { body: { assignee }, }, ); } async getFile( repo: string, branch: string, fileName: string, ): Promise { const base64Content = await this.gerritHttp.getText( `a/projects/${encodeURIComponent( repo, )}/branches/${encodeURIComponent(branch)}/files/${encodeURIComponent(fileName)}/content`, ); return Buffer.from(base64Content.body, 'base64').toString(); } async moveChange( changeNumber: number, destinationBranch: string, ): Promise { const change = await this.gerritHttp.postJson( `a/changes/${changeNumber}/move`, { body: { destination_branch: destinationBranch, }, }, ); return change.body; } normalizeMessage(message: string): string { // Gerrit would trim it anyway let msg = message.trim(); const encoder = new TextEncoder(); const bytes = encoder.encode(msg); if (bytes.length > MAX_GERRIT_COMMENT_SIZE) { const truncationNotice = '\n\n[Truncated by Renovate]'; const truncationNoticeBytes = encoder.encode(truncationNotice); const maxContentBytes = MAX_GERRIT_COMMENT_SIZE - truncationNoticeBytes.length; const truncatedBytes = bytes.slice(0, maxContentBytes); const decoded = new TextDecoder().decode(truncatedBytes); msg = decoded + truncationNotice; } return msg; } private buildSearchFilters( repository: string, searchConfig: GerritFindPRConfig, ): string[] { const filters = [ 'owner:self', `project:${repository}`, '-is:wip', '-is:private', ]; const filterState = mapPrStateToGerritFilter(searchConfig.state); if (filterState) { filters.push(filterState); } if (searchConfig.branchName) { filters.push(`footer:Renovate-Branch=${searchConfig.branchName}`); } else if (semver.gte(this.gerritVersion, '3.6.0')) { filters.push('hasfooter:Renovate-Branch'); } else { filters.push('message:"Renovate-Branch: "'); } if (searchConfig.targetBranch) { filters.push(`branch:${searchConfig.targetBranch}`); } if (searchConfig.label) { filters.push(`label:Code-Review=${searchConfig.label}`); } if (searchConfig.prTitle) { // Quotes in the search operators must be escaped with a backslash: // https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators const escapedTitle = searchConfig.prTitle.replaceAll('"', '\\"'); if (semver.gte(this.gerritVersion, '3.8.0')) { filters.push(`subject:"${escapedTitle}"`); } else { filters.push(`message:"${escapedTitle}"`); } } return filters; } } export const client = new GerritClient();