This commit is contained in:
Felipe Santos 2025-01-07 14:38:36 -03:00 committed by GitHub
commit 33b2425a31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 124 additions and 206 deletions

View file

@ -102,7 +102,6 @@ describe('modules/platform/gerrit/client', () => {
'footer:Renovate-Branch=dependency-xyz', 'footer:Renovate-Branch=dependency-xyz',
{ branchName: 'dependency-xyz' }, { branchName: 'dependency-xyz' },
], ],
['hashtag:sourceBranch-dependency-xyz', { branchName: 'dependency-xyz' }], // for backwards compatibility
['label:Code-Review=-2', { branchName: 'dependency-xyz', label: '-2' }], ['label:Code-Review=-2', { branchName: 'dependency-xyz', label: '-2' }],
[ [
'branch:otherTarget', 'branch:otherTarget',
@ -399,10 +398,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 +420,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 +480,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
); );
} }
@ -220,16 +221,7 @@ class GerritClient {
const filterState = mapPrStateToGerritFilter(searchConfig.state); const filterState = mapPrStateToGerritFilter(searchConfig.state);
const filters = ['owner:self', 'project:' + repository, filterState]; const filters = ['owner:self', 'project:' + repository, filterState];
if (searchConfig.branchName) { if (searchConfig.branchName) {
filters.push( filters.push(`footer:Renovate-Branch=${searchConfig.branchName}`);
...[
'(',
`footer:Renovate-Branch=${searchConfig.branchName}`,
// for backwards compatibility
'OR',
`hashtag:sourceBranch-${searchConfig.branchName}`,
')',
],
);
} }
if (searchConfig.targetBranch) { if (searchConfig.targetBranch) {
filters.push(`branch:${searchConfig.targetBranch}`); filters.push(`branch:${searchConfig.targetBranch}`);

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

@ -8,12 +8,6 @@ Support for Gerrit is currently _experimental_, meaning that it _might_ still ha
Renovate stores its metadata in the _commit message footer_. Renovate stores its metadata in the _commit message footer_.
Previously Renovate stored metadata in Gerrit's _hashtags_.
To keep backwards compatibility, Renovate still reads metadata from hashtags.
But Renovate _always_ puts its metadata in the _commit message footer_!
When the Renovate maintainers mark Gerrit support as stable, the maintainers will remove the "read metadata from hashtags" feature.
This means changes without metadata in the commit message footer will be "forgotten" by Renovate.
## Authentication ## Authentication
<figure markdown> <figure markdown>

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

@ -34,10 +34,6 @@ export type GerritReviewersType = 'REVIEWER' | 'CC' | 'REMOVED';
export interface GerritChange { export interface GerritChange {
branch: string; branch: string;
/**
* for backwards compatibility
*/
hashtags?: string[];
change_id: string; change_id: string;
subject: string; subject: string;
status: GerritChangeStatus; status: GerritChangeStatus;
@ -79,6 +75,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 +99,8 @@ export interface GerritMergeableInfo {
| 'CHERRY_PICK'; | 'CHERRY_PICK';
mergeable: boolean; mergeable: boolean;
} }
export interface GerritApprovalInfo {
value?: number;
username?: string;
}

View file

@ -186,47 +186,6 @@ describe('modules/platform/gerrit/utils', () => {
}); });
expect(utils.extractSourceBranch(change)).toBe('renovate/dependency-1.x'); expect(utils.extractSourceBranch(change)).toBe('renovate/dependency-1.x');
}); });
// for backwards compatibility
it('no commit message but with hashtags', () => {
const change = partial<GerritChange>({
hashtags: ['sourceBranch-renovate/dependency-1.x'],
});
expect(utils.extractSourceBranch(change)).toBe('renovate/dependency-1.x');
});
// for backwards compatibility
it('commit message with no footer but with hashtags', () => {
const change = partial<GerritChange>({
hashtags: ['sourceBranch-renovate/dependency-1.x'],
current_revision: 'abc',
revisions: {
abc: partial<GerritRevisionInfo>({
commit: {
message: 'some message...',
},
}),
},
});
expect(utils.extractSourceBranch(change)).toBe('renovate/dependency-1.x');
});
// for backwards compatibility
it('prefers the footer over the hashtags', () => {
const change = partial<GerritChange>({
hashtags: ['sourceBranch-renovate/dependency-1.x'],
current_revision: 'abc',
revisions: {
abc: partial<GerritRevisionInfo>({
commit: {
message:
'Some change\n\nRenovate-Branch: renovate/dependency-2.x\nChange-Id: ...',
},
}),
},
});
expect(utils.extractSourceBranch(change)).toBe('renovate/dependency-2.x');
});
}); });
describe('findPullRequestBody()', () => { describe('findPullRequestBody()', () => {

View file

@ -101,13 +101,6 @@ export function extractSourceBranch(change: GerritChange): string | undefined {
} }
} }
// for backwards compatibility
if (!sourceBranch) {
sourceBranch = change.hashtags
?.find((tag) => tag.startsWith('sourceBranch-'))
?.replace('sourceBranch-', '');
}
return sourceBranch ?? undefined; return sourceBranch ?? undefined;
} }

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,