diff --git a/docs/usage/assets/images/gerrit-http-password.png b/docs/usage/assets/images/gerrit-http-password.png new file mode 100644 index 00000000000000..fcbb6226f36756 Binary files /dev/null and b/docs/usage/assets/images/gerrit-http-password.png differ diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 1a32eb51dc3e4c..33bf0affa54058 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -1042,7 +1042,7 @@ const options: RenovateOptions[] = [ description: 'Set to `true` to automatically approve PRs.', type: 'boolean', default: false, - supportedPlatforms: ['azure', 'gitlab'], + supportedPlatforms: ['azure', 'gerrit', 'gitlab'], }, // depType { diff --git a/lib/config/presets/local/index.ts b/lib/config/presets/local/index.ts index 96b1d4d4690211..6b7e39a3738f2f 100644 --- a/lib/config/presets/local/index.ts +++ b/lib/config/presets/local/index.ts @@ -21,6 +21,7 @@ const resolvers = { bitbucket: local, 'bitbucket-server': local, codecommit: null, + gerrit: local, gitea, github, gitlab, diff --git a/lib/constants/platforms.ts b/lib/constants/platforms.ts index c43a9ca9fc7281..d1ee8156312ba6 100644 --- a/lib/constants/platforms.ts +++ b/lib/constants/platforms.ts @@ -3,6 +3,7 @@ export type PlatformId = | 'codecommit' | 'bitbucket' | 'bitbucket-server' + | 'gerrit' | 'gitea' | 'github' | 'gitlab' diff --git a/lib/modules/platform/api.ts b/lib/modules/platform/api.ts index 973f6b75d39db6..7d0ab19cee6bcb 100644 --- a/lib/modules/platform/api.ts +++ b/lib/modules/platform/api.ts @@ -3,6 +3,7 @@ import * as azure from './azure'; import * as bitbucket from './bitbucket'; import * as bitbucketServer from './bitbucket-server'; import * as codecommit from './codecommit'; +import * as gerrit from './gerrit'; import * as gitea from './gitea'; import * as github from './github'; import * as gitlab from './gitlab'; @@ -16,6 +17,7 @@ api.set(azure.id, azure); api.set(bitbucket.id, bitbucket); api.set(bitbucketServer.id, bitbucketServer); api.set(codecommit.id, codecommit); +api.set(gerrit.id, gerrit); api.set(gitea.id, gitea); api.set(github.id, github); api.set(gitlab.id, gitlab); diff --git a/lib/modules/platform/gerrit/client.spec.ts b/lib/modules/platform/gerrit/client.spec.ts new file mode 100644 index 00000000000000..8d64188c9f0fa3 --- /dev/null +++ b/lib/modules/platform/gerrit/client.spec.ts @@ -0,0 +1,482 @@ +import * as httpMock from '../../../../test/http-mock'; +import { partial } from '../../../../test/util'; +import { REPOSITORY_ARCHIVED } from '../../../constants/error-messages'; +import { setBaseUrl } from '../../../util/http/gerrit'; +import type { FindPRConfig } from '../types'; +import { client } from './client'; +import type { + GerritChange, + GerritChangeMessageInfo, + GerritFindPRConfig, + GerritMergeableInfo, +} from './types'; + +const gerritEndpointUrl = 'https://dev.gerrit.com/renovate/'; +const jsonResultHeader = { 'content-type': 'application/json;charset=utf-8' }; + +describe('modules/platform/gerrit/client', () => { + beforeAll(() => { + setBaseUrl(gerritEndpointUrl); + }); + + describe('getRepos()', () => { + it('returns repos', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/projects/?type=CODE&state=ACTIVE') + .reply( + 200, + gerritRestResponse({ + repo1: { id: 'repo1', state: 'ACTIVE' }, + repo2: { id: 'repo2', state: 'ACTIVE' }, + }), + jsonResultHeader, + ); + expect(await client.getRepos()).toEqual(['repo1', 'repo2']); + }); + }); + + describe('getProjectInfo()', () => { + it('inactive', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/projects/test%2Frepo') + .reply( + 200, + gerritRestResponse({ + id: 'repo1', + name: 'test-repo', + state: 'READ_ONLY', + }), + jsonResultHeader, + ); + await expect(client.getProjectInfo('test/repo')).rejects.toThrow( + REPOSITORY_ARCHIVED, + ); + }); + + it('active', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/projects/test%2Frepo') + .reply( + 200, + gerritRestResponse({ + id: 'repo1', + name: 'test-repo', + state: 'ACTIVE', + }), + jsonResultHeader, + ); + await expect(client.getProjectInfo('test/repo')).resolves.toEqual({ + id: 'repo1', + name: 'test-repo', + state: 'ACTIVE', + }); + }); + }); + + describe('getBranchInfo()', () => { + it('info', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/projects/test%2Frepo/branches/HEAD') + .reply( + 200, + gerritRestResponse({ ref: 'sha-hash....', revision: 'main' }), + jsonResultHeader, + ); + await expect(client.getBranchInfo('test/repo')).resolves.toEqual({ + ref: 'sha-hash....', + revision: 'main', + }); + }); + }); + + describe('findChanges()', () => { + it.each([ + ['owner:self', { branchName: 'dependency-xyz' }], + ['project:repo', { branchName: 'dependency-xyz' }], + ['-is:wip', { branchName: 'dependency-xyz' }], + ['hashtag:sourceBranch-dependency-xyz', { branchName: 'dependency-xyz' }], + ['label:Code-Review=-2', { branchName: 'dependency-xyz', label: '-2' }], + [ + 'branch:otherTarget', + { branchName: 'dependency-xyz', targetBranch: 'otherTarget' }, + ], + [ + 'status:closed', + { + branchName: 'dependency-xyz', + state: 'closed' as FindPRConfig['state'], + }, + ], + ])( + 'query contains %p', + async (expectedQueryPart: string, config: GerritFindPRConfig) => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/changes/') + .query((query) => query?.q?.includes(expectedQueryPart) ?? false) + .reply( + 200, + gerritRestResponse([{ _number: 1 }, { _number: 2 }]), + jsonResultHeader, + ); + await expect(client.findChanges('repo', config)).resolves.toEqual([ + { _number: 1 }, + { _number: 2 }, + ]); + }, + ); + }); + + describe('getChange()', () => { + it('get', async () => { + const change = partial({}); + httpMock + .scope(gerritEndpointUrl) + .get( + '/a/changes/123456?o=SUBMITTABLE&o=CHECK&o=MESSAGES&o=DETAILED_ACCOUNTS&o=LABELS&o=CURRENT_ACTIONS&o=CURRENT_REVISION', + ) + .reply(200, gerritRestResponse(change), jsonResultHeader); + await expect(client.getChange(123456)).resolves.toEqual(change); + }); + }); + + describe('getMergeableInfo()', () => { + it('get', async () => { + const mergeInfo: GerritMergeableInfo = { + mergeable: true, + submit_type: 'MERGE_IF_NECESSARY', + }; + httpMock + .scope(gerritEndpointUrl) + .get('/a/changes/123456/revisions/current/mergeable') + .reply(200, gerritRestResponse(mergeInfo), jsonResultHeader); + await expect( + client.getMergeableInfo(partial({ _number: 123456 })), + ).resolves.toEqual(mergeInfo); + }); + }); + + describe('abandonChange()', () => { + it('abandon', async () => { + httpMock + .scope(gerritEndpointUrl) + .post('/a/changes/123456/abandon') + .reply(200, gerritRestResponse({}), jsonResultHeader); + await expect(client.abandonChange(123456)).toResolve(); + }); + }); + + describe('submitChange()', () => { + it('submit', async () => { + const change = partial({}); + httpMock + .scope(gerritEndpointUrl) + .post('/a/changes/123456/submit') + .reply(200, gerritRestResponse(change), jsonResultHeader); + await expect(client.submitChange(123456)).resolves.toEqual(change); + }); + }); + + describe('setCommitMessage()', () => { + it('setCommitMessage', async () => { + const change = partial({}); + httpMock + .scope(gerritEndpointUrl) + .put('/a/changes/123456/message', { message: 'new message' }) + .reply(200, gerritRestResponse(change), jsonResultHeader); + await expect(client.setCommitMessage(123456, 'new message')).toResolve(); + }); + }); + + describe('updateCommitMessage', () => { + it('updateCommitMessage - success', async () => { + const change = partial({}); + httpMock + .scope(gerritEndpointUrl) + .put('/a/changes/123456/message', { + message: `new message\n\nChange-Id: changeID\n`, + }) + .reply(200, gerritRestResponse(change), jsonResultHeader); + await expect( + client.updateCommitMessage(123456, 'changeID', 'new message'), + ).toResolve(); + }); + }); + + describe('getMessages()', () => { + it('no messages', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/changes/123456/messages') + .reply(200, gerritRestResponse([]), jsonResultHeader); + await expect(client.getMessages(123456)).resolves.toEqual([]); + }); + + it('with messages', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/changes/123456/messages') + .reply( + 200, + gerritRestResponse([ + partial({ message: 'msg1' }), + partial({ message: 'msg2' }), + ]), + jsonResultHeader, + ); + await expect(client.getMessages(123456)).resolves.toEqual([ + { message: 'msg1' }, + { message: 'msg2' }, + ]); + }); + }); + + describe('addMessage()', () => { + it('add with tag', async () => { + httpMock + .scope(gerritEndpointUrl) + .post('/a/changes/123456/revisions/current/review', { + message: 'message', + tag: 'tag', + }) + .reply(200, gerritRestResponse([]), jsonResultHeader); + await expect(client.addMessage(123456, 'message', 'tag')).toResolve(); + }); + + it('add without tag', async () => { + httpMock + .scope(gerritEndpointUrl) + .post('/a/changes/123456/revisions/current/review', { + message: 'message', + }) + .reply(200, gerritRestResponse([]), jsonResultHeader); + await expect(client.addMessage(123456, 'message')).toResolve(); + }); + }); + + describe('checkForExistingMessage()', () => { + it('msg not found', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/changes/123456/messages') + .reply(200, gerritRestResponse([]), jsonResultHeader); + await expect( + client.checkForExistingMessage(123456, ' the message '), + ).resolves.toBeFalse(); + }); + + it('msg found', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/changes/123456/messages') + .reply( + 200, + gerritRestResponse([ + partial({ message: 'msg1' }), + partial({ message: 'the message' }), + ]), + jsonResultHeader, + ); + await expect( + client.checkForExistingMessage(123456, 'the message'), + ).resolves.toBeTrue(); + }); + }); + + describe('addMessageIfNotAlreadyExists()', () => { + it('msg not found', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/changes/123456/messages') + .reply(200, gerritRestResponse([]), jsonResultHeader); + httpMock + .scope(gerritEndpointUrl) + .post('/a/changes/123456/revisions/current/review', { + message: 'new trimmed message', + tag: 'TAG', + }) + .reply(200, gerritRestResponse([]), jsonResultHeader); + + await expect( + client.addMessageIfNotAlreadyExists( + 123456, + ' new trimmed message\n', + 'TAG', + ), + ).toResolve(); + }); + + it('msg already exists', async () => { + httpMock + .scope(gerritEndpointUrl) + .get('/a/changes/123456/messages') + .reply( + 200, + gerritRestResponse([ + partial({ message: 'msg1', tag: 'TAG' }), + ]), + jsonResultHeader, + ); + + await expect( + client.addMessageIfNotAlreadyExists(123456, 'msg1\n', 'TAG'), + ).toResolve(); + }); + }); + + describe('setLabel()', () => { + it('setLabel', async () => { + httpMock + .scope(gerritEndpointUrl) + .post('/a/changes/123456/revisions/current/review', { + labels: { 'Code-Review': 2 }, + }) + .reply(200, gerritRestResponse([]), jsonResultHeader); + await expect(client.setLabel(123456, 'Code-Review', +2)).toResolve(); + }); + }); + + describe('addReviewer()', () => { + it('add', async () => { + httpMock + .scope(gerritEndpointUrl) + .post('/a/changes/123456/reviewers', { + reviewer: 'username', + }) + .reply(200, gerritRestResponse([]), jsonResultHeader); + await expect(client.addReviewer(123456, 'username')).toResolve(); + }); + }); + + describe('addAssignee()', () => { + it('add', async () => { + httpMock + .scope(gerritEndpointUrl) + .put('/a/changes/123456/assignee', { + assignee: 'username', + }) + .reply(200, gerritRestResponse([]), jsonResultHeader); + await expect(client.addAssignee(123456, 'username')).toResolve(); + }); + }); + + describe('getFile()', () => { + it('getFile() - repo and branch', async () => { + httpMock + .scope(gerritEndpointUrl) + .get( + '/a/projects/test%2Frepo/branches/main/files/renovate.json/content', + ) + .reply(200, gerritFileResponse('{}')); + await expect( + client.getFile('test/repo', 'main', 'renovate.json'), + ).resolves.toBe('{}'); + }); + }); + + describe('approveChange()', () => { + it('already approved - do nothing', async () => { + const change = partial({}); + httpMock + .scope(gerritEndpointUrl) + .get((url) => url.includes('/a/changes/123456?o=')) + .reply(200, gerritRestResponse(change), jsonResultHeader); + await expect(client.approveChange(123456)).toResolve(); + }); + + it('label not available - do nothing', async () => { + const change = partial({ labels: {} }); + httpMock + .scope(gerritEndpointUrl) + .get((url) => url.includes('/a/changes/123456?o=')) + .reply(200, gerritRestResponse(change), jsonResultHeader); + + await expect(client.approveChange(123456)).toResolve(); + }); + + it('not already approved - approve now', async () => { + const change = partial({ labels: { 'Code-Review': {} } }); + httpMock + .scope(gerritEndpointUrl) + .get((url) => url.includes('/a/changes/123456?o=')) + .reply(200, gerritRestResponse(change), jsonResultHeader); + const approveMock = httpMock + .scope(gerritEndpointUrl) + .post('/a/changes/123456/revisions/current/review', { + labels: { 'Code-Review': +2 }, + }) + .reply(200, gerritRestResponse(''), jsonResultHeader); + await expect(client.approveChange(123456)).toResolve(); + expect(approveMock.isDone()).toBeTrue(); + }); + }); + + describe('wasApprovedBy()', () => { + it('label not exists', () => { + expect( + client.wasApprovedBy(partial({}), 'user'), + ).toBeUndefined(); + }); + + it('not approved by anyone', () => { + expect( + client.wasApprovedBy( + partial({ + labels: { + 'Code-Review': {}, + }, + }), + 'user', + ), + ).toBeUndefined(); + }); + + it('approved by given user', () => { + expect( + client.wasApprovedBy( + partial({ + labels: { + 'Code-Review': { + approved: { + _account_id: 1, + username: 'user', + }, + }, + }, + }), + 'user', + ), + ).toBeTrue(); + }); + + it('approved by given other', () => { + expect( + client.wasApprovedBy( + partial({ + labels: { + 'Code-Review': { + approved: { + _account_id: 1, + username: 'other', + }, + }, + }, + }), + 'user', + ), + ).toBeFalse(); + }); + }); +}); + +function gerritRestResponse(body: any): any { + return `)]}'\n${JSON.stringify(body)}`; +} + +function gerritFileResponse(content: string): any { + return Buffer.from(content).toString('base64'); +} diff --git a/lib/modules/platform/gerrit/client.ts b/lib/modules/platform/gerrit/client.ts new file mode 100644 index 00000000000000..4a5e3ccebab75c --- /dev/null +++ b/lib/modules/platform/gerrit/client.ts @@ -0,0 +1,235 @@ +import { REPOSITORY_ARCHIVED } from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import { GerritHttp } from '../../../util/http/gerrit'; +import type { + GerritAccountInfo, + GerritBranchInfo, + GerritChange, + GerritChangeMessageInfo, + GerritFindPRConfig, + GerritMergeableInfo, + GerritProjectInfo, +} from './types'; +import { mapPrStateToGerritFilter } from './utils'; + +class GerritClient { + private requestDetails = [ + 'SUBMITTABLE', //include the submittable field in ChangeInfo, which can be used to tell if the change is reviewed and ready for submit. + 'CHECK', // include potential problems with the change. + 'MESSAGES', + 'DETAILED_ACCOUNTS', + 'LABELS', + 'CURRENT_ACTIONS', //to check if current_revision can be "rebased" + 'CURRENT_REVISION', //get RevisionInfo::ref to fetch + ] as const; + + private gerritHttp = new GerritHttp(); + + async getRepos(): Promise { + const res = await this.gerritHttp.getJson( + 'a/projects/?type=CODE&state=ACTIVE', + {}, + ); + return Object.keys(res.body); + } + + async getProjectInfo(repository: string): Promise { + const projectInfo = await this.gerritHttp.getJson( + `a/projects/${encodeURIComponent(repository)}`, + ); + 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`, + ); + return branchInfo.body; + } + + async findChanges( + repository: string, + findPRConfig: GerritFindPRConfig, + refreshCache?: boolean, + ): Promise { + const filters = GerritClient.buildSearchFilters(repository, findPRConfig); + const changes = await this.gerritHttp.getJson( + `a/changes/?q=` + + filters.join('+') + + this.requestDetails.map((det) => `&o=${det}`).join(''), + { memCache: !refreshCache }, + ); + logger.trace( + `findChanges(${filters.join(', ')}) => ${changes.body.length}`, + ); + return changes.body; + } + + async getChange(changeNumber: number): Promise { + const changes = await this.gerritHttp.getJson( + `a/changes/${changeNumber}?` + + this.requestDetails.map((det) => `o=${det}`).join('&'), + ); + return changes.body; + } + + async getMergeableInfo(change: GerritChange): Promise { + const mergeable = await this.gerritHttp.getJson( + `a/changes/${change._number}/revisions/current/mergeable`, + ); + return mergeable.body; + } + + async abandonChange(changeNumber: number): Promise { + await this.gerritHttp.postJson(`a/changes/${changeNumber}/abandon`); + } + + async submitChange(changeNumber: number): Promise { + const change = await this.gerritHttp.postJson( + `a/changes/${changeNumber}/submit`, + ); + return change.body; + } + + async setCommitMessage(changeNumber: number, message: string): Promise { + await this.gerritHttp.putJson(`a/changes/${changeNumber}/message`, { + body: { message }, + }); + } + + async updateCommitMessage( + number: number, + gerritChangeID: string, + prTitle: string, + ): Promise { + await this.setCommitMessage( + number, + `${prTitle}\n\nChange-Id: ${gerritChangeID}\n`, + ); + } + + async getMessages(changeNumber: number): Promise { + const messages = await this.gerritHttp.getJson( + `a/changes/${changeNumber}/messages`, + { memCache: false }, + ); + return messages.body; + } + + async addMessage( + changeNumber: number, + message: string, + tag?: string, + ): Promise { + await this.gerritHttp.postJson( + `a/changes/${changeNumber}/revisions/current/review`, + { body: { message, tag } }, + ); + } + + async checkForExistingMessage( + changeNumber: number, + newMessage: string, + msgType?: string, + ): Promise { + const messages = await this.getMessages(changeNumber); + return messages.some( + (existingMsg) => + (msgType === undefined || msgType === existingMsg.tag) && + existingMsg.message.includes(newMessage), + ); + } + + async addMessageIfNotAlreadyExists( + changeNumber: number, + message: string, + tag?: string, + ): Promise { + const newMsg = message.trim(); //the last \n was removed from gerrit after the comment was added... + 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 } } }, + ); + } + + async addReviewer(changeNumber: number, reviewer: string): Promise { + await this.gerritHttp.postJson(`a/changes/${changeNumber}/reviewers`, { + body: { reviewer }, + }); + } + + async addAssignee(changeNumber: number, assignee: string): Promise { + await this.gerritHttp.putJson( + `a/changes/${changeNumber}/assignee`, + { + body: { assignee }, + }, + ); + } + + async getFile( + repo: string, + branch: string, + fileName: string, + ): Promise { + const base64Content = await this.gerritHttp.get( + `a/projects/${encodeURIComponent( + repo, + )}/branches/${branch}/files/${encodeURIComponent(fileName)}/content`, + ); + return Buffer.from(base64Content.body, 'base64').toString(); + } + + async approveChange(changeId: number): Promise { + const isApproved = await this.checkIfApproved(changeId); + if (!isApproved) { + await this.setLabel(changeId, 'Code-Review', +2); + } + } + + async checkIfApproved(changeId: number): Promise { + const change = await client.getChange(changeId); + const reviewLabels = change?.labels?.['Code-Review']; + return reviewLabels === undefined || reviewLabels.approved !== undefined; + } + + wasApprovedBy(change: GerritChange, username: string): boolean | undefined { + return ( + change.labels?.['Code-Review'].approved && + change.labels['Code-Review'].approved.username === username + ); + } + + private static buildSearchFilters( + repository: string, + searchConfig: GerritFindPRConfig, + ): string[] { + const filterState = mapPrStateToGerritFilter(searchConfig.state); + const filters = ['owner:self', 'project:' + repository, filterState]; + if (searchConfig.branchName !== '') { + filters.push(`hashtag:sourceBranch-${searchConfig.branchName}`); + } + if (searchConfig.targetBranch) { + filters.push(`branch:${searchConfig.targetBranch}`); + } + if (searchConfig.label) { + filters.push(`label:Code-Review=${searchConfig.label}`); + } + return filters; + } +} + +export const client = new GerritClient(); diff --git a/lib/modules/platform/gerrit/index.md b/lib/modules/platform/gerrit/index.md new file mode 100644 index 00000000000000..bc82cee7a834ae --- /dev/null +++ b/lib/modules/platform/gerrit/index.md @@ -0,0 +1,70 @@ +# Gerrit + +## Supported Gerrit versions + +Renovate supports all Gerrit 3.x versions. +Support for Gerrit is currently _experimental_, meaning that it _might_ still have some undiscovered bugs or design limitations, and that we _might_ need to change functionality in a non-backwards compatible manner in a non-major release. + +The current implementation uses Gerrit's "hashtags" feature. +Therefore you must use a Gerrit version that uses the [NoteDB](https://gerrit-review.googlesource.com/Documentation/note-db.html) backend. +We did not test Gerrit `2.x` with NoteDB (only in `2.15` and `2.16`), but could work. + +## Authentication + +
+ ![Gerrit HTTP access token](/assets/images/gerrit-http-password.png){ loading=lazy } +
First, create a HTTP access token for the Renovate account.
+
+ +Let Renovate use your HTTP access token by doing _one_ of the following: + +- Set your HTTP access token as a `password` in your `config.js` file, or +- Set your HTTP access token as an environment variable `RENOVATE_PASSWORD`, or +- Set your HTTP access token when you run Renovate in the CLI with `--password=` + +The Gerrit user account must be allowed to assign the Code-Review label with "+2" to their own changes for "automerge" to work. + +You must set `platform=gerrit` in your Renovate config file. + +## Renovate PR/Branch-Model with Gerrit and needed permissions + +If you use the "Code-Review" label and want to get `automerge` working then you must set `autoApprove=true` in your Renovate config. +Renovate will now add the _Code-Review_ label with the value "+2" to each of its "pull requests" (Gerrit-Change). + + +!!! note + The bot's user account must have permission to give +2 for the Code-Review label. + +The Renovate option `automergeType: "branch"` makes no sense for Gerrit, because there are no branches used to create pull requests. +It works similar to the default option `"pr"`. + +## Optional features + +You can use the `statusCheckNames` configuration to map any of the available branch checks (like `minimumReleaseAge`, `mergeConfidence`, and so on) to a Gerrit label. + +For example, if you want to use the [Merge Confidence](https://docs.renovatebot.com/merge-confidence/) feature and map the result of the Merge Confidence check to your Gerrit label "Renovate-Merge-Confidence" you can configure: + +```json +{ + "statusCheckNames": { + "mergeConfidence": "Renovate-Merge-Confidence" + } +} +``` + +## Unsupported platform features/concepts + +- Creating issues (not a Gerrit concept) +- Dependency Dashboard (needs issues first) + +## Known problems + +### PR title is different from first commit message + +Sometimes the PR title passed to the Gerrit platform code is different from the first line of the commit message. +For example: + +Commit-Message=`Update keycloak.version to v21` \ +Pull-Request-Title=`Update keycloak.version to v21 (major)` + +In this case the Gerrit-Platform implementation tries to detect this and change the commit-message in a second patch-set. diff --git a/lib/modules/platform/gerrit/index.spec.ts b/lib/modules/platform/gerrit/index.spec.ts new file mode 100644 index 00000000000000..7b5fa75db6b5ea --- /dev/null +++ b/lib/modules/platform/gerrit/index.spec.ts @@ -0,0 +1,764 @@ +import { git, mocked, partial } from '../../../../test/util'; +import { REPOSITORY_ARCHIVED } from '../../../constants/error-messages'; +import type { BranchStatus } from '../../../types'; +import * as _hostRules from '../../../util/host-rules'; +import { repoFingerprint } from '../util'; +import { client as _client } from './client'; +import type { + GerritAccountInfo, + GerritChange, + GerritChangeMessageInfo, + GerritLabelInfo, + GerritLabelTypeInfo, + GerritProjectInfo, +} from './types'; +import { TAG_PULL_REQUEST_BODY, mapGerritChangeToPr } from './utils'; +import { writeToConfig } from '.'; +import * as gerrit from '.'; + +const gerritEndpointUrl = 'https://dev.gerrit.com/renovate'; + +const codeReviewLabel: GerritLabelTypeInfo = { + values: { + '-2': 'bad', + '-1': 'unlikely', + 0: 'neutral', + 1: 'ok', + 2: 'good', + }, + default_value: 0, +}; + +jest.mock('../../../util/host-rules'); +jest.mock('../../../util/git'); +jest.mock('./client'); +const clientMock = mocked(_client); +const hostRules = mocked(_hostRules); + +describe('modules/platform/gerrit/index', () => { + beforeEach(async () => { + hostRules.find.mockReturnValue({ + username: 'user', + password: 'pass', + }); + writeToConfig({ + repository: 'test/repo', + labels: {}, + }); + await gerrit.initPlatform({ + endpoint: gerritEndpointUrl, + username: 'user', + password: 'pass', + }); + }); + + describe('initPlatform()', () => { + it('should throw if no endpoint', () => { + expect.assertions(1); + expect(() => gerrit.initPlatform({})).toThrow(); + }); + + it('should throw if no username/password', () => { + expect.assertions(1); + expect(() => gerrit.initPlatform({ endpoint: 'endpoint' })).toThrow(); + }); + + it('should init', async () => { + expect( + await gerrit.initPlatform({ + endpoint: gerritEndpointUrl, + username: 'abc', + password: '123', + }), + ).toEqual({ endpoint: 'https://dev.gerrit.com/renovate/' }); + }); + }); + + describe('getRepos()', () => { + it('returns repos', async () => { + clientMock.getRepos.mockResolvedValueOnce(['repo1', 'repo2']); + expect(await gerrit.getRepos()).toEqual(['repo1', 'repo2']); + }); + }); + + it('initRepo() - inactive', async () => { + clientMock.getProjectInfo.mockRejectedValueOnce( + new Error(REPOSITORY_ARCHIVED), + ); + await expect(gerrit.initRepo({ repository: 'test/repo' })).rejects.toThrow( + REPOSITORY_ARCHIVED, + ); + }); + + describe('initRepo()', () => { + const projectInfo: GerritProjectInfo = { + id: 'repo1', + name: 'test-repo2', + }; + + beforeEach(() => { + clientMock.getBranchInfo.mockResolvedValueOnce({ + ref: 'sha-hash....', + revision: 'main', + }); + }); + + it('initRepo() - active', async () => { + clientMock.getProjectInfo.mockResolvedValueOnce(projectInfo); + clientMock.findChanges.mockResolvedValueOnce([]); + expect(await gerrit.initRepo({ repository: 'test/repo' })).toEqual({ + defaultBranch: 'main', + isFork: false, + repoFingerprint: repoFingerprint('test/repo', `${gerritEndpointUrl}/`), + }); + expect(git.initRepo).toHaveBeenCalledWith({ + url: 'https://user:pass@dev.gerrit.com/renovate/a/test%2Frepo', + }); + }); + + it('initRepo() - abandon rejected changes', async () => { + clientMock.getProjectInfo.mockResolvedValueOnce({ + ...projectInfo, + labels: { 'Code-Review': codeReviewLabel }, + }); + clientMock.findChanges.mockResolvedValueOnce([ + partial({ _number: 1 }), + partial({ _number: 2 }), + ]); + + await gerrit.initRepo({ repository: 'test/repo' }); + + expect(clientMock.findChanges.mock.calls[0]).toEqual([ + 'test/repo', + { branchName: '', label: '-2', state: 'open' }, + ]); + expect(clientMock.abandonChange.mock.calls).toEqual([[1], [2]]); + }); + }); + + describe('findPr()', () => { + it('findPr() - no results', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + await expect( + gerrit.findPr({ branchName: 'branch', state: 'open' }), + ).resolves.toBeNull(); + expect(clientMock.findChanges).toHaveBeenCalledWith( + 'test/repo', + { branchName: 'branch', state: 'open' }, + undefined, + ); + }); + + it('findPr() - return the last change from search results', async () => { + clientMock.findChanges.mockResolvedValueOnce([ + partial({ _number: 1 }), + partial({ _number: 2 }), + ]); + await expect( + gerrit.findPr({ branchName: 'branch', state: 'open' }), + ).resolves.toHaveProperty('number', 2); + }); + }); + + describe('getPr()', () => { + it('getPr() - found', async () => { + const change = partial({}); + clientMock.getChange.mockResolvedValueOnce(change); + await expect(gerrit.getPr(123456)).resolves.toEqual( + mapGerritChangeToPr(change), + ); + expect(clientMock.getChange).toHaveBeenCalledWith(123456); + }); + + it('getPr() - not found', async () => { + clientMock.getChange.mockRejectedValueOnce({ statusCode: 404 }); + await expect(gerrit.getPr(123456)).resolves.toBeNull(); + }); + + it('getPr() - other error', async () => { + clientMock.getChange.mockRejectedValueOnce(new Error('other error')); + await expect(gerrit.getPr(123456)).rejects.toThrow(); + }); + }); + + describe('updatePr()', () => { + beforeAll(() => { + gerrit.writeToConfig({ labels: {} }); + }); + + it('updatePr() - new prTitle => copy to commit msg', async () => { + const change = partial({ + change_id: '...', + subject: 'old title', + }); + clientMock.getChange.mockResolvedValueOnce(change); + await gerrit.updatePr({ number: 123456, prTitle: 'new title' }); + expect(clientMock.updateCommitMessage).toHaveBeenCalledWith( + 123456, + '...', + 'new title', + ); + }); + + it('updatePr() - auto approve enabled', async () => { + const change = partial({}); + clientMock.getChange.mockResolvedValueOnce(change); + await gerrit.updatePr({ + number: 123456, + prTitle: 'subject', + platformOptions: { + autoApprove: true, + }, + }); + expect(clientMock.approveChange).toHaveBeenCalledWith(123456); + }); + + it('updatePr() - closed => abandon the change', async () => { + const change = partial({}); + clientMock.getChange.mockResolvedValueOnce(change); + await gerrit.updatePr({ + number: 123456, + prTitle: change.subject, + state: 'closed', + }); + expect(clientMock.abandonChange).toHaveBeenCalledWith(123456); + }); + + it('updatePr() - existing prBody found in change.messages => nothing todo...', async () => { + const change = partial({}); + clientMock.getChange.mockResolvedValueOnce(change); + clientMock.getMessages.mockResolvedValueOnce([ + partial({ + tag: TAG_PULL_REQUEST_BODY, + message: 'Last PR-Body', + }), + ]); + await gerrit.updatePr({ + number: 123456, + prTitle: 'title', + prBody: 'Last PR-Body', + }); + expect(clientMock.addMessage).not.toHaveBeenCalled(); + }); + + it('updatePr() - new prBody found in change.messages => add as message', async () => { + const change = partial({}); + clientMock.getChange.mockResolvedValueOnce(change); + clientMock.getMessages.mockResolvedValueOnce([]); + await gerrit.updatePr({ + number: 123456, + prTitle: change.subject, + prBody: 'NEW PR-Body', + }); + expect(clientMock.addMessageIfNotAlreadyExists).toHaveBeenCalledWith( + 123456, + 'NEW PR-Body', + TAG_PULL_REQUEST_BODY, + ); + }); + }); + + describe('createPr() - error ', () => { + it('createPr() - no existing found => rejects', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + await expect( + gerrit.createPr({ + sourceBranch: 'source', + targetBranch: 'target', + prTitle: 'title', + prBody: 'body', + }), + ).rejects.toThrow( + `the change should be created automatically from previous push to refs/for/source`, + ); + }); + }); + + describe('createPr() - success', () => { + beforeAll(() => { + gerrit.writeToConfig({ labels: {} }); + }); + + const change = partial({ + _number: 123456, + change_id: '...', + }); + + beforeEach(() => { + clientMock.findChanges.mockResolvedValueOnce([change]); + clientMock.getChange.mockResolvedValueOnce(change); + clientMock.getMessages.mockResolvedValueOnce([ + partial({ + tag: TAG_PULL_REQUEST_BODY, + message: 'Last PR-Body', + }), + ]); + }); + + it('createPr() - update body/title WITHOUT approve', async () => { + const pr = await gerrit.createPr({ + sourceBranch: 'source', + targetBranch: 'target', + prTitle: 'title', + prBody: 'body', + platformOptions: { + autoApprove: false, + }, + }); + expect(pr).toHaveProperty('number', 123456); + expect(clientMock.addMessageIfNotAlreadyExists).toHaveBeenCalledWith( + 123456, + 'body', + TAG_PULL_REQUEST_BODY, + ); + expect(clientMock.approveChange).not.toHaveBeenCalled(); + expect(clientMock.updateCommitMessage).toHaveBeenCalledWith( + 123456, + '...', + 'title', + ); + }); + + it('createPr() - update body and approve', async () => { + const pr = await gerrit.createPr({ + sourceBranch: 'source', + targetBranch: 'target', + prTitle: change.subject, + prBody: 'body', + platformOptions: { + autoApprove: true, + }, + }); + expect(pr).toHaveProperty('number', 123456); + expect(clientMock.addMessageIfNotAlreadyExists).toHaveBeenCalledWith( + 123456, + 'body', + TAG_PULL_REQUEST_BODY, + ); + expect(clientMock.approveChange).toHaveBeenCalledWith(123456); + expect(clientMock.setCommitMessage).not.toHaveBeenCalled(); + }); + }); + + describe('getBranchPr()', () => { + it('getBranchPr() - no result', async () => { + clientMock.findChanges.mockResolvedValue([]); + await expect( + gerrit.getBranchPr('renovate/dependency-1.x'), + ).resolves.toBeNull(); + expect(clientMock.findChanges).toHaveBeenCalledWith('test/repo', { + branchName: 'renovate/dependency-1.x', + state: 'open', + }); + }); + + it('getBranchPr() - found', async () => { + const change = partial({ + _number: 123456, + }); + clientMock.findChanges.mockResolvedValue([change]); + await expect( + gerrit.getBranchPr('renovate/dependency-1.x'), + ).resolves.toHaveProperty('number', 123456); + expect(clientMock.findChanges.mock.lastCall).toEqual([ + 'test/repo', + { state: 'open', branchName: 'renovate/dependency-1.x' }, + ]); + }); + }); + + describe('getPrList()', () => { + it('getPrList() - empty list', async () => { + clientMock.findChanges.mockResolvedValue([]); + await expect(gerrit.getPrList()).resolves.toEqual([]); + expect(clientMock.findChanges).toHaveBeenCalledWith('test/repo', { + branchName: '', + }); + }); + + it('getPrList() - multiple results', async () => { + const change = partial({}); + clientMock.findChanges.mockResolvedValue([change, change, change]); + await expect(gerrit.getPrList()).resolves.toHaveLength(3); + }); + }); + + describe('mergePr()', () => { + it('mergePr() - blocker by Verified', async () => { + clientMock.submitChange.mockRejectedValueOnce({ + statusCode: 409, + message: 'blocked by Verified', + }); + await expect(gerrit.mergePr({ id: 123456 })).resolves.toBeFalse(); + expect(clientMock.submitChange).toHaveBeenCalledWith(123456); + }); + + it('mergePr() - success', async () => { + clientMock.submitChange.mockResolvedValueOnce( + partial({ status: 'MERGED' }), + ); + await expect(gerrit.mergePr({ id: 123456 })).resolves.toBeTrue(); + }); + + it('mergePr() - other errors', async () => { + clientMock.submitChange.mockRejectedValueOnce( + new Error('any other error'), + ); + await expect(gerrit.mergePr({ id: 123456 })).rejects.toThrow(); + }); + }); + + describe('getBranchStatus()', () => { + it('getBranchStatus() - branchname/change not found => yellow', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + await expect( + gerrit.getBranchStatus('renovate/dependency-1.x'), + ).resolves.toBe('yellow'); + }); + + it('getBranchStatus() - branchname/changes found, submittable and not hasProblems => green', async () => { + const change = partial({ + submittable: true, + }); + clientMock.findChanges.mockResolvedValueOnce([change]); + await expect( + gerrit.getBranchStatus('renovate/dependency-1.x'), + ).resolves.toBe('green'); + }); + + it('getBranchStatus() - branchname/changes found and hasProblems => red', async () => { + const submittableChange = partial({ + submittable: true, + problems: [], + }); + const changeWithProblems = { ...submittableChange }; + changeWithProblems.submittable = false; + changeWithProblems.problems = [ + { message: 'error1' }, + { message: 'error2' }, + ]; + clientMock.findChanges.mockResolvedValueOnce([ + changeWithProblems, + submittableChange, + ]); + await expect( + gerrit.getBranchStatus('renovate/dependency-1.x'), + ).resolves.toBe('red'); + }); + }); + + describe('getBranchStatusCheck()', () => { + describe('GerritLabel is not available', () => { + beforeAll(() => { + writeToConfig({ labels: {} }); + }); + + it.each([ + 'unknownCtx', + 'renovate/stability-days', + 'renovate/merge-confidence', + ])('getBranchStatusCheck() - %s ', async (ctx) => { + await expect( + gerrit.getBranchStatusCheck('renovate/dependency-1.x', ctx), + ).resolves.toBe('yellow'); + expect(clientMock.findChanges).not.toHaveBeenCalled(); + }); + }); + + describe('GerritLabel is available', () => { + beforeEach(() => { + writeToConfig({ + labels: { + 'Renovate-Merge-Confidence': { + values: { '0': 'default', '-1': 'Unsatisfied', '1': 'Satisfied' }, + default_value: 0, + }, + }, + }); + }); + + it.each([ + { + label: 'Renovate-Merge-Confidence', + labelValue: { rejected: partial({}) }, + expectedState: 'red' as BranchStatus, + }, + { + label: 'Renovate-Merge-Confidence', + labelValue: { approved: partial({}) }, + expectedState: 'green' as BranchStatus, + }, + ])('$ctx/$labels', async ({ label, labelValue, expectedState }) => { + const change = partial({ + labels: { + [label]: partial({ ...labelValue }), + }, + }); + clientMock.findChanges.mockResolvedValueOnce([change]); + await expect( + gerrit.getBranchStatusCheck('renovate/dependency-1.x', label), + ).resolves.toBe(expectedState); + }); + }); + }); + + describe('setBranchStatus()', () => { + describe('GerritLabel is not available', () => { + beforeEach(() => { + writeToConfig({ labels: {} }); + }); + + it('setBranchStatus(renovate/stability-days)', async () => { + await expect( + gerrit.setBranchStatus({ + branchName: 'branch', + context: 'renovate/stability-days', + state: 'red', + description: 'desc', + }), + ).resolves.toBeUndefined(); + expect(clientMock.setLabel).not.toHaveBeenCalled(); + }); + + it('setBranchStatus(renovate/merge-confidence)', async () => { + await expect( + gerrit.setBranchStatus({ + branchName: 'branch', + context: 'renovate/merge-confidence', + state: 'red', + description: 'desc', + }), + ).resolves.toBeUndefined(); + }); + }); + + describe('GerritLabel is available', () => { + beforeEach(() => { + writeToConfig({ + labels: { + 'Renovate-Merge-Confidence': { + values: { '0': 'default', '-1': 'Unsatisfied', '1': 'Satisfied' }, + default_value: 0, + }, + }, + }); + }); + + it.each([ + { + ctx: 'Renovate-Merge-Confidence', + branchState: 'red' as BranchStatus, + expectedVote: -1, + expectedLabel: 'Renovate-Merge-Confidence', + }, + { + ctx: 'Renovate-Merge-Confidence', + branchState: 'yellow' as BranchStatus, + expectedVote: -1, + expectedLabel: 'Renovate-Merge-Confidence', + }, + { + ctx: 'Renovate-Merge-Confidence', + branchState: 'green' as BranchStatus, + expectedVote: 1, + expectedLabel: 'Renovate-Merge-Confidence', + }, + ])( + '$ctx/$branchState', + async ({ ctx, branchState, expectedVote, expectedLabel }) => { + const change = partial({ _number: 123456 }); + clientMock.findChanges.mockResolvedValueOnce([change]); + await gerrit.setBranchStatus({ + branchName: 'renovate/dependency-1.x', + context: ctx, + state: branchState, + description: 'desc', + }); + expect(clientMock.setLabel).toHaveBeenCalledWith( + 123456, + expectedLabel, + expectedVote, + ); + }, + ); + + it('no change found', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + await expect( + gerrit.setBranchStatus({ + branchName: 'renovate/dependency-1.x', + context: 'Renovate-Merge-Confidence', + state: 'red', + description: 'desc', + }), + ).resolves.toBeUndefined(); + expect(clientMock.setLabel).not.toHaveBeenCalled(); + }); + }); + }); + + describe('addReviewers()', () => { + it('addReviewers() - add reviewers', async () => { + await expect( + gerrit.addReviewers(123456, ['user1', 'user2']), + ).resolves.toBeUndefined(); + expect(clientMock.addReviewer).toHaveBeenCalledTimes(2); + expect(clientMock.addReviewer).toHaveBeenNthCalledWith( + 1, + 123456, + 'user1', + ); + expect(clientMock.addReviewer).toHaveBeenNthCalledWith( + 2, + 123456, + 'user2', + ); + }); + }); + + describe('addAssignees()', () => { + it('addAssignees() - set assignee', async () => { + await expect( + gerrit.addAssignees(123456, ['user1', 'user2']), + ).resolves.toBeUndefined(); + expect(clientMock.addAssignee).toHaveBeenCalledTimes(1); + expect(clientMock.addAssignee).toHaveBeenCalledWith(123456, 'user1'); + }); + }); + + describe('ensureComment()', () => { + it('ensureComment() - without tag', async () => { + await expect( + gerrit.ensureComment({ + number: 123456, + topic: null, + content: 'My-Comment-Msg', + }), + ).resolves.toBeTrue(); + expect(clientMock.addMessageIfNotAlreadyExists).toHaveBeenCalledWith( + 123456, + 'My-Comment-Msg', + undefined, + ); + }); + + it('ensureComment() - with tag', async () => { + await expect( + gerrit.ensureComment({ + number: 123456, + topic: 'myTopic', + content: 'My-Comment-Msg', + }), + ).resolves.toBeTrue(); + expect(clientMock.addMessageIfNotAlreadyExists).toHaveBeenCalledWith( + 123456, + 'My-Comment-Msg', + 'myTopic', + ); + }); + }); + + describe('getRawFile()', () => { + beforeEach(() => { + clientMock.getFile.mockResolvedValueOnce('{}'); + }); + + it('getRawFile() - repo and branch', async () => { + await expect( + gerrit.getRawFile('renovate.json', 'test/repo', 'main'), + ).resolves.toBe('{}'); + expect(clientMock.getFile).toHaveBeenCalledWith( + 'test/repo', + 'main', + 'renovate.json', + ); + }); + + it('getRawFile() - repo/branch from config', async () => { + writeToConfig({ + repository: 'repo', + head: 'master', + labels: {}, + }); + await expect(gerrit.getRawFile('renovate.json')).resolves.toBe('{}'); + expect(clientMock.getFile).toHaveBeenCalledWith( + 'repo', + 'master', + 'renovate.json', + ); + }); + + it('getRawFile() - repo/branch defaults', async () => { + writeToConfig({ + repository: undefined, + head: undefined, + labels: {}, + }); + await expect(gerrit.getRawFile('renovate.json')).resolves.toBe('{}'); + expect(clientMock.getFile).toHaveBeenCalledWith( + 'All-Projects', + 'HEAD', + 'renovate.json', + ); + }); + }); + + describe('getJsonFile()', () => { + //TODO: the wanted semantic is not clear + it('getJsonFile()', async () => { + clientMock.getFile.mockResolvedValueOnce('{}'); + await expect( + gerrit.getJsonFile('renovate.json', 'test/repo', 'main'), + ).resolves.toEqual({}); + }); + }); + + describe('getRepoForceRebase()', () => { + it('getRepoForceRebase()', async () => { + await expect(gerrit.getRepoForceRebase()).resolves.toBeFalse(); + }); + }); + + describe('massageMarkdown()', () => { + it('massageMarkdown()', () => { + expect(gerrit.massageMarkdown('Pull Requests')).toBe('Change-Requests'); + }); + //TODO: add some tests for Gerrit-specific replacements.. + }); + + describe('currently unused/not-implemented functions', () => { + it('deleteLabel()', async () => { + await expect( + gerrit.deleteLabel(123456, 'label'), + ).resolves.toBeUndefined(); + }); + + it('ensureCommentRemoval()', async () => { + await expect( + gerrit.ensureCommentRemoval({ + type: 'by-topic', + number: 123456, + topic: 'topic', + }), + ).resolves.toBeUndefined(); + }); + + it('ensureIssueClosing()', async () => { + await expect(gerrit.ensureIssueClosing('title')).resolves.toBeUndefined(); + }); + + it('ensureIssue()', async () => { + await expect( + gerrit.ensureIssue({ body: 'body', title: 'title' }), + ).resolves.toBeNull(); + }); + + it('findIssue()', async () => { + await expect(gerrit.findIssue('title')).resolves.toBeNull(); + }); + + it('getIssueList()', async () => { + await expect(gerrit.getIssueList()).resolves.toStrictEqual([]); + }); + }); +}); diff --git a/lib/modules/platform/gerrit/index.ts b/lib/modules/platform/gerrit/index.ts new file mode 100644 index 00000000000000..8b7c8abd44c15d --- /dev/null +++ b/lib/modules/platform/gerrit/index.ts @@ -0,0 +1,454 @@ +import { logger } from '../../../logger'; +import type { BranchStatus } from '../../../types'; +import { parseJson } from '../../../util/common'; +import * as git from '../../../util/git'; +import { setBaseUrl } from '../../../util/http/gerrit'; +import { regEx } from '../../../util/regex'; +import { ensureTrailingSlash } from '../../../util/url'; +import type { + BranchStatusConfig, + CreatePRConfig, + EnsureCommentConfig, + EnsureCommentRemovalConfigByContent, + EnsureCommentRemovalConfigByTopic, + EnsureIssueConfig, + EnsureIssueResult, + FindPRConfig, + Issue, + MergePRConfig, + PlatformParams, + PlatformResult, + Pr, + RepoParams, + RepoResult, + UpdatePrConfig, +} from '../types'; +import { repoFingerprint } from '../util'; + +import { smartTruncate } from '../utils/pr-body'; +import { readOnlyIssueBody } from '../utils/read-only-issue-body'; +import { client } from './client'; +import { configureScm } from './scm'; +import type { GerritLabelTypeInfo, GerritProjectInfo } from './types'; +import { + TAG_PULL_REQUEST_BODY, + getGerritRepoUrl, + mapBranchStatusToLabel, + mapGerritChangeToPr, +} from './utils'; + +export const id = 'gerrit'; + +const defaults: { + endpoint?: string; +} = {}; + +let config: { + repository?: string; + head?: string; + config?: GerritProjectInfo; + labels: Record; + gerritUsername?: string; +} = { + labels: {}, +}; + +export function writeToConfig(newConfig: typeof config): void { + config = { ...config, ...newConfig }; +} + +export 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); + const platformConfig: PlatformResult = { + endpoint: defaults.endpoint, + }; + return Promise.resolve(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, + 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: projectInfo.labels ?? {}, + }; + const baseUrl = defaults.endpoint!; + const url = getGerritRepoUrl(repository, baseUrl); + configureScm(repository, config.gerritUsername!); + await git.initRepo({ url }); + + //abandon "open" and "rejected" changes at startup + const rejectedChanges = await client.findChanges(config.repository!, { + branchName: '', + state: 'open', + label: '-2', + }); + for (const change of rejectedChanges) { + await client.abandonChange(change._number); + } + const repoConfig: RepoResult = { + defaultBranch: config.head!, + isFork: false, + repoFingerprint: repoFingerprint(repository, baseUrl), + }; + return repoConfig; +} + +export async function findPr( + findPRConfig: FindPRConfig, + refreshCache?: boolean, +): Promise { + const change = ( + await client.findChanges(config.repository!, findPRConfig, refreshCache) + ).pop(); + return change ? mapGerritChangeToPr(change) : null; +} + +export async function getPr(number: number): Promise { + try { + const change = await client.getChange(number); + 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})`); + const change = await client.getChange(prConfig.number); + if (change.subject !== prConfig.prTitle) { + await client.updateCommitMessage( + prConfig.number, + change.change_id, + prConfig.prTitle, + ); + } + if (prConfig.prBody) { + await client.addMessageIfNotAlreadyExists( + prConfig.number, + prConfig.prBody, + TAG_PULL_REQUEST_BODY, + ); + } + if (prConfig.platformOptions?.autoApprove) { + await client.approveChange(prConfig.number); + } + 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() ?? '' + })`, + ); + const pr = ( + await client.findChanges( + config.repository!, + { + branchName: prConfig.sourceBranch, + targetBranch: prConfig.targetBranch, + state: 'open', + }, + true, + ) + ).pop(); + if (pr === undefined) { + throw new Error( + `the change should be created automatically from previous push to refs/for/${prConfig.sourceBranch}`, + ); + } + //Workaround for "Known Problems.1" + if (pr.subject !== prConfig.prTitle) { + await client.updateCommitMessage( + pr._number, + pr.change_id, + prConfig.prTitle, + ); + } + await client.addMessageIfNotAlreadyExists( + pr._number, + prConfig.prBody, + TAG_PULL_REQUEST_BODY, + ); + if (prConfig.platformOptions?.autoApprove) { + await client.approveChange(pr._number); + } + return getPr(pr._number); +} + +export async function getBranchPr(branchName: string): Promise { + const change = ( + await client.findChanges(config.repository!, { branchName, state: 'open' }) + ).pop(); + return change ? mapGerritChangeToPr(change) : null; +} + +export function getPrList(): Promise { + return client + .findChanges(config.repository!, { branchName: '' }) + .then((res) => res.map((change) => mapGerritChangeToPr(change))); +} + +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 changes = await client.findChanges( + config.repository!, + { state: 'open', branchName }, + true, + ); + if (changes.length > 0) { + const allSubmittable = + changes.filter((change) => change.submittable === true).length === + changes.length; + if (allSubmittable) { + return 'green'; + } + const hasProblems = + changes.filter((change) => change.problems.length > 0).length > 0; + if (hasProblems) { + return 'red'; + } + } + 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 label = config.labels[context]; + if (label) { + const change = ( + await client.findChanges( + config.repository!, + { branchName, state: 'open' }, + true, + ) + ).pop(); + if (change) { + const labelRes = change.labels?.[context]; + if (labelRes) { + if (labelRes.approved) { + return 'green'; + } + if (labelRes.rejected) { + return 'red'; + } + } + } + } + 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 pr = await getBranchPr(branchStatusConfig.branchName); + if (pr === null) { + return; + } + await client.setLabel(pr.number, branchStatusConfig.context, labelValue); + } +} + +export function getRawFile( + fileName: string, + repoName?: string, + branchOrTag?: string, +): Promise { + const repo = repoName ?? config.repository ?? 'All-Projects'; + const branch = + branchOrTag ?? (repo === config.repository ? config.head! : 'HEAD'); + return client.getFile(repo, branch, fileName); +} + +export async function getJsonFile( + fileName: string, + repoName?: string, + branchOrTag?: string, +): Promise { + const raw = await getRawFile(fileName, repoName, branchOrTag); + return parseJson(raw, fileName); +} + +export function getRepoForceRebase(): Promise { + return Promise.resolve(false); +} + +export async function addReviewers( + number: number, + reviewers: string[], +): Promise { + for (const reviewer of reviewers) { + await client.addReviewer(number, reviewer); + } +} + +/** + * add "CC" (only one possible) + */ +export async function addAssignees( + number: number, + assignees: string[], +): Promise { + if (assignees.length) { + 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): string { + //TODO: do more Gerrit specific replacements? + return smartTruncate(readOnlyIssueBody(prBody), 16384) //TODO: check the real gerrit limit (max. chars) + .replace(regEx(/Pull Request(s)?/g), 'Change-Request$1') + .replace(regEx(/\bPR(s)?\b/g), 'Change-Request$1') + .replace(regEx(/<\/?summary>/g), '**') + .replace(regEx(/<\/?details>/g), '') + .replace(regEx(/​/g), '') //remove zero-width-space not supported in gerrit-markdown + .replace( + 'close this Change-Request unmerged.', + 'abandon or down vote this Change-Request with -2.', + ) + .replace('Branch creation', 'Change creation') + .replace( + 'Close this Change-Request', + 'Down-vote this Change-Request with -2', + ) + .replace( + 'you tick the rebase/retry checkbox', + 'add "rebase!" at the beginning of the commit message.', + ) + .replace(regEx(`\n---\n\n.*?.*?\n`), '') + .replace(regEx(//g), ''); +} + +export function deleteLabel(number: number, label: string): Promise { + return Promise.resolve(); +} + +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([]); +} diff --git a/lib/modules/platform/gerrit/scm.spec.ts b/lib/modules/platform/gerrit/scm.spec.ts new file mode 100644 index 00000000000000..18667a04c4d4c5 --- /dev/null +++ b/lib/modules/platform/gerrit/scm.spec.ts @@ -0,0 +1,396 @@ +import { git, mocked, partial } from '../../../../test/util'; +import type { LongCommitSha } from '../../../util/git/types'; +import { client as _client } from './client'; +import { GerritScm, configureScm } from './scm'; +import type { + GerritAccountInfo, + GerritChange, + GerritRevisionInfo, +} from './types'; + +jest.mock('../../../util/git'); +jest.mock('./client'); +const clientMock = mocked(_client); + +describe('modules/platform/gerrit/scm', () => { + const gerritScm = new GerritScm(); + + beforeEach(() => { + configureScm('test/repo', 'user'); + }); + + describe('isBranchBehindBase()', () => { + it('no open change for with branchname found -> isBehind == true', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + await expect( + gerritScm.isBranchBehindBase('myBranchName', 'baseBranch'), + ).resolves.toBeTrue(); + expect(clientMock.findChanges).toHaveBeenCalledWith( + 'test/repo', + { + branchName: 'myBranchName', + state: 'open', + targetBranch: 'baseBranch', + }, + true, + ); + }); + + it('open change found for branchname, rebase action is available -> isBehind == true ', async () => { + const change = partial({ + current_revision: 'currentRevSha', + revisions: { + currentRevSha: partial({ + actions: { + rebase: { + enabled: true, + }, + }, + }), + }, + }); + clientMock.findChanges.mockResolvedValueOnce([change]); + await expect( + gerritScm.isBranchBehindBase('myBranchName', 'baseBranch'), + ).resolves.toBeTrue(); + }); + + it('open change found for branch name, but rebase action is not available -> isBehind == false ', async () => { + const change = partial({ + current_revision: 'currentRevSha', + revisions: { + currentRevSha: partial({ + actions: { + rebase: {}, + }, + }), + }, + }); + clientMock.findChanges.mockResolvedValueOnce([change]); + await expect( + gerritScm.isBranchBehindBase('myBranchName', 'baseBranch'), + ).resolves.toBeFalse(); + }); + }); + + describe('isBranchModified()', () => { + it('no open change for with branchname found -> not modified', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + await expect( + gerritScm.isBranchModified('myBranchName'), + ).resolves.toBeFalse(); + expect(clientMock.findChanges).toHaveBeenCalledWith( + 'test/repo', + { branchName: 'myBranchName', state: 'open' }, + true, + ); + }); + + it('open change found for branchname, but not modified', async () => { + const change = partial({ + current_revision: 'currentRevSha', + revisions: { + currentRevSha: partial({ + uploader: partial({ username: 'user' }), + }), + }, + }); + clientMock.findChanges.mockResolvedValueOnce([change]); + await expect( + gerritScm.isBranchModified('myBranchName'), + ).resolves.toBeFalse(); + }); + + it('open change found for branchname, but modified from other user', async () => { + const change = partial({ + current_revision: 'currentRevSha', + revisions: { + currentRevSha: partial({ + uploader: partial({ username: 'other_user' }), //!== gerritLogin + }), + }, + }); + clientMock.findChanges.mockResolvedValueOnce([change]); + await expect( + gerritScm.isBranchModified('myBranchName'), + ).resolves.toBeTrue(); + }); + }); + + describe('isBranchConflicted()', () => { + it('no open change with branch name found -> return true', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + await expect( + gerritScm.isBranchConflicted('target', 'myBranchName'), + ).resolves.toBe(true); + expect(clientMock.findChanges).toHaveBeenCalledWith('test/repo', { + branchName: 'myBranchName', + state: 'open', + targetBranch: 'target', + }); + }); + + it('open change found for branch name/baseBranch and its mergeable', async () => { + const change = partial({}); + clientMock.findChanges.mockResolvedValueOnce([change]); + clientMock.getMergeableInfo.mockResolvedValueOnce({ + submit_type: 'MERGE_IF_NECESSARY', + mergeable: true, + }); + await expect( + gerritScm.isBranchConflicted('target', 'myBranchName'), + ).resolves.toBeFalse(); + expect(clientMock.getMergeableInfo).toHaveBeenCalledWith(change); + }); + + it('open change found for branch name/baseBranch and its NOT mergeable', async () => { + const change = partial({}); + clientMock.findChanges.mockResolvedValueOnce([change]); + clientMock.getMergeableInfo.mockResolvedValueOnce({ + submit_type: 'MERGE_IF_NECESSARY', + mergeable: false, + }); + await expect( + gerritScm.isBranchConflicted('target', 'myBranchName'), + ).resolves.toBeTrue(); + expect(clientMock.getMergeableInfo).toHaveBeenCalledWith(change); + }); + }); + + describe('branchExists()', () => { + it('no change found for branch name -> return result from git.branchExists', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + git.branchExists.mockReturnValueOnce(true); + await expect(gerritScm.branchExists('myBranchName')).resolves.toBeTrue(); + expect(clientMock.findChanges).toHaveBeenCalledWith( + 'test/repo', + { + branchName: 'myBranchName', + state: 'open', + }, + true, + ); + expect(git.branchExists).toHaveBeenCalledWith('myBranchName'); + }); + + it('open change found for branch name -> return true', async () => { + const change = partial({}); + clientMock.findChanges.mockResolvedValueOnce([change]); + await expect(gerritScm.branchExists('myBranchName')).resolves.toBeTrue(); + expect(git.branchExists).not.toHaveBeenCalledWith('myBranchName'); + }); + }); + + describe('getBranchCommit()', () => { + it('no change found for branch name -> return result from git.getBranchCommit', async () => { + git.getBranchCommit.mockReturnValueOnce('shaHashValue' as LongCommitSha); + clientMock.findChanges.mockResolvedValueOnce([]); + await expect(gerritScm.getBranchCommit('myBranchName')).resolves.toBe( + 'shaHashValue', + ); + expect(clientMock.findChanges).toHaveBeenCalledWith( + 'test/repo', + { + branchName: 'myBranchName', + state: 'open', + }, + true, + ); + }); + + it('open change found for branchname -> return true', async () => { + const change = partial({ current_revision: 'curSha' }); + clientMock.findChanges.mockResolvedValueOnce([change]); + await expect(gerritScm.getBranchCommit('myBranchName')).resolves.toBe( + 'curSha', + ); + }); + }); + + it('deleteBranch()', async () => { + await expect(gerritScm.deleteBranch('branchName')).toResolve(); + }); + + describe('mergeToLocal', () => { + it('no change exists', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + git.mergeToLocal.mockResolvedValueOnce(); + + await expect(gerritScm.mergeToLocal('nonExistingChange')).toResolve(); + + expect(clientMock.findChanges).toHaveBeenCalledWith( + 'test/repo', + { + branchName: 'nonExistingChange', + state: 'open', + }, + true, + ); + expect(git.mergeToLocal).toHaveBeenCalledWith('nonExistingChange'); + }); + + it('change exists', async () => { + const change = partial({ + current_revision: 'curSha', + revisions: { + curSha: partial({ + ref: 'refs/changes/34/1234/1', + }), + }, + }); + clientMock.findChanges.mockResolvedValueOnce([change]); + git.mergeToLocal.mockResolvedValueOnce(); + + await expect(gerritScm.mergeToLocal('existingChange')).toResolve(); + + expect(clientMock.findChanges).toHaveBeenCalledWith( + 'test/repo', + { + branchName: 'existingChange', + state: 'open', + }, + true, + ); + expect(git.mergeToLocal).toHaveBeenCalledWith('refs/changes/34/1234/1'); + }); + }); + + describe('commitFiles()', () => { + it('commitFiles() - empty commit', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + git.prepareCommit.mockResolvedValueOnce(null); //empty commit + + await expect( + gerritScm.commitAndPush({ + branchName: 'renovate/dependency-1.x', + baseBranch: 'main', + message: 'commit msg', + files: [], + }), + ).resolves.toBeNull(); + expect(clientMock.findChanges).toHaveBeenCalledWith( + 'test/repo', + { + branchName: 'renovate/dependency-1.x', + state: 'open', + targetBranch: 'main', + }, + true, + ); + }); + + it('commitFiles() - create first Patch', async () => { + clientMock.findChanges.mockResolvedValueOnce([]); + git.prepareCommit.mockResolvedValueOnce({ + commitSha: 'commitSha' as LongCommitSha, + parentCommitSha: 'parentSha' as LongCommitSha, + files: [], + }); + git.pushCommit.mockResolvedValueOnce(true); + + expect( + await gerritScm.commitAndPush({ + branchName: 'renovate/dependency-1.x', + baseBranch: 'main', + message: 'commit msg', + files: [], + }), + ).toBe('commitSha'); + expect(git.prepareCommit).toHaveBeenCalledWith({ + baseBranch: 'main', + branchName: 'renovate/dependency-1.x', + files: [], + message: ['commit msg', expect.stringMatching(/Change-Id: I.{32}/)], + force: true, + }); + expect(git.pushCommit).toHaveBeenCalledWith({ + files: [], + sourceRef: 'renovate/dependency-1.x', + targetRef: 'refs/for/main%t=sourceBranch-renovate/dependency-1.x', + }); + }); + + it('commitFiles() - existing change-set without new changes', async () => { + const existingChange = partial({ + change_id: '...', + current_revision: 'commitSha', + revisions: { + commitSha: partial({ ref: 'refs/changes/1/2' }), + }, + }); + clientMock.findChanges.mockResolvedValueOnce([existingChange]); + git.prepareCommit.mockResolvedValueOnce({ + commitSha: 'commitSha' as LongCommitSha, + parentCommitSha: 'parentSha' as LongCommitSha, + files: [], + }); + git.pushCommit.mockResolvedValueOnce(true); + git.hasDiff.mockResolvedValueOnce(false); //no changes + + expect( + await gerritScm.commitAndPush({ + branchName: 'renovate/dependency-1.x', + baseBranch: 'main', + message: ['commit msg'], + files: [], + }), + ).toBeNull(); + expect(git.prepareCommit).toHaveBeenCalledWith({ + baseBranch: 'main', + branchName: 'renovate/dependency-1.x', + files: [], + message: ['commit msg', 'Change-Id: ...'], + force: true, + }); + expect(git.fetchRevSpec).toHaveBeenCalledWith('refs/changes/1/2'); + expect(git.pushCommit).toHaveBeenCalledTimes(0); + }); + + it('commitFiles() - existing change-set with new changes - auto-approve again', async () => { + const existingChange = partial({ + _number: 123456, + change_id: '...', + current_revision: 'commitSha', + revisions: { + commitSha: partial({ ref: 'refs/changes/1/2' }), + }, + }); + clientMock.findChanges.mockResolvedValueOnce([existingChange]); + clientMock.wasApprovedBy.mockReturnValueOnce(true); + git.prepareCommit.mockResolvedValueOnce({ + commitSha: 'commitSha' as LongCommitSha, + parentCommitSha: 'parentSha' as LongCommitSha, + files: [], + }); + git.pushCommit.mockResolvedValueOnce(true); + git.hasDiff.mockResolvedValueOnce(true); + + expect( + await gerritScm.commitAndPush({ + branchName: 'renovate/dependency-1.x', + baseBranch: 'main', + message: 'commit msg', + files: [], + }), + ).toBe('commitSha'); + expect(git.prepareCommit).toHaveBeenCalledWith({ + baseBranch: 'main', + branchName: 'renovate/dependency-1.x', + files: [], + message: ['commit msg', 'Change-Id: ...'], + force: true, + }); + expect(git.fetchRevSpec).toHaveBeenCalledWith('refs/changes/1/2'); + expect(git.pushCommit).toHaveBeenCalledWith({ + files: [], + sourceRef: 'renovate/dependency-1.x', + targetRef: 'refs/for/main%t=sourceBranch-renovate/dependency-1.x', + }); + expect(clientMock.wasApprovedBy).toHaveBeenCalledWith( + existingChange, + 'user', + ); + expect(clientMock.approveChange).toHaveBeenCalledWith(123456); + }); + }); +}); diff --git a/lib/modules/platform/gerrit/scm.ts b/lib/modules/platform/gerrit/scm.ts new file mode 100644 index 00000000000000..40fc56c88aab2e --- /dev/null +++ b/lib/modules/platform/gerrit/scm.ts @@ -0,0 +1,171 @@ +import { randomUUID } from 'crypto'; +import { logger } from '../../../logger'; +import * as git from '../../../util/git'; +import type { CommitFilesConfig, LongCommitSha } from '../../../util/git/types'; +import { hash } from '../../../util/hash'; +import { DefaultGitScm } from '../default-scm'; +import { client } from './client'; +import type { GerritFindPRConfig } from './types'; + +let repository: string; +let username: string; +export function configureScm(repo: string, login: string): void { + repository = repo; + username = login; +} + +export class GerritScm extends DefaultGitScm { + override async branchExists(branchName: string): Promise { + const searchConfig: GerritFindPRConfig = { state: 'open', branchName }; + const change = await client + .findChanges(repository, searchConfig, true) + .then((res) => res.pop()); + if (change) { + return true; + } + return git.branchExists(branchName); + } + + override async getBranchCommit( + branchName: string, + ): Promise { + const searchConfig: GerritFindPRConfig = { state: 'open', branchName }; + const change = await client + .findChanges(repository, searchConfig, true) + .then((res) => res.pop()); + if (change) { + return change.current_revision! as LongCommitSha; + } + return git.getBranchCommit(branchName); + } + + override async isBranchBehindBase( + branchName: string, + baseBranch: string, + ): Promise { + const searchConfig: GerritFindPRConfig = { + state: 'open', + branchName, + targetBranch: baseBranch, + }; + const change = await client + .findChanges(repository, searchConfig, true) + .then((res) => res.pop()); + if (change) { + const currentGerritPatchset = change.revisions![change.current_revision!]; + return currentGerritPatchset.actions?.['rebase'].enabled === true; + } + return true; + } + + override async isBranchConflicted( + baseBranch: string, + branch: string, + ): Promise { + const searchConfig: GerritFindPRConfig = { + state: 'open', + branchName: branch, + targetBranch: baseBranch, + }; + const change = (await client.findChanges(repository, searchConfig)).pop(); + if (change) { + const mergeInfo = await client.getMergeableInfo(change); + return !mergeInfo.mergeable; + } else { + logger.warn( + `There is no open change with branch=${branch} and baseBranch=${baseBranch}`, + ); + return true; + } + } + + override async isBranchModified(branchName: string): Promise { + const searchConfig: GerritFindPRConfig = { state: 'open', branchName }; + const change = await client + .findChanges(repository, searchConfig, true) + .then((res) => res.pop()); + if (change) { + const currentGerritPatchset = change.revisions![change.current_revision!]; + return currentGerritPatchset.uploader.username !== username; + } + return false; + } + + override async commitAndPush( + commit: CommitFilesConfig, + ): Promise { + logger.debug(`commitAndPush(${commit.branchName})`); + const searchConfig: GerritFindPRConfig = { + state: 'open', + branchName: commit.branchName, + targetBranch: commit.baseBranch, + }; + const existingChange = await client + .findChanges(repository, searchConfig, true) + .then((res) => res.pop()); + + let hasChanges = true; + const origMsg = + typeof commit.message === 'string' ? [commit.message] : commit.message; + commit.message = [ + ...origMsg, + `Change-Id: ${existingChange?.change_id ?? generateChangeId()}`, + ]; + const commitResult = await git.prepareCommit({ ...commit, force: true }); + if (commitResult) { + const { commitSha } = commitResult; + if (existingChange?.revisions && existingChange.current_revision) { + const fetchRefSpec = + existingChange.revisions[existingChange.current_revision].ref; + await git.fetchRevSpec(fetchRefSpec); //fetch current ChangeSet for git diff + hasChanges = await git.hasDiff('HEAD', 'FETCH_HEAD'); //avoid empty patchsets + } + if (hasChanges || commit.force) { + const pushResult = await git.pushCommit({ + sourceRef: commit.branchName, + targetRef: `refs/for/${commit.baseBranch!}%t=sourceBranch-${ + commit.branchName + }`, + files: commit.files, + }); + 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 null; //empty commit, no changes in this Gerrit-Change + } + + override deleteBranch(branchName: string): Promise { + return Promise.resolve(); + } + + override async mergeToLocal(branchName: string): Promise { + const searchConfig: GerritFindPRConfig = { state: 'open', branchName }; + const change = await client + .findChanges(repository, searchConfig, true) + .then((res) => res.pop()); + if (change) { + return super.mergeToLocal( + change.revisions![change.current_revision!].ref, + ); + } + return super.mergeToLocal(branchName); + } +} + +/** + * This function should generate a Gerrit Change-ID analogous to the commit hook. We avoid the commit hook cause of security concerns. + * random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin) prefixed with an 'I'. + * TODO: Gerrit don't accept longer Change-IDs (sha256), but what happens with this https://git-scm.com/docs/hash-function-transition/ ? + */ +function generateChangeId(): string { + return 'I' + hash(randomUUID(), 'sha1'); +} diff --git a/lib/modules/platform/gerrit/types.ts b/lib/modules/platform/gerrit/types.ts new file mode 100644 index 00000000000000..7ec71999f4f1e2 --- /dev/null +++ b/lib/modules/platform/gerrit/types.ts @@ -0,0 +1,93 @@ +import type { FindPRConfig } from '../types'; + +export interface GerritFindPRConfig extends FindPRConfig { + label?: string; +} + +/** + * The Interfaces for the Gerrit API Responses ({@link https://gerrit-review.googlesource.com/Documentation/rest-api.html | REST-API}) + * minimized to only needed properties. + * + * @packageDocumentation + */ + +export interface GerritProjectInfo { + id: string; + name: string; + state?: 'ACTIVE' | 'READ_ONLY' | 'HIDDEN'; + labels?: Record; +} + +export interface GerritLabelTypeInfo { + values: Record; + default_value: number; +} + +export interface GerritBranchInfo { + ref: string; + revision: string; +} + +export type GerritChangeStatus = 'NEW' | 'MERGED' | 'ABANDONED'; + +export type GerritReviewersType = 'REVIEWER' | 'CC' | 'REMOVED'; + +export interface GerritChange { + branch: string; + hashtags?: string[]; + change_id: string; + subject: string; + status: GerritChangeStatus; + submittable?: boolean; + _number: number; + labels?: Record; + reviewers?: Record; + messages?: GerritChangeMessageInfo[]; + current_revision?: string; + /** + * All patch sets of this change as a map that maps the commit ID of the patch set to a RevisionInfo entity. + */ + revisions?: Record; + problems: unknown[]; +} + +export interface GerritRevisionInfo { + uploader: GerritAccountInfo; + /** + * The Git reference for the patch set. + */ + ref: string; + actions?: Record; +} + +export interface GerritChangeMessageInfo { + id: string; + message: string; + tag?: string; +} + +export interface GerritLabelInfo { + approved?: GerritAccountInfo; + rejected?: GerritAccountInfo; +} + +export interface GerritActionInfo { + method?: string; + enabled?: boolean; +} + +export interface GerritAccountInfo { + _account_id: number; + username?: string; +} + +export interface GerritMergeableInfo { + submit_type: + | 'MERGE_IF_NECESSARY' + | 'FAST_FORWARD_ONLY' + | 'REBASE_IF_NECESSARY' + | 'REBASE_ALWAYS' + | 'MERGE_ALWAYS' + | 'CHERRY_PICK'; + mergeable: boolean; +} diff --git a/lib/modules/platform/gerrit/utils.spec.ts b/lib/modules/platform/gerrit/utils.spec.ts new file mode 100644 index 00000000000000..f5159804473bea --- /dev/null +++ b/lib/modules/platform/gerrit/utils.spec.ts @@ -0,0 +1,251 @@ +import { mocked, partial } from '../../../../test/util'; +import { CONFIG_GIT_URL_UNAVAILABLE } from '../../../constants/error-messages'; +import type { BranchStatus } from '../../../types'; +import * as _hostRules from '../../../util/host-rules'; +import { setBaseUrl } from '../../../util/http/gerrit'; +import { hashBody } from '../pr-body'; +import type { + GerritAccountInfo, + GerritChange, + GerritChangeMessageInfo, + GerritChangeStatus, + GerritLabelTypeInfo, +} from './types'; +import * as utils from './utils'; +import { mapBranchStatusToLabel } from './utils'; + +jest.mock('../../../util/host-rules'); + +const baseUrl = 'https://gerrit.example.com'; +const hostRules = mocked(_hostRules); + +describe('modules/platform/gerrit/utils', () => { + beforeEach(() => { + setBaseUrl(baseUrl); + }); + + describe('getGerritRepoUrl()', () => { + it('create a git url with username/password', () => { + hostRules.find.mockReturnValue({ + username: 'abc', + password: '123', + }); + const repoUrl = utils.getGerritRepoUrl('web/apps', baseUrl); + expect(repoUrl).toBe('https://abc:123@gerrit.example.com/a/web%2Fapps'); + }); + + it('create a git url without username/password', () => { + hostRules.find.mockReturnValue({}); + expect(() => utils.getGerritRepoUrl('web/apps', baseUrl)).toThrow( + 'Init: You must configure a Gerrit Server username/password', + ); + }); + + it('throws on invalid endpoint', () => { + expect(() => utils.getGerritRepoUrl('web/apps', '...')).toThrow( + Error(CONFIG_GIT_URL_UNAVAILABLE), + ); + }); + }); + + describe('mapPrStateToGerritFilter()', () => { + it.each([ + ['closed', 'status:closed'], + ['merged', 'status:merged'], + ['!open', '-status:open'], + ['open', 'status:open'], + ['all', '-is:wip'], + [undefined, '-is:wip'], + ])( + 'maps pr state %p to gerrit filter %p', + (prState: any, filter: string) => { + expect(utils.mapPrStateToGerritFilter(prState)).toEqual(filter); + }, + ); + }); + + describe('mapGerritChangeStateToPrState()', () => { + it.each([ + ['NEW' as GerritChangeStatus, 'open'], + ['MERGED' as GerritChangeStatus, 'merged'], + ['ABANDONED' as GerritChangeStatus, 'closed'], + ['unknown' as GerritChangeStatus, 'all'], + ])( + 'maps gerrit change state %p to PrState %p', + (state: GerritChangeStatus, prState: any) => { + expect(utils.mapGerritChangeStateToPrState(state)).toEqual(prState); + }, + ); + }); + + describe('mapGerritChangeToPr()', () => { + it('map a gerrit change to to Pr', () => { + const change = partial({ + _number: 123456, + status: 'NEW', + hashtags: ['other', 'sourceBranch-renovate/dependency-1.x'], + branch: 'main', + subject: 'Fix for', + reviewers: { + REVIEWER: [partial({ username: 'username' })], + REMOVED: [], + CC: [], + }, + messages: [ + partial({ + id: '9d78ac236714cee8c2d86e95d638358925cf6853', + tag: 'pull-request', + message: 'Patch Set 1:\n\nOld PR-Body', + }), + partial({ + id: '1d17c930381e88e177bbc59595c3ec941bd21028', + tag: 'pull-request', + message: 'Patch Set 12:\n\nLast PR-Body', + }), + partial({ + id: '9d78ac236714cee8c2d86e95d638358925cf6853', + message: 'other message...', + }), + ], + }); + + expect(utils.mapGerritChangeToPr(change)).toEqual({ + number: 123456, + state: 'open', + title: 'Fix for', + sourceBranch: 'renovate/dependency-1.x', + targetBranch: 'main', + reviewers: ['username'], + bodyStruct: { + hash: hashBody('Last PR-Body'), + }, + }); + }); + + it('map a gerrit change without sourceBranch-tag and reviewers to Pr', () => { + const change = partial({ + _number: 123456, + status: 'NEW', + hashtags: ['other'], + branch: 'main', + subject: 'Fix for', + }); + expect(utils.mapGerritChangeToPr(change)).toEqual({ + number: 123456, + state: 'open', + title: 'Fix for', + sourceBranch: 'main', + targetBranch: 'main', + reviewers: [], + bodyStruct: { + hash: hashBody(''), + }, + }); + }); + }); + + describe('extractSourceBranch()', () => { + it('without hashtags', () => { + const change = partial({ + hashtags: undefined, + }); + expect(utils.extractSourceBranch(change)).toBeUndefined(); + }); + + it('no hashtag with "sourceBranch-" prefix', () => { + const change = partial({ + hashtags: ['other', 'another'], + }); + expect(utils.extractSourceBranch(change)).toBeUndefined(); + }); + + it('hashtag with "sourceBranch-" prefix', () => { + const change = partial({ + hashtags: ['other', 'sourceBranch-renovate/dependency-1.x', 'another'], + }); + expect(utils.extractSourceBranch(change)).toBe('renovate/dependency-1.x'); + }); + }); + + describe('findPullRequestBody()', () => { + it('find pull-request-body', () => { + const change = partial({ + messages: [ + partial({ + id: '9d78ac236714cee8c2d86e95d638358925cf6853', + tag: 'pull-request', + message: 'Patch Set 1:\n\nOld PR-Body', + }), + partial({ + id: '1d17c930381e88e177bbc59595c3ec941bd21028', + tag: 'pull-request', + message: 'Patch Set 12:\n\nLast PR-Body', + }), + partial({ + id: '9d78ac236714cee8c2d86e95d638358925cf6853', + message: 'other message...', + }), + ], + }); + expect(utils.findPullRequestBody(change)).toBe('Last PR-Body'); + }); + + it('no pull-request-body message found', () => { + const change = partial({}); + expect(utils.findPullRequestBody(change)).toBeUndefined(); + change.messages = []; + expect(utils.findPullRequestBody(change)).toBeUndefined(); + change.messages = [ + partial({ + tag: 'other-tag', + message: 'message', + }), + ]; + expect(utils.findPullRequestBody(change)).toBeUndefined(); + }); + }); + + describe('mapBranchStatusToLabel()', () => { + const labelWithOne: GerritLabelTypeInfo = { + values: { '-1': 'rejected', '0': 'default', '1': 'accepted' }, + default_value: 0, + }; + + it.each([ + ['red' as BranchStatus, -1], + ['yellow' as BranchStatus, -1], + ['green' as BranchStatus, 1], + ])( + 'Label with +1/-1 map branchState=%p to %p', + (branchState, expectedValue) => { + expect(mapBranchStatusToLabel(branchState, labelWithOne)).toEqual( + expectedValue, + ); + }, + ); + + const labelWithTwo: GerritLabelTypeInfo = { + values: { + '-2': 'rejected', + '-1': 'disliked', + '0': 'default', + '1': 'looksOkay', + '2': 'approved', + }, + default_value: 0, + }; + + it.each([ + ['red' as BranchStatus, -2], + ['yellow' as BranchStatus, -2], + ['green' as BranchStatus, 2], + ])( + 'Label with +2/-2, map branchState=%p to %p', + (branchState, expectedValue) => { + expect(mapBranchStatusToLabel(branchState, labelWithTwo)).toEqual( + expectedValue, + ); + }, + ); + }); +}); diff --git a/lib/modules/platform/gerrit/utils.ts b/lib/modules/platform/gerrit/utils.ts new file mode 100644 index 00000000000000..d42ec4a463b2e1 --- /dev/null +++ b/lib/modules/platform/gerrit/utils.ts @@ -0,0 +1,122 @@ +import { CONFIG_GIT_URL_UNAVAILABLE } from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import type { BranchStatus, PrState } from '../../../types'; +import * as hostRules from '../../../util/host-rules'; +import { joinUrlParts, parseUrl } from '../../../util/url'; +import { hashBody } from '../pr-body'; +import type { Pr } from '../types'; +import type { + GerritChange, + GerritChangeStatus, + GerritLabelTypeInfo, +} from './types'; + +export const TAG_PULL_REQUEST_BODY = 'pull-request'; + +export function getGerritRepoUrl(repository: string, endpoint: string): string { + // Find options for current host and determine Git endpoint + const opts = hostRules.find({ + hostType: 'gerrit', + url: endpoint, + }); + + const url = parseUrl(endpoint); + if (!url) { + throw new Error(CONFIG_GIT_URL_UNAVAILABLE); + } + if (!(opts.username && opts.password)) { + throw new Error( + 'Init: You must configure a Gerrit Server username/password', + ); + } + url.username = opts.username; + url.password = opts.password; + url.pathname = joinUrlParts( + url.pathname, + 'a', + encodeURIComponent(repository), + ); + logger.trace( + { url: url.toString() }, + 'using URL based on configured endpoint', + ); + return url.toString(); +} + +export function mapPrStateToGerritFilter(state?: PrState): string { + switch (state) { + case 'closed': + return 'status:closed'; + case 'merged': + return 'status:merged'; + case '!open': + return '-status:open'; + case 'open': + return 'status:open'; + case 'all': + default: + return '-is:wip'; + } +} + +export function mapGerritChangeToPr(change: GerritChange): Pr { + return { + number: change._number, + state: mapGerritChangeStateToPrState(change.status), + sourceBranch: extractSourceBranch(change) ?? change.branch, + targetBranch: change.branch, + title: change.subject, + reviewers: + change.reviewers?.REVIEWER?.filter( + (reviewer) => typeof reviewer.username === 'string', + ).map((reviewer) => reviewer.username!) ?? [], + bodyStruct: { + hash: hashBody(findPullRequestBody(change)), + }, + }; +} + +export function mapGerritChangeStateToPrState( + state: GerritChangeStatus, +): PrState { + switch (state) { + case 'NEW': + return 'open'; + case 'MERGED': + return 'merged'; + case 'ABANDONED': + return 'closed'; + } + return 'all'; +} +export function extractSourceBranch(change: GerritChange): string | undefined { + return change.hashtags + ?.find((tag) => tag.startsWith('sourceBranch-')) + ?.replace('sourceBranch-', ''); +} + +export function findPullRequestBody(change: GerritChange): string | undefined { + const msg = Array.from(change.messages ?? []) + .reverse() + .find((msg) => msg.tag === TAG_PULL_REQUEST_BODY); + if (msg) { + return msg.message.replace(/^Patch Set \d+:\n\n/, ''); //TODO: check how to get rid of the auto-added prefix? + } + return undefined; +} + +export function mapBranchStatusToLabel( + state: BranchStatus, + label: GerritLabelTypeInfo, +): number { + const numbers = Object.keys(label.values).map((x) => parseInt(x, 10)); + switch (state) { + case 'green': + return Math.max(...numbers); + case 'yellow': + case 'red': + return Math.min(...numbers); + } + // istanbul ignore next + return label.default_value; +} diff --git a/lib/modules/platform/scm.ts b/lib/modules/platform/scm.ts index 8f80217ec36da5..7adc03b7fad70f 100644 --- a/lib/modules/platform/scm.ts +++ b/lib/modules/platform/scm.ts @@ -2,6 +2,7 @@ import type { Constructor } from 'type-fest'; import type { PlatformId } from '../../constants'; import { PLATFORM_NOT_FOUND } from '../../constants/error-messages'; import { DefaultGitScm } from './default-scm'; +import { GerritScm } from './gerrit/scm'; import { GithubScm } from './github/scm'; import { LocalFs } from './local/scm'; import type { PlatformScm } from './types'; @@ -11,6 +12,7 @@ platformScmImpls.set('azure', DefaultGitScm); platformScmImpls.set('codecommit', DefaultGitScm); platformScmImpls.set('bitbucket', DefaultGitScm); platformScmImpls.set('bitbucket-server', DefaultGitScm); +platformScmImpls.set('gerrit', GerritScm); platformScmImpls.set('gitea', DefaultGitScm); platformScmImpls.set('github', GithubScm); platformScmImpls.set('gitlab', DefaultGitScm); diff --git a/lib/util/http/gerrit.spec.ts b/lib/util/http/gerrit.spec.ts new file mode 100644 index 00000000000000..7c99f72d5f4f01 --- /dev/null +++ b/lib/util/http/gerrit.spec.ts @@ -0,0 +1,74 @@ +import * as httpMock from '../../../test/http-mock'; +import { GerritHttp, setBaseUrl } from './gerrit'; + +const baseUrl = 'https://gerrit.example.com/'; + +describe('util/http/gerrit', () => { + let api: GerritHttp; + + beforeEach(() => { + api = new GerritHttp(); + setBaseUrl(baseUrl); + }); + + it.each(['some-url/', baseUrl + 'some-url/'])('get %p', async (pathOrUrl) => { + const body = 'body result'; + httpMock + .scope(baseUrl) + .get(/some-url\/$/) + .reply(200, body, { 'content-type': 'text/plain;charset=utf-8' }); + + const res = await api.get(pathOrUrl); + expect(res.body).toEqual(body); + }); + + it('getJson', async () => { + const body = { key: 'value' }; + httpMock + .scope(baseUrl) + .get('/some-url') + .matchHeader('a', 'b') + .reply(200, gerritResult(JSON.stringify(body)), { + 'content-type': 'application/json;charset=utf-8', + }); + + const res = await api + .getJson('some-url', { headers: { a: 'b' } }) + .then((res) => res.body); + return expect(res).toEqual(body); + }); + + it('postJson', () => { + httpMock + .scope(baseUrl) + .post('/some-url') + .matchHeader('content-Type', 'application/json') + .reply(200, gerritResult('{"res":"success"}'), { + 'content-type': 'application/json;charset=utf-8', + }); + + return expect( + api + .postJson('some-url', { body: { key: 'value' } }) + .then((res) => res.body), + ).resolves.toEqual({ res: 'success' }); + }); + + it('putJson', () => { + httpMock + .scope(baseUrl) + .put('/some-url') + .matchHeader('content-Type', 'application/json') + .reply(200, gerritResult('{"res":"success"}'), { + 'content-type': 'application/json;charset=utf-8', + }); + + return expect( + api.putJson('some-url', { body: { key: 'value' } }).then((r) => r.body), + ).resolves.toEqual({ res: 'success' }); + }); +}); + +function gerritResult(body: string): string { + return `)]}'\n${body}`; +} diff --git a/lib/util/http/gerrit.ts b/lib/util/http/gerrit.ts new file mode 100644 index 00000000000000..a163da021fc5ee --- /dev/null +++ b/lib/util/http/gerrit.ts @@ -0,0 +1,38 @@ +import { parseJson } from '../common'; +import { regEx } from '../regex'; +import { validateUrl } from '../url'; +import type { HttpOptions, HttpResponse, InternalHttpOptions } from './types'; +import { Http } from './index'; + +let baseUrl: string; +export function setBaseUrl(url: string): void { + baseUrl = url; +} + +/** + * Access Gerrit REST-API and strip-of the "magic prefix" from responses. + * @see https://gerrit-review.googlesource.com/Documentation/rest-api.html + */ +export class GerritHttp extends Http { + private static magicPrefix = regEx(/^\)]}'\n/g); + + constructor(options?: HttpOptions) { + super('gerrit', options); + } + + protected override async request( + path: string, + options?: InternalHttpOptions, + ): Promise> { + const url = validateUrl(path) ? path : baseUrl + path; + const opts: InternalHttpOptions = { + parseJson: (text: string) => + parseJson(text.replace(GerritHttp.magicPrefix, ''), path), + ...options, + }; + opts.headers = { + ...opts.headers, + }; + return await super.request(url, opts); + } +} diff --git a/lib/util/http/types.ts b/lib/util/http/types.ts index 86b5366ac53b64..5d3d2770a29d0f 100644 --- a/lib/util/http/types.ts +++ b/lib/util/http/types.ts @@ -2,6 +2,7 @@ import type { IncomingHttpHeaders } from 'node:http'; import type { OptionsOfBufferResponseBody, OptionsOfJSONResponseBody, + ParseJsonFunction, } from 'got'; export type GotContextOptions = { @@ -79,6 +80,7 @@ export interface InternalHttpOptions extends HttpOptions { json?: HttpOptions['body']; responseType?: 'json' | 'buffer'; method?: 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; + parseJson?: ParseJsonFunction; } export interface HttpHeaders extends IncomingHttpHeaders { diff --git a/readme.md b/readme.md index 42fe148987e700..2d08611e813b30 100644 --- a/readme.md +++ b/readme.md @@ -39,6 +39,7 @@ Renovate works on these platforms: - [Azure DevOps](https://docs.renovatebot.com/modules/platform/azure/) - [AWS CodeCommit](https://docs.renovatebot.com/modules/platform/codecommit/) - [Gitea and Forgejo](https://docs.renovatebot.com/modules/platform/gitea/) +- [Gerrit (experimental)](https://docs.renovatebot.com/modules/platform/gerrit/) ## Who Uses Renovate?