Files
renovate/lib/modules/platform/gerrit/client.ts
Sebastian Poxhofer dfe806f9ac refactor(gerrit): use schema validated getJson instead of getJsonUnchecked (#43777)
* fix(platform/gerrit): replace getJsonUnchecked with validated getJson

Migrate all getJsonUnchecked calls in lib/modules/platform/gerrit/client.ts
to use Zod-validated getJson. Co-locate schemas in schema.ts with lenient
optional fields to ensure backward compatibility with all existing API responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* strictly follow previous interfaces

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:23:04 +00:00

379 lines
11 KiB
TypeScript

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<HttpOptions, 'username' | 'password'>,
): Promise<string> {
const res = await this.gerritHttp.getJson(
'a/config/server/version',
options,
z.string(),
);
return res.body;
}
async getRepos(): Promise<string[]> {
const res = await this.gerritHttp.getJson(
'a/projects/?type=CODE&state=ACTIVE',
GerritRepos,
);
return Object.keys(res.body);
}
async getProjectInfo(repository: string): Promise<GerritProjectInfo> {
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<GerritBranchInfo> {
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<GerritChange | null> {
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<GerritChange[]> {
const startOffset = findPRConfig.startOffset ?? 0;
const pageLimit = findPRConfig.singleChange
? 1
: (findPRConfig.pageLimit ?? 50);
const query: Record<string, any> = {
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<GerritChange> {
const queryString = getQueryString({ o: requestDetails });
const changes = await this.gerritHttp.getJson(
`a/changes/${changeNumber}?${queryString}`,
GerritChange,
);
return changes.body;
}
async getMergeableInfo(change: GerritChange): Promise<GerritMergeableInfo> {
const mergeable = await this.gerritHttp.getJson(
`a/changes/${change._number}/revisions/current/mergeable`,
GerritMergeableInfo,
);
return mergeable.body;
}
async abandonChange(changeNumber: number, message?: string): Promise<void> {
await this.gerritHttp.postJson(`a/changes/${changeNumber}/abandon`, {
body: {
message,
notify: 'OWNER_REVIEWERS', // Avoids notifying cc's
},
});
}
async submitChange(changeNumber: number): Promise<GerritChange> {
const change = await this.gerritHttp.postJson<GerritChange>(
`a/changes/${changeNumber}/submit`,
);
return change.body;
}
async getMessages(changeNumber: number): Promise<GerritChangeMessageInfo[]> {
const messages = await this.gerritHttp.getJson(
`a/changes/${changeNumber}/messages`,
GerritChangeMessages,
);
return messages.body;
}
async addMessage(
changeNumber: number,
fullMessage: string,
tag?: string,
): Promise<void> {
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<boolean> {
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<void> {
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<void> {
await this.gerritHttp.postJson(
`a/changes/${changeNumber}/revisions/current/review`,
{ body: { labels: { [label]: value }, notify: 'NONE' } },
);
}
async setHashtags(
changeNumber: number,
hashtagsInput: GerritHashtagsInput,
): Promise<void> {
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<void> {
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<void> {
await this.gerritHttp.putJson<GerritAccountInfo>(
// 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<string> {
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<GerritChange> {
const change = await this.gerritHttp.postJson<GerritChange>(
`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();