This commit is contained in:
Felipe Santos 2025-01-02 18:34:12 +01:00 committed by GitHub
commit 3e24b3ff7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 123 additions and 137 deletions

View file

@ -399,10 +399,20 @@ describe('modules/platform/gerrit/client', () => {
describe('approveChange()', () => { describe('approveChange()', () => {
it('already approved - do nothing', async () => { it('already approved - do nothing', async () => {
const change = partial<GerritChange>({}); const change = partial<GerritChange>({
labels: {
'Code-Review': {
all: [{ value: 2 }],
},
},
});
httpMock httpMock
.scope(gerritEndpointUrl) .scope(gerritEndpointUrl)
.get((url) => url.includes('/a/changes/123456?o=')) .get(
(url) =>
url.includes('/a/changes/123456?o=') &&
url.includes('o=DETAILED_LABELS'),
)
.reply(200, gerritRestResponse(change), jsonResultHeader); .reply(200, gerritRestResponse(change), jsonResultHeader);
await expect(client.approveChange(123456)).toResolve(); await expect(client.approveChange(123456)).toResolve();
}); });
@ -411,17 +421,54 @@ describe('modules/platform/gerrit/client', () => {
const change = partial<GerritChange>({ labels: {} }); const change = partial<GerritChange>({ labels: {} });
httpMock httpMock
.scope(gerritEndpointUrl) .scope(gerritEndpointUrl)
.get((url) => url.includes('/a/changes/123456?o=')) .get(
(url) =>
url.includes('/a/changes/123456?o=') &&
url.includes('o=DETAILED_LABELS'),
)
.reply(200, gerritRestResponse(change), jsonResultHeader); .reply(200, gerritRestResponse(change), jsonResultHeader);
await expect(client.approveChange(123456)).toResolve(); await expect(client.approveChange(123456)).toResolve();
}); });
it('not already approved - approve now', async () => { it('not already approved - approve now', async () => {
const change = partial<GerritChange>({ labels: { 'Code-Review': {} } }); const change = partial<GerritChange>({
labels: { 'Code-Review': { all: [] } },
});
httpMock httpMock
.scope(gerritEndpointUrl) .scope(gerritEndpointUrl)
.get((url) => url.includes('/a/changes/123456?o=')) .get(
(url) =>
url.includes('/a/changes/123456?o=') &&
url.includes('o=DETAILED_LABELS'),
)
.reply(200, gerritRestResponse(change), jsonResultHeader);
const approveMock = httpMock
.scope(gerritEndpointUrl)
.post('/a/changes/123456/revisions/current/review', {
labels: { 'Code-Review': +2 },
notify: 'NONE',
})
.reply(200, gerritRestResponse(''), jsonResultHeader);
await expect(client.approveChange(123456)).toResolve();
expect(approveMock.isDone()).toBeTrue();
});
it('not already approved because of +1 - approve now', async () => {
const change = partial<GerritChange>({
labels: {
'Code-Review': {
all: [{ value: 1 }],
},
},
});
httpMock
.scope(gerritEndpointUrl)
.get(
(url) =>
url.includes('/a/changes/123456?o=') &&
url.includes('o=DETAILED_LABELS'),
)
.reply(200, gerritRestResponse(change), jsonResultHeader); .reply(200, gerritRestResponse(change), jsonResultHeader);
const approveMock = httpMock const approveMock = httpMock
.scope(gerritEndpointUrl) .scope(gerritEndpointUrl)
@ -434,63 +481,6 @@ describe('modules/platform/gerrit/client', () => {
expect(approveMock.isDone()).toBeTrue(); expect(approveMock.isDone()).toBeTrue();
}); });
}); });
describe('wasApprovedBy()', () => {
it('label not exists', () => {
expect(
client.wasApprovedBy(partial<GerritChange>({}), 'user'),
).toBeUndefined();
});
it('not approved by anyone', () => {
expect(
client.wasApprovedBy(
partial<GerritChange>({
labels: {
'Code-Review': {},
},
}),
'user',
),
).toBeUndefined();
});
it('approved by given user', () => {
expect(
client.wasApprovedBy(
partial<GerritChange>({
labels: {
'Code-Review': {
approved: {
_account_id: 1,
username: 'user',
},
},
},
}),
'user',
),
).toBeTrue();
});
it('approved by given other', () => {
expect(
client.wasApprovedBy(
partial<GerritChange>({
labels: {
'Code-Review': {
approved: {
_account_id: 1,
username: 'other',
},
},
},
}),
'user',
),
).toBeFalse();
});
});
}); });
function gerritRestResponse(body: any): any { function gerritRestResponse(body: any): any {

View file

@ -2,6 +2,7 @@ import { REPOSITORY_ARCHIVED } from '../../../constants/error-messages';
import { logger } from '../../../logger'; import { logger } from '../../../logger';
import { GerritHttp } from '../../../util/http/gerrit'; import { GerritHttp } from '../../../util/http/gerrit';
import { regEx } from '../../../util/regex'; import { regEx } from '../../../util/regex';
import { getQueryString } from '../../../util/url';
import type { import type {
GerritAccountInfo, GerritAccountInfo,
GerritBranchInfo, GerritBranchInfo,
@ -72,10 +73,14 @@ class GerritClient {
return changes.body; return changes.body;
} }
async getChange(changeNumber: number): Promise<GerritChange> { async getChange(
changeNumber: number,
extraOptions?: string[],
): Promise<GerritChange> {
const options = [...this.requestDetails, ...(extraOptions ?? [])];
const queryString = getQueryString({ o: options });
const changes = await this.gerritHttp.getJson<GerritChange>( const changes = await this.gerritHttp.getJson<GerritChange>(
`a/changes/${changeNumber}?` + `a/changes/${changeNumber}?${queryString}`,
this.requestDetails.map((det) => `o=${det}`).join('&'),
); );
return changes.body; return changes.body;
} }
@ -196,15 +201,11 @@ class GerritClient {
} }
async checkIfApproved(changeId: number): Promise<boolean> { async checkIfApproved(changeId: number): Promise<boolean> {
const change = await client.getChange(changeId); const change = await client.getChange(changeId, ['DETAILED_LABELS']);
const reviewLabels = change?.labels?.['Code-Review']; const reviewLabel = change?.labels?.['Code-Review'];
return reviewLabels === undefined || reviewLabels.approved !== undefined;
}
wasApprovedBy(change: GerritChange, username: string): boolean | undefined {
return ( return (
change.labels?.['Code-Review'].approved && reviewLabel === undefined ||
change.labels['Code-Review'].approved.username === username reviewLabel.all?.some((label) => label.value === 2) === true
); );
} }

View file

@ -187,28 +187,6 @@ describe('modules/platform/gerrit/index', () => {
gerrit.writeToConfig({ labels: {} }); gerrit.writeToConfig({ labels: {} });
}); });
it('updatePr() - auto approve enabled', async () => {
const change = partial<GerritChange>({
current_revision: 'some-revision',
revisions: {
'some-revision': partial<GerritRevisionInfo>({
commit: {
message: 'some message',
},
}),
},
});
clientMock.getChange.mockResolvedValueOnce(change);
await gerrit.updatePr({
number: 123456,
prTitle: 'subject',
platformPrOptions: {
autoApprove: true,
},
});
expect(clientMock.approveChange).toHaveBeenCalledWith(123456);
});
it('updatePr() - closed => abandon the change', async () => { it('updatePr() - closed => abandon the change', async () => {
const change = partial<GerritChange>({}); const change = partial<GerritChange>({});
clientMock.getChange.mockResolvedValueOnce(change); clientMock.getChange.mockResolvedValueOnce(change);
@ -309,7 +287,7 @@ describe('modules/platform/gerrit/index', () => {
]); ]);
}); });
it('createPr() - update body WITHOUT approve', async () => { it('createPr() - update body', async () => {
const pr = await gerrit.createPr({ const pr = await gerrit.createPr({
sourceBranch: 'source', sourceBranch: 'source',
targetBranch: 'target', targetBranch: 'target',
@ -325,26 +303,6 @@ describe('modules/platform/gerrit/index', () => {
'body', 'body',
TAG_PULL_REQUEST_BODY, TAG_PULL_REQUEST_BODY,
); );
expect(clientMock.approveChange).not.toHaveBeenCalled();
});
it('createPr() - update body and approve', async () => {
const pr = await gerrit.createPr({
sourceBranch: 'source',
targetBranch: 'target',
prTitle: change.subject,
prBody: 'body',
platformPrOptions: {
autoApprove: true,
},
});
expect(pr).toHaveProperty('number', 123456);
expect(clientMock.addMessageIfNotAlreadyExists).toHaveBeenCalledWith(
123456,
'body',
TAG_PULL_REQUEST_BODY,
);
expect(clientMock.approveChange).toHaveBeenCalledWith(123456);
}); });
}); });
@ -750,6 +708,13 @@ describe('modules/platform/gerrit/index', () => {
//TODO: add some tests for Gerrit-specific replacements.. //TODO: add some tests for Gerrit-specific replacements..
}); });
describe('approvePrForAutomerge()', () => {
it('approvePrForAutomerge() - calls approveChange', async () => {
await expect(gerrit.approvePrForAutomerge(123456)).toResolve();
expect(clientMock.approveChange).toHaveBeenCalledWith(123456);
});
});
describe('currently unused/not-implemented functions', () => { describe('currently unused/not-implemented functions', () => {
it('deleteLabel()', async () => { it('deleteLabel()', async () => {
await expect( await expect(

View file

@ -160,9 +160,6 @@ export async function updatePr(prConfig: UpdatePrConfig): Promise<void> {
TAG_PULL_REQUEST_BODY, TAG_PULL_REQUEST_BODY,
); );
} }
if (prConfig.platformPrOptions?.autoApprove) {
await client.approveChange(prConfig.number);
}
if (prConfig.state && prConfig.state === 'closed') { if (prConfig.state && prConfig.state === 'closed') {
await client.abandonChange(prConfig.number); await client.abandonChange(prConfig.number);
} }
@ -195,9 +192,6 @@ export async function createPr(prConfig: CreatePRConfig): Promise<Pr | null> {
prConfig.prBody, prConfig.prBody,
TAG_PULL_REQUEST_BODY, TAG_PULL_REQUEST_BODY,
); );
if (prConfig.platformPrOptions?.autoApprove) {
await client.approveChange(pr._number);
}
return getPr(pr._number); return getPr(pr._number);
} }
@ -442,3 +436,14 @@ export function findIssue(title: string): Promise<Issue | null> {
export function getIssueList(): Promise<Issue[]> { export function getIssueList(): Promise<Issue[]> {
return Promise.resolve([]); return Promise.resolve([]);
} }
/**
* The Code-Review +2 vote of when the change was created or updated in Gerrit
* may have been downgraded by a CI check utilizing the same account as
* Renovate (e.g. SonarQube which posts Code-Review +1). This function will
* vote with +2 again on the change, if needed, before Renovate attempt to
* automerge it.
*/
export async function approvePrForAutomerge(number: number): Promise<void> {
await client.approveChange(number);
}

View file

@ -369,7 +369,6 @@ describe('modules/platform/gerrit/scm', () => {
}, },
}); });
clientMock.findChanges.mockResolvedValueOnce([existingChange]); clientMock.findChanges.mockResolvedValueOnce([existingChange]);
clientMock.wasApprovedBy.mockReturnValueOnce(true);
git.prepareCommit.mockResolvedValueOnce({ git.prepareCommit.mockResolvedValueOnce({
commitSha: 'commitSha' as LongCommitSha, commitSha: 'commitSha' as LongCommitSha,
parentCommitSha: 'parentSha' as LongCommitSha, parentCommitSha: 'parentSha' as LongCommitSha,
@ -385,6 +384,7 @@ describe('modules/platform/gerrit/scm', () => {
message: 'commit msg', message: 'commit msg',
files: [], files: [],
prTitle: 'pr title', prTitle: 'pr title',
autoApprove: true,
}), }),
).toBe('commitSha'); ).toBe('commitSha');
expect(git.prepareCommit).toHaveBeenCalledWith({ expect(git.prepareCommit).toHaveBeenCalledWith({
@ -396,19 +396,15 @@ describe('modules/platform/gerrit/scm', () => {
'Renovate-Branch: renovate/dependency-1.x\nChange-Id: ...', 'Renovate-Branch: renovate/dependency-1.x\nChange-Id: ...',
], ],
prTitle: 'pr title', prTitle: 'pr title',
autoApprove: true,
force: true, force: true,
}); });
expect(git.fetchRevSpec).toHaveBeenCalledWith('refs/changes/1/2'); expect(git.fetchRevSpec).toHaveBeenCalledWith('refs/changes/1/2');
expect(git.pushCommit).toHaveBeenCalledWith({ expect(git.pushCommit).toHaveBeenCalledWith({
files: [], files: [],
sourceRef: 'renovate/dependency-1.x', sourceRef: 'renovate/dependency-1.x',
targetRef: 'refs/for/main%notify=NONE', targetRef: 'refs/for/main%notify=NONE,l=Code-Review+2',
}); });
expect(clientMock.wasApprovedBy).toHaveBeenCalledWith(
existingChange,
'user',
);
expect(clientMock.approveChange).toHaveBeenCalledWith(123456);
}); });
}); });
}); });

View file

@ -129,19 +129,16 @@ export class GerritScm extends DefaultGitScm {
hasChanges = await git.hasDiff('HEAD', 'FETCH_HEAD'); //avoid empty patchsets hasChanges = await git.hasDiff('HEAD', 'FETCH_HEAD'); //avoid empty patchsets
} }
if (hasChanges || commit.force) { if (hasChanges || commit.force) {
const pushOptions = ['notify=NONE'];
if (commit.autoApprove) {
pushOptions.push('l=Code-Review+2');
}
const pushResult = await git.pushCommit({ const pushResult = await git.pushCommit({
sourceRef: commit.branchName, sourceRef: commit.branchName,
targetRef: `refs/for/${commit.baseBranch!}%notify=NONE`, targetRef: `refs/for/${commit.baseBranch!}%${pushOptions.join(',')}`,
files: commit.files, files: commit.files,
}); });
if (pushResult) { if (pushResult) {
//existingChange was the old change before commit/push. we need to approve again, if it was previously approved from renovate
if (
existingChange &&
client.wasApprovedBy(existingChange, username)
) {
await client.approveChange(existingChange._number);
}
return commitSha; return commitSha;
} }
} }

View file

@ -79,6 +79,8 @@ export interface GerritLabelInfo {
rejected?: GerritAccountInfo; rejected?: GerritAccountInfo;
/** If true, the label blocks submit operation. If not set, the default is false. */ /** If true, the label blocks submit operation. If not set, the default is false. */
blocking?: boolean; blocking?: boolean;
/** List of votes. Only set when o=DETAILED_LABELS. */
all?: GerritApprovalInfo[];
} }
export interface GerritActionInfo { export interface GerritActionInfo {
@ -101,3 +103,8 @@ export interface GerritMergeableInfo {
| 'CHERRY_PICK'; | 'CHERRY_PICK';
mergeable: boolean; mergeable: boolean;
} }
export interface GerritApprovalInfo {
value?: number;
username?: string;
}

View file

@ -283,6 +283,11 @@ export interface Platform {
maxBodyLength(): number; maxBodyLength(): number;
labelCharLimit?(): number; labelCharLimit?(): number;
/**
* Platforms that support `autoApprove` can implement this function. It will be
* invoked during automerge before the PR branch status is checked.
*/
approvePrForAutomerge?(number: number): Promise<void>;
} }
export interface PlatformScm { export interface PlatformScm {

View file

@ -86,6 +86,8 @@ export interface CommitFilesConfig {
platformCommit?: PlatformCommitOptions; platformCommit?: PlatformCommitOptions;
/** Only needed by Gerrit platform */ /** Only needed by Gerrit platform */
prTitle?: string; prTitle?: string;
/** Only needed by Gerrit platform */
autoApprove?: boolean;
} }
export interface PushFilesConfig { export interface PushFilesConfig {

View file

@ -62,5 +62,7 @@ export function commitFilesToBranch(
platformCommit: config.platformCommit, platformCommit: config.platformCommit,
// Only needed by Gerrit platform // Only needed by Gerrit platform
prTitle: config.prTitle, prTitle: config.prTitle,
// Only needed by Gerrit platform
autoApprove: config.autoApprove,
}); });
} }

View file

@ -124,6 +124,18 @@ describe('workers/repository/update/pr/automerge', () => {
expect(platform.mergePr).toHaveBeenCalledTimes(0); expect(platform.mergePr).toHaveBeenCalledTimes(0);
}); });
it('should attempt to auto-approve if supported', async () => {
config.autoApprove = true;
config.automerge = true;
config.pruneBranchAfterAutomerge = true;
platform.getBranchStatus.mockResolvedValueOnce('green');
platform.mergePr.mockResolvedValueOnce(true);
const res = await prAutomerge.checkAutoMerge(pr, config);
expect(res).toEqual({ automerged: true, branchRemoved: true });
expect(platform.approvePrForAutomerge).toHaveBeenCalledTimes(1);
expect(platform.mergePr).toHaveBeenCalledTimes(1);
});
it('should not automerge if enabled and pr is unmergeable', async () => { it('should not automerge if enabled and pr is unmergeable', async () => {
config.automerge = true; config.automerge = true;
scm.isBranchConflicted.mockResolvedValueOnce(true); scm.isBranchConflicted.mockResolvedValueOnce(true);

View file

@ -69,6 +69,10 @@ export async function checkAutoMerge(
prAutomergeBlockReason: 'PlatformNotReady', prAutomergeBlockReason: 'PlatformNotReady',
}; };
} }
if (config.autoApprove && platform.approvePrForAutomerge) {
logger.debug('Auto-approving PR for automerge');
await platform.approvePrForAutomerge(pr.number);
}
const branchStatus = await resolveBranchStatus( const branchStatus = await resolveBranchStatus(
branchName, branchName,
!!config.internalChecksAsSuccess, !!config.internalChecksAsSuccess,