From e3780f7cf89a130ef864307669369b632f89cbb8 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Tue, 1 Aug 2023 10:06:05 +0200 Subject: [PATCH] - Added Renovate support for the [SCM-Manager](https://scm-manager.org/) - SCM-Manager is a repository management tool like GitHub - It is maintained as an open source project by the company [Cloudogu](https://cloudogu.com/en/) - The SCM-Manager support is not feature complete yet, features like auto merging are still missing - The SCM-Manager also got added to the documentation as another platform author Thomas Zerr 1690877165 +0200 committer Eduard Heimbuch 1706264263 +0100 --- docs/usage/bot-comparison.md | 2 +- docs/usage/faq.md | 8 +- docs/usage/getting-started/running.md | 1 + docs/usage/getting-started/use-cases.md | 4 +- lib/config/options/index.ts | 6 +- lib/config/presets/local/index.ts | 1 + lib/constants/platforms.ts | 3 +- lib/modules/platform/api.ts | 2 + lib/modules/platform/scm.ts | 1 + lib/modules/platform/scmm/index.spec.ts | 411 +++++++++++++++++++ lib/modules/platform/scmm/index.ts | 349 ++++++++++++++++ lib/modules/platform/scmm/mapper.spec.ts | 44 ++ lib/modules/platform/scmm/mapper.ts | 20 + lib/modules/platform/scmm/readme.md | 13 + lib/modules/platform/scmm/scm-client.spec.ts | 332 +++++++++++++++ lib/modules/platform/scmm/scm-client.ts | 130 ++++++ lib/modules/platform/scmm/types.ts | 144 +++++++ lib/modules/platform/scmm/utils.spec.ts | 175 ++++++++ lib/modules/platform/scmm/utils.ts | 101 +++++ package.json | 1 + pnpm-lock.yaml | 64 +++ readme.md | 1 + 22 files changed, 1802 insertions(+), 11 deletions(-) create mode 100644 lib/modules/platform/scmm/index.spec.ts create mode 100644 lib/modules/platform/scmm/index.ts create mode 100644 lib/modules/platform/scmm/mapper.spec.ts create mode 100644 lib/modules/platform/scmm/mapper.ts create mode 100644 lib/modules/platform/scmm/readme.md create mode 100644 lib/modules/platform/scmm/scm-client.spec.ts create mode 100644 lib/modules/platform/scmm/scm-client.ts create mode 100644 lib/modules/platform/scmm/types.ts create mode 100644 lib/modules/platform/scmm/utils.spec.ts create mode 100644 lib/modules/platform/scmm/utils.ts diff --git a/docs/usage/bot-comparison.md b/docs/usage/bot-comparison.md index 227542e02c7946..e53993d5c54c58 100644 --- a/docs/usage/bot-comparison.md +++ b/docs/usage/bot-comparison.md @@ -12,7 +12,7 @@ If you see anything wrong on this page, please let us know by creating a [Discus | Dependency Dashboard | Yes | No | | Grouped updates | Yes, use community-provided groups, or create your own | Yes, create [`groups`](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups) manually | | Upgrades common monorepo packages at once | Yes | No | -| Officially supported platforms | GitHub, GitLab, Bitbucket, Azure, Gitea, see [full list](./index.md#supported-platforms) | GitHub only | +| Officially supported platforms | GitHub, GitLab, Bitbucket, Azure, Gitea, SCM-Manager, see [full list](./index.md#supported-platforms) | GitHub only | | Supported languages | [List for Renovate](./modules/manager/index.md) | [List for Dependabot](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/about-dependabot-version-updates#supported-repositories-and-ecosystems) | | Show changelogs | Yes | Yes | | Compatibility score badges | Four badges showing: Age, Adoption, Passing, Confidence | One badge with overall compatibility score | diff --git a/docs/usage/faq.md b/docs/usage/faq.md index 940aa16db1b5fb..bf1f61eb64b2d9 100644 --- a/docs/usage/faq.md +++ b/docs/usage/faq.md @@ -38,10 +38,10 @@ Major releases of Renovate are held back until the maintainers are reasonably ce ## Renovate core features not supported on all platforms -| Feature | Platforms which lack feature | See Renovate issue(s) | -| --------------------- | ---------------------------------------------------------- | ------------------------------------------------------------ | -| Dependency Dashboard | Azure, Bitbucket, Bitbucket Server | [#9592](https://github.com/renovatebot/renovate/issues/9592) | -| The Mend Renovate App | Azure, Bitbucket, Bitbucket Server, Forgejo, Gitea, GitLab | | +| Feature | Platforms which lack feature | See Renovate issue(s) | +| --------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------ | +| Dependency Dashboard | Azure, Bitbucket, Bitbucket Server, SCM-Manager | [#9592](https://github.com/renovatebot/renovate/issues/9592) | +| The Mend Renovate App | Azure, Bitbucket, Bitbucket Server, Forgejo, Gitea, GitLab, SCM-Manager | | ## Major platform features not supported by Renovate diff --git a/docs/usage/getting-started/running.md b/docs/usage/getting-started/running.md index 5abf351cd7ebed..a132daef73262c 100644 --- a/docs/usage/getting-started/running.md +++ b/docs/usage/getting-started/running.md @@ -208,6 +208,7 @@ Read the platform-specific docs to learn how to setup authentication on your pla - [Gitea and Forgejo](../modules/platform/gitea/index.md) - [github.com and GitHub Enterprise Server](../modules/platform/github/index.md) - [GitLab](../modules/platform/gitlab/index.md) +- [SCM-Manager](../modules/platform/scmm/index.md) ### GitHub.com token for changelogs diff --git a/docs/usage/getting-started/use-cases.md b/docs/usage/getting-started/use-cases.md index ffcdd260bc181e..f863a72435cf21 100644 --- a/docs/usage/getting-started/use-cases.md +++ b/docs/usage/getting-started/use-cases.md @@ -22,8 +22,8 @@ Example package files include: Renovate: 1. Scans your repositories to find package files and their dependencies -1. Checks if any newer versions exist -1. Raises Pull Requests for available updates +2. Checks if any newer versions exist +3. Raises Pull Requests for available updates The Pull Requests patch the package files directly, and include changelogs for the newer versions (if they are available). diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 17a556a8c69597..71a519c03dc5bc 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -316,7 +316,7 @@ const options: RenovateOptions[] = [ 'If set to `true` then Renovate creates draft PRs, instead of normal status PRs.', type: 'boolean', default: false, - supportedPlatforms: ['azure', 'gitea', 'github', 'gitlab'], + supportedPlatforms: ['azure', 'gitea', 'github', 'gitlab', 'scmm'], }, { name: 'dryRun', @@ -780,7 +780,7 @@ const options: RenovateOptions[] = [ description: 'Username for authentication.', stage: 'repository', type: 'string', - supportedPlatforms: ['azure', 'bitbucket', 'bitbucket-server'], + supportedPlatforms: ['azure', 'bitbucket', 'bitbucket-server', 'scmm'], globalOnly: true, }, { @@ -2729,7 +2729,7 @@ const options: RenovateOptions[] = [ description: 'Overrides the default resolution for Git remote, e.g. to switch GitLab from HTTPS to SSH-based.', type: 'string', - supportedPlatforms: ['gitlab', 'bitbucket-server'], + supportedPlatforms: ['gitlab', 'bitbucket-server', 'scmm'], allowedValues: ['default', 'ssh', 'endpoint'], default: 'default', stage: 'repository', diff --git a/lib/config/presets/local/index.ts b/lib/config/presets/local/index.ts index 6b7e39a3738f2f..ff78212a9e1454 100644 --- a/lib/config/presets/local/index.ts +++ b/lib/config/presets/local/index.ts @@ -26,6 +26,7 @@ const resolvers = { github, gitlab, local: null, + scmm: null, } satisfies Record; export function getPreset({ diff --git a/lib/constants/platforms.ts b/lib/constants/platforms.ts index d1ee8156312ba6..f91756f58c1bbc 100644 --- a/lib/constants/platforms.ts +++ b/lib/constants/platforms.ts @@ -7,7 +7,8 @@ export type PlatformId = | 'gitea' | 'github' | 'gitlab' - | 'local'; + | 'local' + | 'scmm'; export const GITEA_API_USING_HOST_TYPES = [ 'gitea', diff --git a/lib/modules/platform/api.ts b/lib/modules/platform/api.ts index 7d0ab19cee6bcb..514d2effac1aae 100644 --- a/lib/modules/platform/api.ts +++ b/lib/modules/platform/api.ts @@ -8,6 +8,7 @@ import * as gitea from './gitea'; import * as github from './github'; import * as gitlab from './gitlab'; import * as local from './local'; +import * as scmm from './scmm'; import type { Platform } from './types'; const api = new Map(); @@ -22,3 +23,4 @@ api.set(gitea.id, gitea); api.set(github.id, github); api.set(gitlab.id, gitlab); api.set(local.id, local); +api.set(scmm.id, scmm); diff --git a/lib/modules/platform/scm.ts b/lib/modules/platform/scm.ts index 7adc03b7fad70f..f62617238a9771 100644 --- a/lib/modules/platform/scm.ts +++ b/lib/modules/platform/scm.ts @@ -17,6 +17,7 @@ platformScmImpls.set('gitea', DefaultGitScm); platformScmImpls.set('github', GithubScm); platformScmImpls.set('gitlab', DefaultGitScm); platformScmImpls.set('local', LocalFs); +platformScmImpls.set('scmm', DefaultGitScm); let _scm: PlatformScm | undefined; diff --git a/lib/modules/platform/scmm/index.spec.ts b/lib/modules/platform/scmm/index.spec.ts new file mode 100644 index 00000000000000..3941a33a68df3d --- /dev/null +++ b/lib/modules/platform/scmm/index.spec.ts @@ -0,0 +1,411 @@ +import { mocked } from '../../../../test/util'; +import * as _git from '../../../util/git'; +import * as _hostRules from '../../../util/host-rules'; +import type { Pr } from '../types'; +import * as _util from '../util'; +import { mapPrFromScmToRenovate } from './mapper'; +import ScmClient from './scm-client'; +import type { + PrFilterByState, + PullRequest, + PullRequestCreateParams, + Repo, + User, +} from './types'; +import { + createPr, + findPr, + getBranchPr, + getPr, + getPrList, + getRepos, + initPlatform, + initRepo, + invalidatePrCache, + updatePr, +} from './index'; + +jest.mock('../../../util/host-rules'); +const hostRules: jest.Mocked = mocked(_hostRules); + +jest.mock('../../../util/git'); +const git: jest.Mocked = mocked(_git); + +jest.mock('../util'); +const util: jest.Mocked = mocked(_util); + +const endpoint = 'http://localhost:1337/scm/api/v2'; +const token = 'TEST_TOKEN'; + +const user: User = { + mail: 'test@user.de', + displayName: 'Test User', + username: 'testUser1337', +}; + +const repo: Repo = { + contact: 'test@test.com', + creationDate: '2023-08-02T10:48:24.762Z', + description: 'Default Repo', + lastModified: '2023-08-10T10:48:24.762Z', + namespace: 'default', + name: 'repo', + type: 'git', + archived: false, + exporting: false, + healthCheckRunning: false, + _links: { + protocol: [ + { name: 'http', href: 'http://localhost:8080/scm/default/repo' }, + ], + }, +}; + +const pullRequest: PullRequest = { + id: '1', + author: { displayName: 'Thomas Zerr', username: 'tzerr' }, + source: 'feature/test', + target: 'develop', + title: 'The PullRequest', + description: 'Another PullRequest', + creationDate: '2023-08-02T10:48:24.762Z', + status: 'OPEN', + labels: [], + tasks: { todo: 2, done: 4 }, + _links: {}, + _embedded: { + defaultConfig: { + mergeStrategy: 'SQUASH', + deleteBranchOnMerge: true, + }, + }, +}; + +const renovatePr: Pr = mapPrFromScmToRenovate(pullRequest); + +describe('modules/platform/scmm/index', () => { + beforeEach(() => { + jest.resetAllMocks(); + invalidatePrCache(); + }); + + describe(initPlatform, () => { + it('should throw error, because endpoint is not configured', async () => { + await expect(initPlatform({ token })).rejects.toThrow( + 'SCM-Manager endpoint not configured', + ); + }); + + it('should throw error, because token is not configured', async () => { + await expect(initPlatform({ endpoint })).rejects.toThrow( + 'SCM-Manager api token not configured', + ); + }); + + it('should init platform', async () => { + jest + .spyOn(ScmClient.prototype, 'getCurrentUser') + .mockResolvedValueOnce(user); + + expect(await initPlatform({ endpoint, token })).toEqual({ + endpoint, + gitAuthor: 'Test User ', + }); + }); + }); + + describe(initRepo, () => { + it('should init repo', async () => { + const repository = `${repo.namespace}/${repo.name}`; + const expectedFingerprint = 'expectedFingerprint'; + const expectedDefaultBranch = 'expectedDefaultBranch'; + + jest.spyOn(ScmClient.prototype, 'getRepo').mockResolvedValueOnce(repo); + jest + .spyOn(ScmClient.prototype, 'getDefaultBranch') + .mockResolvedValueOnce(expectedDefaultBranch); + + hostRules.find.mockReturnValueOnce({ username: user.username }); + git.initRepo.mockImplementationOnce(() => { + return Promise.resolve(); + }); + util.repoFingerprint.mockReturnValueOnce(expectedFingerprint); + + expect( + await initRepo({ repository: `${repo.namespace}/${repo.name}` }), + ).toEqual({ + defaultBranch: expectedDefaultBranch, + isFork: false, + repoFingerprint: expectedFingerprint, + }); + + expect(git.initRepo).toHaveBeenCalledWith({ + url: `http://${user.username}@localhost:8080/scm/default/repo`, + repository, + defaultBranch: expectedDefaultBranch, + }); + }); + }); + + describe(getRepos, () => { + it('should return all available repos', async () => { + jest + .spyOn(ScmClient.prototype, 'getAllRepos') + .mockResolvedValueOnce([ + repo, + { ...repo, namespace: 'other', name: 'repository' }, + { ...repo, namespace: 'other', name: 'mercurial', type: 'hg' }, + { ...repo, namespace: 'other', name: 'subversion', type: 'svn' }, + ]); + + expect(await getRepos()).toEqual(['default/repo', 'other/repository']); + }); + }); + + describe(getPrList, () => { + it('should return empty array, because no pr could be found', async () => { + jest + .spyOn(ScmClient.prototype, 'getAllRepoPrs') + .mockRejectedValue(new Error()); + + expect(await getPrList()).toIncludeAllMembers([]); + }); + + it('should return all prs of a repo', async () => { + const expectedResult: Pr[] = [ + { + sourceBranch: pullRequest.source, + createdAt: pullRequest.creationDate, + labels: pullRequest.labels, + number: parseInt(pullRequest.id), + state: pullRequest.status, + targetBranch: pullRequest.target, + title: pullRequest.title, + hasAssignees: false, + isDraft: false, + reviewers: [], + }, + ]; + + jest + .spyOn(ScmClient.prototype, 'getAllRepoPrs') + .mockResolvedValueOnce([pullRequest]); + + //Fetching from client + expect(await getPrList()).toIncludeAllMembers(expectedResult); + //Fetching from cache + expect(await getPrList()).toIncludeAllMembers(expectedResult); + }); + }); + + describe(findPr, () => { + it('search in pull request without explicitly setting the state as argument', async () => { + jest + .spyOn(ScmClient.prototype, 'getAllRepoPrs') + .mockResolvedValueOnce([pullRequest]); + + expect( + await findPr({ + branchName: pullRequest.source, + prTitle: pullRequest.title, + }), + ).toEqual(renovatePr); + }); + + it.each([ + [[], pullRequest.source, pullRequest.title, 'all', null], + [[pullRequest], 'invalid branchName', pullRequest.title, 'all', null], + [[pullRequest], pullRequest.source, 'invalid title', 'all', null], + [[pullRequest], pullRequest.source, null, 'all', renovatePr], + [[pullRequest], pullRequest.source, undefined, 'all', renovatePr], + [[pullRequest], pullRequest.source, pullRequest.title, 'all', renovatePr], + [ + [pullRequest], + pullRequest.source, + pullRequest.title, + 'open', + renovatePr, + ], + [[pullRequest], pullRequest.source, pullRequest.title, '!open', null], + [[pullRequest], pullRequest.source, pullRequest.title, 'closed', null], + ])( + 'search within %p for %p, %p, %p with result %p', + async ( + availablePullRequest: PullRequest[], + branchName: string, + prTitle: string | null | undefined, + state: string, + result: Pr | null, + ) => { + jest + .spyOn(ScmClient.prototype, 'getAllRepoPrs') + .mockResolvedValueOnce(availablePullRequest); + + expect( + await findPr({ + branchName, + prTitle, + state: state as PrFilterByState, + }), + ).toEqual(result); + }, + ); + }); + + describe(getBranchPr, () => { + it.each([ + [[], pullRequest.source, null], + [[pullRequest], 'invalid branchName', null], + [[pullRequest], pullRequest.source, renovatePr], + ])( + 'search within %p for %p with result %p', + async ( + availablePullRequest: PullRequest[], + branchName: string, + result: Pr | null, + ) => { + jest + .spyOn(ScmClient.prototype, 'getAllRepoPrs') + .mockResolvedValueOnce(availablePullRequest); + + expect(await getBranchPr(branchName)).toEqual(result); + }, + ); + }); + + describe(getPr, () => { + it('should return null, because pr was not found', async () => { + jest + .spyOn(ScmClient.prototype, 'getAllRepoPrs') + .mockResolvedValueOnce([]); + + jest + .spyOn(ScmClient.prototype, 'getRepoPr') + .mockRejectedValue(new Error('Not found')); + + expect(await getPr(1)).toBeNull(); + }); + + it.each([ + [[], pullRequest, 1, renovatePr], + [[pullRequest], pullRequest, 1, renovatePr], + ])( + 'search within %p for %p with result %p', + async ( + availablePullRequest: PullRequest[], + pullRequestById: PullRequest, + prId: number, + result: Pr | null, + ) => { + jest + .spyOn(ScmClient.prototype, 'getAllRepoPrs') + .mockResolvedValueOnce(availablePullRequest); + + jest + .spyOn(ScmClient.prototype, 'getRepoPr') + .mockResolvedValueOnce(pullRequestById); + + expect(await getPr(prId)).toEqual(result); + }, + ); + }); + + describe(createPr, () => { + it.each([ + [undefined, 'OPEN', false], + [false, 'OPEN', false], + [true, 'DRAFT', true], + ])( + 'it should create the pr with isDraft %p and state %p', + async ( + draftPR: boolean | undefined, + expectedState: string, + expectedIsDraft: boolean, + ) => { + jest + .spyOn(ScmClient.prototype, 'createPr') + .mockImplementationOnce( + (_repoPath: string, createParams: PullRequestCreateParams) => { + return Promise.resolve({ + id: '1337', + source: createParams.source, + target: createParams.target, + title: createParams.title, + description: createParams.description ?? '', + creationDate: '2023-01-01T13:37:00.000Z', + status: createParams.status ?? 'OPEN', + labels: [], + tasks: { todo: 0, done: 0 }, + _links: {}, + _embedded: { + defaultConfig: { + mergeStrategy: 'FAST_FORWARD_IF_POSSIBLE', + deleteBranchOnMerge: false, + }, + }, + }); + }, + ); + + expect( + await createPr({ + sourceBranch: 'feature/test', + targetBranch: 'develop', + prTitle: 'PR Title', + prBody: 'PR Body', + draftPR, + }), + ).toEqual({ + sourceBranch: 'feature/test', + targetBranch: 'develop', + title: 'PR Title', + createdAt: '2023-01-01T13:37:00.000Z', + hasAssignees: false, + isDraft: expectedIsDraft, + labels: [], + number: 1337, + reviewers: [], + state: expectedState, + }); + }, + ); + }); + + describe(updatePr, () => { + it.each([ + ['open', 'OPEN', 'prBody', 'prBody'], + ['closed', 'REJECTED', 'prBody', 'prBody'], + [undefined, undefined, 'prBody', 'prBody'], + ['open', 'OPEN', undefined, undefined], + ])( + 'it should update the pr with state %p and prBody %p', + async ( + actualState: string | undefined, + expectedState: string | undefined, + actualPrBody: string | undefined, + expectedPrBody: string | undefined, + ) => { + jest + .spyOn(ScmClient.prototype, 'updatePr') + .mockImplementationOnce(() => Promise.resolve()); + + await updatePr({ + number: 1, + prTitle: 'PR Title', + prBody: actualPrBody, + state: actualState as 'open' | 'closed' | undefined, + targetBranch: 'Target/Branch', + }); + + expect( + jest.spyOn(ScmClient.prototype, 'updatePr'), + ).toHaveBeenCalledWith('default/repo', 1, { + description: expectedPrBody, + status: expectedState, + target: 'Target/Branch', + title: 'PR Title', + }); + }, + ); + }); +}); diff --git a/lib/modules/platform/scmm/index.ts b/lib/modules/platform/scmm/index.ts new file mode 100644 index 00000000000000..5da76f5f31db36 --- /dev/null +++ b/lib/modules/platform/scmm/index.ts @@ -0,0 +1,349 @@ +import { logger } from '../../../logger'; +import type { BranchStatus } from '../../../types'; +import * as git from '../../../util/git'; +import * as hostRules from '../../../util/host-rules'; +import { sanitize } from '../../../util/sanitize'; +import type { + BranchStatusConfig, + CreatePRConfig, + EnsureCommentConfig, + EnsureCommentRemovalConfigByContent, + EnsureCommentRemovalConfigByTopic, + EnsureIssueConfig, + FindPRConfig, + Issue, + MergePRConfig, + PlatformParams, + PlatformResult, + Pr, + RepoParams, + RepoResult, + UpdatePrConfig, +} from '../types'; +import { repoFingerprint } from '../util'; +import { smartTruncate } from '../utils/pr-body'; +import { mapPrFromScmToRenovate } from './mapper'; +import ScmClient from './scm-client'; +import { getRepoUrl, mapPrState, matchPrState, smartLinks } from './utils'; + +interface SCMMRepoConfig { + repository: string; + prList: Pr[] | null; + defaultBranch: string; +} + +export const id = 'scmm'; + +let config: SCMMRepoConfig = {} as any; +let scmmClient: ScmClient; + +export async function initPlatform({ + endpoint, + token, +}: PlatformParams): Promise { + if (!endpoint) { + throw new Error('SCM-Manager endpoint not configured'); + } + + if (!token) { + throw new Error('SCM-Manager api token not configured'); + } + + scmmClient = new ScmClient(endpoint, token); + + const me = await scmmClient.getCurrentUser(); + const gitAuthor = `${me.displayName} <${me.mail}>`; + const result = { endpoint, gitAuthor }; + + logger.info(`Plattform initialized ${JSON.stringify(result)}`); + + return result; +} + +export async function initRepo({ + repository, + gitUrl, +}: RepoParams): Promise { + const repo = await scmmClient.getRepo(repository); + const defaultBranch = await scmmClient.getDefaultBranch(repo); + const url = getRepoUrl( + repo, + gitUrl, + /* istanbul ignore next */ + hostRules.find({ hostType: id, url: scmmClient.getEndpoint() }).username ?? + '', + process.env.RENOVATE_TOKEN ?? '', + ); + + config = {} as any; + config.repository = repository; + config.defaultBranch = defaultBranch; + + await git.initRepo({ + ...config, + url, + }); + + // Reset cached resources + invalidatePrCache(); + + const result = { + defaultBranch: config.defaultBranch, + isFork: false, + repoFingerprint: repoFingerprint( + config.repository, + scmmClient.getEndpoint(), + ), + }; + + logger.info(`Repo initialized: ${JSON.stringify(result)}`); + + return result; +} + +export async function getRepos(): Promise { + const repos = (await scmmClient.getAllRepos()).filter( + (repo) => repo.type === 'git', + ); + const result = repos.map((repo) => `${repo.namespace}/${repo.name}`); + logger.info(`Discovered ${repos.length} repos`); + + return result; +} + +export async function getBranchPr(branchName: string): Promise { + return await findPr({ branchName, state: 'open' }); +} + +export async function findPr({ + branchName, + prTitle, + state = 'all', +}: FindPRConfig): Promise { + const inProgressPrs = await getPrList(); + const result = inProgressPrs.find( + (pr) => + branchName === pr.sourceBranch && + (!prTitle || prTitle === pr.title) && + matchPrState(pr, state), + ); + + if (result) { + logger.info(`Found PR ${JSON.stringify(result)}`); + return result; + } + + logger.debug( + `Could not find PR with source branch ${branchName} and title ${ + prTitle ?? '' + } and state ${state}`, + ); + + return null; +} + +export async function getPr(number: number): Promise { + const inProgressPrs = await getPrList(); + const cachedPr = inProgressPrs.find((pr) => pr.number === number); + + if (cachedPr) { + logger.info(`Returning from cached PRs, ${JSON.stringify(cachedPr)}`); + return cachedPr; + } + + try { + const result = await scmmClient.getRepoPr(config.repository, number); + logger.info(`Returning PR from API, ${JSON.stringify(result)}`); + return mapPrFromScmToRenovate(result); + } catch (error) { + logger.info(`Not found PR with id ${number}`); + return null; + } +} + +export async function getPrList(): Promise { + if (config.prList === null) { + try { + config.prList = (await scmmClient.getAllRepoPrs(config.repository)).map( + (pr) => mapPrFromScmToRenovate(pr), + ); + } catch (error) { + logger.error(error); + } + } + + return config.prList ?? []; +} + +export async function createPr({ + sourceBranch, + targetBranch, + prTitle, + prBody, + draftPR, +}: CreatePRConfig): Promise { + const createdPr = await scmmClient.createPr(config.repository, { + source: sourceBranch, + target: targetBranch, + title: prTitle, + description: sanitize(prBody), + status: draftPR ? 'DRAFT' : 'OPEN', + }); + + logger.info( + `Pr Created with title '${createdPr.title}' from source '${createdPr.source}' to target '${createdPr.target}'`, + ); + logger.debug(`Pr Created ${JSON.stringify(createdPr)}`); + + return mapPrFromScmToRenovate(createdPr); +} + +export async function updatePr({ + number, + prTitle, + prBody, + state, + targetBranch, +}: UpdatePrConfig): Promise { + await scmmClient.updatePr(config.repository, number, { + title: prTitle, + description: sanitize(prBody) ?? undefined, + target: targetBranch, + status: mapPrState(state), + }); + + logger.info(`Updated Pr #${number} with title ${prTitle}`); +} + +/* istanbul ignore next */ +export function mergePr(config: MergePRConfig): Promise { + logger.debug('NO-OP mergePr'); + return Promise.resolve(false); +} + +/* istanbul ignore next */ +export function getBranchStatus( + branchName: string, + internalChecksAsSuccess: boolean, +): Promise { + logger.debug('NO-OP getBranchStatus'); + return Promise.resolve('red'); +} + +/* istanbul ignore next */ +export function setBranchStatus( + branchStatusConfig: BranchStatusConfig, +): Promise { + logger.debug('NO-OP setBranchStatus'); + return Promise.resolve(); +} + +/* istanbul ignore next */ +export function getBranchStatusCheck( + branchName: string, + context: string | null | undefined, +): Promise { + logger.debug('NO-OP setBranchStatus'); + return Promise.resolve(null); +} + +/* istanbul ignore next */ +export function addReviewers( + number: number, + reviewers: string[], +): Promise { + logger.debug('NO-OP addReviewers'); + return Promise.resolve(); +} + +/* istanbul ignore next */ +export function addAssignees( + number: number, + assignees: string[], +): Promise { + logger.debug('NO-OP addAssignees'); + return Promise.resolve(); +} + +/* istanbul ignore next */ +export function deleteLabel(number: number, label: string): Promise { + logger.debug('NO-OP deleteLabel'); + return Promise.resolve(); +} + +/* istanbul ignore next */ +export function getIssueList(): Promise { + logger.debug('NO-OP getIssueList'); + return Promise.resolve([]); +} + +/* istanbul ignore next */ +export function findIssue(title: string): Promise { + logger.debug('NO-OP findIssue'); + return Promise.resolve(null); +} + +/* istanbul ignore next */ +export function ensureIssue( + config: EnsureIssueConfig, +): Promise<'updated' | 'created' | null> { + logger.debug('NO-OP ensureIssue'); + return Promise.resolve(null); +} + +/* istanbul ignore next */ +export function ensureIssueClosing(title: string): Promise { + logger.debug('NO-OP ensureIssueClosing'); + return Promise.resolve(); +} + +/* istanbul ignore next */ +export function ensureComment(config: EnsureCommentConfig): Promise { + logger.debug('NO-OP ensureComment'); + return Promise.resolve(false); +} + +/* istanbul ignore next */ +export function ensureCommentRemoval( + ensureCommentRemoval: + | EnsureCommentRemovalConfigByTopic + | EnsureCommentRemovalConfigByContent, +): Promise { + logger.debug('NO-OP ensureCommentRemoval'); + return Promise.resolve(); +} + +/* istanbul ignore next */ +export function massageMarkdown(prBody: string): string { + return smartTruncate(smartLinks(prBody), 10000); +} + +/* istanbul ignore next */ +export function getRepoForceRebase(): Promise { + return Promise.resolve(false); +} + +/* istanbul ignore next */ +export function getRawFile( + fileName: string, + repoName?: string, + branchOrTag?: string, +): Promise { + logger.debug('NO-OP getRawFile'); + return Promise.resolve(null); +} + +/* istanbul ignore next */ +export function getJsonFile( + fileName: string, + repoName?: string, + branchOrTag?: string, +): Promise { + logger.debug('NO-OP getJsonFile'); + return Promise.resolve(undefined); +} + +/* istanbul ignore next */ +export function invalidatePrCache(): void { + config.prList = null; +} diff --git a/lib/modules/platform/scmm/mapper.spec.ts b/lib/modules/platform/scmm/mapper.spec.ts new file mode 100644 index 00000000000000..925e826ea9ac01 --- /dev/null +++ b/lib/modules/platform/scmm/mapper.spec.ts @@ -0,0 +1,44 @@ +import { mapPrFromScmToRenovate } from './mapper'; +import type { PullRequest as SCMPullRequest } from './types'; + +describe('modules/platform/scmm/mapper', () => { + it('should correctly map the scm type of a pr to the renovate pr type', () => { + const scmPr: SCMPullRequest = { + source: 'feat/new', + target: 'develop', + creationDate: '2024-12-24T18:21Z', + closeDate: '2024-12-25T18:21Z', + reviewer: [ + { id: 'id', displayName: 'user', mail: 'user@user.de', approved: true }, + ], + labels: ['label'], + id: '1', + status: 'OPEN', + title: 'Merge please', + description: 'Description', + tasks: { todo: 0, done: 0 }, + _links: {}, + _embedded: { + defaultConfig: { + mergeStrategy: 'SQUASH', + deleteBranchOnMerge: true, + }, + }, + }; + + const result = mapPrFromScmToRenovate(scmPr); + expect(result).toEqual({ + sourceBranch: 'feat/new', + targetBranch: 'develop', + createdAt: '2024-12-24T18:21Z', + closedAt: '2024-12-25T18:21Z', + hasAssignees: true, + labels: ['label'], + number: 1, + reviewers: ['user'], + state: 'OPEN', + title: 'Merge please', + isDraft: false, + }); + }); +}); diff --git a/lib/modules/platform/scmm/mapper.ts b/lib/modules/platform/scmm/mapper.ts new file mode 100644 index 00000000000000..a6adceb0caf4c9 --- /dev/null +++ b/lib/modules/platform/scmm/mapper.ts @@ -0,0 +1,20 @@ +import type { Pr as RenovatePr } from '../types'; +import type { PullRequest as SCMPullRequest } from './types'; + +export function mapPrFromScmToRenovate(pr: SCMPullRequest): RenovatePr { + return { + sourceBranch: pr.source, + targetBranch: pr.target, + createdAt: pr.creationDate, + closedAt: pr.closeDate, + hasAssignees: pr.reviewer !== undefined && pr.reviewer.length > 0, + labels: pr.labels, + number: parseInt(pr.id), + reviewers: pr.reviewer + ? pr.reviewer.map((review) => review.displayName) + : [], + state: pr.status, + title: pr.title, + isDraft: pr.status === 'DRAFT', + }; +} diff --git a/lib/modules/platform/scmm/readme.md b/lib/modules/platform/scmm/readme.md new file mode 100644 index 00000000000000..ba8617e77ae117 --- /dev/null +++ b/lib/modules/platform/scmm/readme.md @@ -0,0 +1,13 @@ +# SCM-Manager + +Renovate supports [SCM-Manager](https://scm-manager.org). + +## Authentication + +First, create an API Key for your technical Renovate user in SCM-Manager. +The technical user should be configured properly with name and email address. +Then let Renovate use your API Key by setting the `RENOVATE_TOKEN` environment variable with your key. + +You must set `platform=scmm` in your Renovate config file. + +The technical user needs at least the permissions to read your repository read and create pull request. This can be achieved by granting the permission role "OWNER" to your technical Renovate user. diff --git a/lib/modules/platform/scmm/scm-client.spec.ts b/lib/modules/platform/scmm/scm-client.spec.ts new file mode 100644 index 00000000000000..7e61edbd971ac2 --- /dev/null +++ b/lib/modules/platform/scmm/scm-client.spec.ts @@ -0,0 +1,332 @@ +import * as httpMock from '../../../../test/http-mock'; +import ScmClient from './scm-client'; +import type { + PullRequest, + PullRequestCreateParams, + PullRequestUpdateParams, + Repo, + User, +} from './types'; + +describe('modules/platform/scmm/scm-client', () => { + const endpoint = 'http://localhost:8080/scm/api/v2'; + const token = 'validApiToken'; + + const scmClient = new ScmClient(endpoint, token); + + const repo: Repo = { + contact: 'test@test.com', + creationDate: '2023-08-02T10:48:24.762Z', + description: 'Default Repo', + lastModified: '2023-08-10T10:48:24.762Z', + namespace: 'default', + name: 'repo', + type: 'git', + archived: false, + exporting: false, + healthCheckRunning: false, + _links: { + protocol: [ + { name: 'http', href: 'http://localhost:8080/scm/default/repo' }, + ], + defaultBranch: { + href: `${endpoint}/config/git/default/repo/default-branch`, + }, + }, + }; + + const pullRequest: PullRequest = { + id: '1337', + author: { displayName: 'Thomas Zerr', username: 'tzerr' }, + source: 'feature/test', + target: 'develop', + title: 'The PullRequest', + description: 'Another PullRequest', + creationDate: '2023-08-02T10:48:24.762Z', + status: 'OPEN', + labels: [], + tasks: { todo: 2, done: 4 }, + _links: {}, + _embedded: { + defaultConfig: { + mergeStrategy: 'SQUASH', + deleteBranchOnMerge: true, + }, + }, + }; + + describe(scmClient.getEndpoint, () => { + it('should return the endpoint', () => { + expect(scmClient.getEndpoint()).toEqual(endpoint); + }); + }); + + describe(scmClient.getCurrentUser, () => { + it('should return the current user', async () => { + const expectedUser: User = { + mail: 'test@test.de', + displayName: 'Test User', + username: 'test', + }; + + httpMock.scope(endpoint).get('/me').reply(200, expectedUser); + + expect(await scmClient.getCurrentUser()).toEqual(expectedUser); + }); + + it.each([[401, 500]])( + 'should throw %p response', + async (response: number) => { + httpMock.scope(endpoint).get('/me').reply(response); + await expect(scmClient.getCurrentUser()).rejects.toThrow(); + }, + ); + }); + + describe(scmClient.getRepo, () => { + it('should return the repo', async () => { + httpMock + .scope(endpoint) + .get(`/repositories/${repo.namespace}/${repo.name}`) + .reply(200, repo); + + expect(await scmClient.getRepo(`${repo.namespace}/${repo.name}`)).toEqual( + repo, + ); + }); + + it.each([[401], [403], [404], [500]])( + 'should throw %p response', + async (response: number) => { + httpMock + .scope(endpoint) + .get(`/repositories/${repo.namespace}/${repo.name}`) + .reply(response); + + await expect( + scmClient.getRepo(`${repo.namespace}/${repo.name}`), + ).rejects.toThrow(); + }, + ); + }); + + describe(scmClient.getAllRepos, () => { + it('should return all repos', async () => { + httpMock + .scope(endpoint) + .get('/repositories?pageSize=1000000') + .reply(200, { + page: 0, + pageTotal: 1, + _embedded: { repositories: [repo] }, + }); + + expect(await scmClient.getAllRepos()).toEqual([repo]); + }); + + it.each([[401], [403], [500]])( + 'should throw %p response', + async (response: number) => { + httpMock + .scope(endpoint) + .get('/repositories?pageSize=1000000') + .reply(response); + + await expect(scmClient.getAllRepos()).rejects.toThrow(); + }, + ); + }); + + describe(scmClient.getDefaultBranch, () => { + it('should return the default branch', async () => { + httpMock + .scope(endpoint) + .get('/config/git/default/repo/default-branch') + .reply(200, { + defaultBranch: 'develop', + }); + + expect(await scmClient.getDefaultBranch(repo)).toBe('develop'); + }); + + it.each([[401], [403], [404], [500]])( + 'should throw %p response', + async (response: number) => { + httpMock + .scope(endpoint) + .get('/config/git/default/repo/default-branch') + .reply(response); + + await expect(scmClient.getDefaultBranch(repo)).rejects.toThrow(); + }, + ); + }); + + describe(scmClient.getAllRepoPrs, () => { + it('should return all repo prs', async () => { + httpMock + .scope(endpoint) + .get( + `/pull-requests/${repo.namespace}/${repo.name}?status=ALL&pageSize=1000000`, + ) + .reply(200, { + page: 0, + pageTotal: 1, + _embedded: { + pullRequests: [pullRequest], + }, + }); + + expect( + await scmClient.getAllRepoPrs(`${repo.namespace}/${repo.name}`), + ).toEqual([pullRequest]); + }); + + it.each([[401], [403], [404], [500]])( + 'should throw %p response', + async (response: number) => { + httpMock + .scope(endpoint) + .get( + `/pull-requests/${repo.namespace}/${repo.name}?status=ALL&pageSize=1000000`, + ) + .reply(response); + + await expect( + scmClient.getAllRepoPrs(`${repo.namespace}/${repo.name}`), + ).rejects.toThrow(); + }, + ); + }); + + describe(scmClient.getRepoPr, () => { + it('should return the repo pr', async () => { + httpMock + .scope(endpoint) + .get(`/pull-requests/${repo.namespace}/${repo.name}/${pullRequest.id}`) + .reply(200, pullRequest); + + expect( + await scmClient.getRepoPr(`${repo.namespace}/${repo.name}`, 1337), + ).toEqual(pullRequest); + }); + + it.each([[401], [403], [404], [500]])( + 'should throw %p response', + async (response: number) => { + httpMock + .scope(endpoint) + .get( + `/pull-requests/${repo.namespace}/${repo.name}/${pullRequest.id}`, + ) + .reply(response); + + await expect( + scmClient.getRepoPr(`${repo.namespace}/${repo.name}`, 1337), + ).rejects.toThrow(); + }, + ); + }); + + describe(scmClient.createPr, () => { + it('should create pr for a repo', async () => { + const expectedCreateParams: PullRequestCreateParams = { + source: 'feature/test', + target: 'develop', + title: 'Test Title', + description: 'PR description', + assignees: ['Test assignee'], + status: 'OPEN', + }; + + const expectedPrId = 1337; + + httpMock + .scope(endpoint) + .post(`/pull-requests/${repo.namespace}/${repo.name}`) + .reply(201, undefined, { + location: `${endpoint}/pull-requests/${repo.namespace}/${repo.name}/${expectedPrId}`, + }); + + httpMock + .scope(endpoint) + .get(`/pull-requests/${repo.namespace}/${repo.name}/${expectedPrId}`) + .reply(200, pullRequest); + + expect( + await scmClient.createPr( + `${repo.namespace}/${repo.name}`, + expectedCreateParams, + ), + ).toEqual(pullRequest); + }); + + it.each([[400], [401], [403], [404], [500]])( + 'should throw %p response', + async (response: number) => { + httpMock + .scope(endpoint) + .post(`/pull-requests/${repo.namespace}/${repo.name}`) + .reply(response); + + await expect( + scmClient.createPr(`${repo.namespace}/${repo.name}`, { + source: 'feature/test', + target: 'develop', + title: 'Test Title', + description: 'PR description', + assignees: ['Test assignee'], + status: 'OPEN', + }), + ).rejects.toThrow(); + }, + ); + }); + + describe(scmClient.updatePr, () => { + it('should update pr for a repo', async () => { + const expectedUpdateParams: PullRequestUpdateParams = { + title: 'Test Title', + description: 'PR description', + assignees: ['Test assignee'], + status: 'OPEN', + }; + + const expectedPrId = 1337; + + httpMock + .scope(endpoint) + .put(`/pull-requests/${repo.namespace}/${repo.name}/${expectedPrId}`) + .reply(204); + + await expect( + scmClient.updatePr( + `${repo.namespace}/${repo.name}`, + expectedPrId, + expectedUpdateParams, + ), + ).resolves.not.toThrow(); + }); + + it.each([[400], [401], [403], [404], [500]])( + 'should throw %p response', + async (response: number) => { + const expectedPrId = 1337; + + httpMock + .scope(endpoint) + .put(`/pull-requests/${repo.namespace}/${repo.name}/${expectedPrId}`) + .reply(response); + + await expect( + scmClient.updatePr(`${repo.namespace}/${repo.name}`, expectedPrId, { + title: 'Test Title', + description: 'PR description', + assignees: ['Test assignee'], + status: 'OPEN', + }), + ).rejects.toThrow(); + }, + ); + }); +}); diff --git a/lib/modules/platform/scmm/scm-client.ts b/lib/modules/platform/scmm/scm-client.ts new file mode 100644 index 00000000000000..775d9efd9ff80f --- /dev/null +++ b/lib/modules/platform/scmm/scm-client.ts @@ -0,0 +1,130 @@ +import type { AxiosInstance } from 'axios'; +import axios from 'axios'; +import type { + Link, + Page, + PullRequest, + PullRequestCreateParams, + PullRequestPage, + PullRequestUpdateParams, + Repo, + RepoPage, + User, +} from './types'; + +const URLS = { + ME: 'me', + ALLREPOS: 'repositories', + REPO: (repoPath: string) => `repositories/${repoPath}`, + PULLREQUESTS: (repoPath: string) => `pull-requests/${repoPath}`, + PULLREQUESTBYID: (repoPath: string, id: number) => + `pull-requests/${repoPath}/${id}`, +}; + +const CONTENT_TYPES = { + PULLREQUESTS: 'application/vnd.scmm-pullrequest+json;v=2', +}; + +export default class ScmClient { + private httpClient: AxiosInstance; + + constructor(endpoint: string, token: string) { + this.httpClient = axios.create({ + baseURL: endpoint, + headers: { + Authorization: `Bearer ${token}`, + Accept: '*', + 'X-Scm-Client': 'WUI', + }, + }); + } + + public getEndpoint(): string { + /* istanbul ignore next */ + if (!this.httpClient.defaults.baseURL) { + throw new Error('BaseURL is not defined'); + } + + return this.httpClient.defaults.baseURL; + } + + public async getCurrentUser(): Promise { + const response = await this.httpClient.get(URLS.ME); + return response.data; + } + + public async getRepo(repoPath: string): Promise { + const response = await this.httpClient.get(URLS.REPO(repoPath)); + return response.data; + } + + public async getAllRepos(): Promise { + const response = await this.httpClient.get>(URLS.ALLREPOS, { + params: { pageSize: 1000000 }, + }); + + return response.data._embedded.repositories; + } + + public async getDefaultBranch(repo: Repo): Promise { + const defaultBranchUrl = repo._links['defaultBranch'] as Link; + const response = await this.httpClient.get<{ defaultBranch: string }>( + defaultBranchUrl.href, + { baseURL: undefined }, + ); + + return response.data.defaultBranch; + } + + public async getAllRepoPrs(repoPath: string): Promise { + const response = await this.httpClient.get>( + URLS.PULLREQUESTS(repoPath), + { + params: { status: 'ALL', pageSize: 1000000 }, + }, + ); + return response.data._embedded.pullRequests; + } + + public async getRepoPr(repoPath: string, id: number): Promise { + const response = await this.httpClient.get( + URLS.PULLREQUESTBYID(repoPath, id), + ); + + return response.data; + } + + public async createPr( + repoPath: string, + params: PullRequestCreateParams, + ): Promise { + const createPrResponse = await this.httpClient.post( + URLS.PULLREQUESTS(repoPath), + params, + { + headers: { + 'Content-Type': CONTENT_TYPES.PULLREQUESTS, + }, + }, + ); + + const getCreatedPrResponse = await this.httpClient.get( + createPrResponse.headers.location, + { baseURL: undefined }, + ); + + return getCreatedPrResponse.data; + } + + public async updatePr( + repoPath: string, + id: number, + params: PullRequestUpdateParams, + ): Promise { + await this.httpClient.put(URLS.PULLREQUESTBYID(repoPath, id), params, { + headers: { + 'Content-Type': CONTENT_TYPES.PULLREQUESTS, + }, + }); + } +} diff --git a/lib/modules/platform/scmm/types.ts b/lib/modules/platform/scmm/types.ts new file mode 100644 index 00000000000000..3c5665fac7c777 --- /dev/null +++ b/lib/modules/platform/scmm/types.ts @@ -0,0 +1,144 @@ +export type Page = { + page: number; + pageTotal: number; + _embedded: T; +}; + +export type Links = { + [link: string]: Link | Link[] | undefined; +}; + +export type Link = { + href: string; + name?: string; + templated?: boolean; +}; + +export type PullRequestPage = { + pullRequests: PullRequest[]; +}; + +export interface PullRequestCreateParams extends PullRequestUpdateParams { + source: string; + target: string; +} + +export interface PullRequestUpdateParams { + title: string; + description?: string; + assignees?: string[]; + status?: PRState; + target?: string; +} + +export interface PullRequest { + id: string; + author?: User; + reviser?: Reviser; + closeDate?: string; + source: string; + target: string; + title: string; + description: string; + creationDate: string; + lastModified?: string; + status: PRState; + reviewer?: Reviewer[]; + labels: string[]; + tasks: Tasks; + _links: Links; + _embedded: { + defaultConfig: { + mergeStrategy: PRMergeMethod; + deleteBranchOnMerge: boolean; + }; + }; +} + +export interface User { + mail?: string; + displayName: string; + username: string; +} + +export interface Reviser { + id?: string; + displayName?: string; +} + +export type PRState = 'DRAFT' | 'OPEN' | 'REJECTED' | 'MERGED'; + +export interface Reviewer { + id: string; + displayName: string; + mail?: string; + approved: boolean; +} + +export interface Tasks { + todo: number; + done: number; +} + +export type PRMergeMethod = + | 'MERGE_COMMIT' + | 'REBASE' + | 'FAST_FORWARD_IF_POSSIBLE' + | 'SQUASH'; + +export type CommitStatusType = + | 'pending' + | 'success' + | 'error' + | 'failure' + | 'warning' + | 'unknown'; + +export interface RepoPage { + repositories: Repo[]; +} + +export interface Repo { + contact: string; + creationDate: string; + description: string; + lastModified?: string; + namespace: string; + name: string; + type: RepoType; + archived: boolean; + exporting: boolean; + healthCheckRunning: boolean; + _links: Links; +} + +export type RepoType = 'git' | 'svn' | 'hg'; + +export interface Comment { + id: number; + body: string; +} + +export interface Label { + id: number; + name: string; + description: string; + color: string; +} + +export interface Branch { + name: string; + commit: Commit; +} + +export interface Commit { + id: string; + author: User; +} + +export interface CommitStatus { + id: number; + description: string; +} + +export type PrFilterByState = 'open' | 'closed' | '!open' | 'all'; diff --git a/lib/modules/platform/scmm/utils.spec.ts b/lib/modules/platform/scmm/utils.spec.ts new file mode 100644 index 00000000000000..34fec84fc7988c --- /dev/null +++ b/lib/modules/platform/scmm/utils.spec.ts @@ -0,0 +1,175 @@ +import type { MergeStrategy } from '../../../config/types'; +import type { GitUrlOption, Pr } from '../types'; +import type { PrFilterByState, Repo } from './types'; +import { getMergeMethod, getRepoUrl, matchPrState, smartLinks } from './utils'; + +describe('modules/platform/scmm/utils', () => { + describe(getMergeMethod, () => { + it.each([ + [undefined, null], + ['auto', null], + ['fast-forward', 'FAST_FORWARD_IF_POSSIBLE'], + ['merge-commit', 'MERGE_COMMIT'], + ['rebase', 'REBASE'], + ['squash', 'SQUASH'], + ])( + 'map merge strategy %p on pr merge method %p', + (strategy: string | undefined, method: string | null) => { + expect(getMergeMethod(strategy as MergeStrategy)).toEqual(method); + }, + ); + }); + + describe(smartLinks, () => { + it.each([ + ['', ''], + ['](../pull/', '](pulls/'], + ])('adjust %p to smart link %p', (body: string, result: string) => { + expect(smartLinks(body)).toEqual(result); + }); + }); + + describe(matchPrState, () => { + const defaultPr: Pr = { + sourceBranch: 'feature/test', + createdAt: '2023-08-02T10:48:24.762Z', + number: 1, + state: '', + title: 'Feature Test PR', + isDraft: false, + }; + + it.each([ + [{ ...defaultPr, state: 'OPEN' }, 'all', true], + [{ ...defaultPr, state: 'DRAFT' }, 'all', true], + [{ ...defaultPr, state: 'MERGED' }, 'all', true], + [{ ...defaultPr, state: 'REJECTED' }, 'all', true], + [{ ...defaultPr, state: 'OPEN' }, 'open', true], + [{ ...defaultPr, state: 'DRAFT' }, 'open', true], + [{ ...defaultPr, state: 'MERGED' }, 'open', false], + [{ ...defaultPr, state: 'REJECTED' }, 'open', false], + [{ ...defaultPr, state: 'OPEN' }, '!open', false], + [{ ...defaultPr, state: 'DRAFT' }, '!open', false], + [{ ...defaultPr, state: 'MERGED' }, '!open', true], + [{ ...defaultPr, state: 'REJECTED' }, '!open', true], + [{ ...defaultPr, state: 'OPEN' }, 'closed', false], + [{ ...defaultPr, state: 'DRAFT' }, 'closed', false], + [{ ...defaultPr, state: 'MERGED' }, 'closed', true], + [{ ...defaultPr, state: 'REJECTED' }, 'closed', true], + ])( + 'match scm pr %p state to pr filter by state %p', + (pr: Pr, state: string, result: boolean) => { + expect(matchPrState(pr, state as PrFilterByState)).toEqual(result); + }, + ); + }); + + describe(getRepoUrl, () => { + const repo: Repo = { + contact: 'test@test.com', + creationDate: '2023-08-02T10:48:24.762Z', + description: 'Default Repo', + lastModified: '2023-08-10T10:48:24.762Z', + namespace: 'default', + name: 'repo', + type: 'git', + archived: false, + exporting: false, + healthCheckRunning: false, + _links: {}, + }; + + const username = 'tzerr'; + const password = 'password'; + const gitHttpEndpoint = 'http://localhost:8081/scm/repo/default/repo'; + const gitSshEndpoint = 'ssh://localhost:2222/scm/repo/default/repo'; + + it.each([['ssh'], ['default'], ['endpoint'], [undefined]])( + 'should throw error for option %p, because protocol links are missing', + (gitUrl: string | undefined) => { + expect(() => + getRepoUrl(repo, gitUrl as GitUrlOption, username, password), + ).toThrow('MISSING_PROTOCOL_LINKS'); + }, + ); + + it('should throw error because of missing ssh link', () => { + expect(() => + getRepoUrl( + { + ...repo, + _links: { protocol: [{ name: 'http', href: gitHttpEndpoint }] }, + }, + 'ssh', + username, + password, + ), + ).toThrow('MISSING_SSH_LINK'); + }); + + it('should use the provided ssh link', () => { + expect( + getRepoUrl( + { + ...repo, + _links: { protocol: [{ name: 'ssh', href: gitSshEndpoint }] }, + }, + 'ssh', + username, + password, + ), + ).toEqual(gitSshEndpoint); + }); + + it.each([['endpoint'], ['default'], [undefined]])( + 'should throw error because of missing http link, for option %p', + (gitUrl: string | undefined) => { + expect(() => + getRepoUrl( + { + ...repo, + _links: { protocol: [{ name: 'ssh', href: gitSshEndpoint }] }, + }, + gitUrl as GitUrlOption | undefined, + username, + password, + ), + ).toThrow('MISSING_HTTP_LINK'); + }, + ); + + it.each([['endpoint'], ['default'], [undefined]])( + 'should throw error because of malformed http link, with option %p', + (gitUrl: string | undefined) => { + expect(() => + getRepoUrl( + { + ...repo, + _links: { protocol: [{ name: 'http', href: 'invalid url' }] }, + }, + gitUrl as GitUrlOption | undefined, + username, + password, + ), + ).toThrow('MALFORMED_HTTP_LINK'); + }, + ); + + it.each([['endpoint'], ['default'], [undefined]])( + 'should provide the http link with username, for option %p', + (gitUrl: string | undefined) => { + expect( + getRepoUrl( + { + ...repo, + _links: { protocol: [{ name: 'http', href: gitHttpEndpoint }] }, + }, + gitUrl as GitUrlOption | undefined, + username, + password, + ), + ).toBe('http://tzerr:password@localhost:8081/scm/repo/default/repo'); + }, + ); + }); +}); diff --git a/lib/modules/platform/scmm/utils.ts b/lib/modules/platform/scmm/utils.ts new file mode 100644 index 00000000000000..df94fa872e1567 --- /dev/null +++ b/lib/modules/platform/scmm/utils.ts @@ -0,0 +1,101 @@ +import type { MergeStrategy } from '../../../config/types'; +import { logger } from '../../../logger'; +import { regEx } from '../../../util/regex'; +import { parseUrl } from '../../../util/url'; +import type { GitUrlOption, Pr } from '../types'; +import type { Link, PRMergeMethod, PrFilterByState, Repo } from './types'; + +export function mapPrState( + state: 'open' | 'closed' | undefined, +): 'OPEN' | 'REJECTED' | undefined { + switch (state) { + case 'open': + return 'OPEN'; + case 'closed': + return 'REJECTED'; + default: + return undefined; + } +} + +export function matchPrState(pr: Pr, state: PrFilterByState): boolean { + if (state === 'all') { + return true; + } + + if (state === 'open' && (pr.state === 'OPEN' || pr.state === 'DRAFT')) { + return true; + } + + if (state === '!open' && (pr.state === 'MERGED' || pr.state === 'REJECTED')) { + return true; + } + + if ( + state === 'closed' && + (pr.state === 'MERGED' || pr.state === 'REJECTED') + ) { + return true; + } + + return false; +} + +export function smartLinks(body: string): string { + return body.replace(regEx(/\]\(\.\.\/pull\//g), '](pulls/'); +} + +export function getRepoUrl( + repo: Repo, + gitUrl: GitUrlOption | undefined, + username: string, + password: string, +): string { + const protocolLinks = repo._links.protocol as Link[] | undefined; + if (!protocolLinks) { + throw new Error('MISSING_PROTOCOL_LINKS'); + } + + if (gitUrl === 'ssh') { + const sshUrl = protocolLinks.find((l) => l.name === 'ssh')?.href; + if (!sshUrl) { + throw new Error('MISSING_SSH_LINKS'); + } + + logger.debug(`Using SSH URL: ${sshUrl}`); + return sshUrl; + } + + const httpUrl = protocolLinks.find((l) => l.name === 'http')?.href; + if (!httpUrl) { + throw new Error('MISSING_HTTP_LINK'); + } + + logger.debug(`Using HTTP URL: ${httpUrl}`); + + const repoUrl = parseUrl(httpUrl); + if (!repoUrl) { + throw new Error('MALFORMED_HTTP_LINK'); + } + + repoUrl.username = username; + repoUrl.password = password; + return repoUrl.toString(); +} + +export function getMergeMethod( + strategy: MergeStrategy | undefined, +): PRMergeMethod | null { + switch (strategy) { + case 'fast-forward': + return 'FAST_FORWARD_IF_POSSIBLE'; + case 'merge-commit': + return 'MERGE_COMMIT'; + case 'rebase': + return 'REBASE'; + case 'squash': + return 'SQUASH'; + default: + return null; + } +} diff --git a/package.json b/package.json index 41d638da99ab73..00ecb1ed2f2d3e 100644 --- a/package.json +++ b/package.json @@ -176,6 +176,7 @@ "aggregate-error": "3.1.0", "auth-header": "1.0.0", "aws4": "1.12.0", + "axios": "1.6.5", "azure-devops-node-api": "12.3.0", "bunyan": "1.8.15", "cacache": "18.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ee46cb29382ef..fc7c5d759fea5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: aws4: specifier: 1.12.0 version: 1.12.0 + axios: + specifier: 1.6.5 + version: 1.6.5 azure-devops-node-api: specifier: 12.3.0 version: 12.3.0 @@ -4526,6 +4529,10 @@ packages: safer-buffer: 2.1.2 dev: false + /asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: false + /auth-header@1.0.0: resolution: {integrity: sha512-CPPazq09YVDUNNVWo4oSPTQmtwIzHusZhQmahCKvIsk0/xH6U3QsMAv3sM+7+Q0B1K2KJ/Q38OND317uXs4NHA==} dev: false @@ -4546,6 +4553,16 @@ packages: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} dev: false + /axios@1.6.5: + resolution: {integrity: sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==} + dependencies: + follow-redirects: 1.15.5 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /azure-devops-node-api@12.3.0: resolution: {integrity: sha512-5HDhBFIXJxiFhcJ+A3hN87gwo92PrDNLJvcvRHjr+p7AsuUSF64yQU+M6wcBDczkIDVV7m+MrraKQ2tqUxSbCA==} dependencies: @@ -4973,6 +4990,13 @@ packages: /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + /combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + dev: false + /commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -5257,6 +5281,11 @@ packages: has-property-descriptors: 1.0.1 object-keys: 1.1.1 + /delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dev: false + /deprecation@2.3.1: resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} @@ -6098,6 +6127,16 @@ packages: resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} dev: true + /follow-redirects@1.15.5: + resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -6118,6 +6157,15 @@ packages: cross-spawn: 7.0.3 signal-exit: 4.1.0 + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + /from2@2.3.0: resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} dependencies: @@ -8134,6 +8182,18 @@ packages: braces: 3.0.2 picomatch: 2.3.1 + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + /mime@4.0.1: resolution: {integrity: sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==} engines: {node: '>=16'} @@ -9103,6 +9163,10 @@ packages: resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} dev: false + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: false + /pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} dependencies: diff --git a/readme.md b/readme.md index 2d08611e813b30..2e7bfd953c5fe0 100644 --- a/readme.md +++ b/readme.md @@ -40,6 +40,7 @@ Renovate works on these platforms: - [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/) +- [SCM-Manager](https://scm-manager.org/) ## Who Uses Renovate?