This commit is contained in:
zT-1337 2025-01-08 13:59:52 +01:00 committed by GitHub
commit acd2645447
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2126 additions and 9 deletions

View file

@ -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 |

View file

@ -32,9 +32,9 @@ If you're self hosting Renovate, use the latest release if possible.
## Renovate core features not supported on all platforms
| Feature | Platforms which lack feature | See Renovate issue(s) |
| --------------------- | ----------------------------------------------- | ------------------------------------------------------------ |
| Dependency Dashboard | Azure, Bitbucket, Bitbucket Server, Gerrit | [#9592](https://github.com/renovatebot/renovate/issues/9592) |
| The Mend Renovate App | Azure, Bitbucket Server, Forgejo, Gitea, GitLab | |
| --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| Dependency Dashboard | Azure, Bitbucket, Bitbucket Server, Gerrit, SCM-Manager | [#9592](https://github.com/renovatebot/renovate/issues/9592) |
| The Mend Renovate App | Azure, Bitbucket Server, Forgejo, Gitea, GitLab, SCM-Manager | |
## Major platform features not supported by Renovate

View file

@ -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/scm-manager/index.md)
### GitHub.com token for changelogs

View file

@ -408,7 +408,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', 'scm-manager'],
},
{
name: 'dryRun',
@ -918,7 +918,12 @@ const options: RenovateOptions[] = [
description: 'Username for authentication.',
stage: 'repository',
type: 'string',
supportedPlatforms: ['azure', 'bitbucket', 'bitbucket-server'],
supportedPlatforms: [
'azure',
'bitbucket',
'bitbucket-server',
'scm-manager',
],
globalOnly: true,
},
{
@ -2896,7 +2901,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', 'scm-manager'],
allowedValues: ['default', 'ssh', 'endpoint'],
default: 'default',
stage: 'repository',

View file

@ -26,6 +26,7 @@ const resolvers = {
github,
gitlab,
local: null,
'scm-manager': null,
} satisfies Record<PlatformId, Resolver | null>;
export function getPreset({

View file

@ -8,6 +8,7 @@ export const PLATFORM_HOST_TYPES = [
'github',
'gitlab',
'local',
'scm-manager',
] as const;
export type PlatformId = (typeof PLATFORM_HOST_TYPES)[number];

View file

@ -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 './scm-manager';
import type { Platform } from './types';
const api = new Map<PlatformId, Platform>();
@ -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);

View file

@ -0,0 +1,650 @@
import * as httpMock from '../../../../test/http-mock';
import { git, mocked } from '../../../../test/util';
import * as hostRules from '../../../util/host-rules';
import type { Pr } from '../types';
import * as _util from '../util';
import { mapPrFromScmToRenovate } from './mapper';
import type { PrFilterByState, PullRequest, Repo, User } from './types';
import {
addAssignees,
addReviewers,
createPr,
deleteLabel,
ensureCommentRemoval,
ensureIssue,
ensureIssueClosing,
findIssue,
findPr,
getBranchPr,
getBranchStatus,
getBranchStatusCheck,
getIssueList,
getJsonFile,
getPr,
getPrList,
getRawFile,
getRepoForceRebase,
getRepos,
initPlatform,
initRepo,
invalidatePrCache,
massageMarkdown,
maxBodyLength,
mergePr,
setBranchStatus,
updatePr,
} from './index';
jest.mock('../../../util/git');
jest.mock('../util');
const util: jest.Mocked<typeof _util> = mocked(_util);
const endpoint = 'https://localhost:8080/scm/api/v2';
const token = 'TEST_TOKEN';
const user: User = {
mail: 'test@user.de',
displayName: 'Test User',
name: '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: 'https://localhost:8080/scm/default/repo' },
],
defaultBranch: {
href: 'https://localhost:8080/scm/api/v2/config/git/default/repo/default-branch',
},
},
};
const pullRequest: PullRequest = {
id: '1',
author: { displayName: 'Thomas Zerr', id: '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/scm-manager/index', () => {
beforeEach(() => {
jest.resetAllMocks();
hostRules.add({ token, username: user.name });
invalidatePrCache();
});
describe(initPlatform, () => {
it('should throw error, when endpoint is not configured', async () => {
await expect(initPlatform({ token })).rejects.toThrow(
'SCM-Manager endpoint not configured',
);
});
it('should throw error, when token is not configured', async () => {
await expect(initPlatform({ endpoint })).rejects.toThrow(
'SCM-Manager API token not configured',
);
});
it('should throw error, when token is invalid', async () => {
httpMock.scope(endpoint).get(`/me`).reply(401);
await expect(
initPlatform({ endpoint, token: 'invalid' }),
).rejects.toThrow('Init: Authentication failure');
});
it('should init platform', async () => {
httpMock.scope(endpoint).get('/me').reply(200, user);
expect(await initPlatform({ endpoint, token })).toEqual({
endpoint,
gitAuthor: 'Test User <test@user.de>',
});
});
});
describe(initRepo, () => {
it('should init repo', async () => {
const repository = `${repo.namespace}/${repo.name}`;
const expectedFingerprint = 'expectedFingerprint';
const expectedDefaultBranch = 'expectedDefaultBranch';
httpMock
.scope(endpoint)
.get(`/repositories/${repository}`)
.reply(200, repo);
httpMock
.scope(endpoint)
.get(`/config/git/${repository}/default-branch`)
.reply(200, { defaultBranch: expectedDefaultBranch });
util.repoFingerprint.mockReturnValueOnce(expectedFingerprint);
expect(
await initRepo({ repository: `${repo.namespace}/${repo.name}` }),
).toEqual({
defaultBranch: expectedDefaultBranch,
isFork: false,
repoFingerprint: expectedFingerprint,
});
expect(git.initRepo).toHaveBeenCalledWith({
url: `https://${user.name}:${token}@localhost:8080/scm/default/repo`,
repository,
defaultBranch: expectedDefaultBranch,
});
});
});
describe(getRepos, () => {
it('should return all available repos', async () => {
httpMock
.scope(endpoint)
.get(`/repositories?pageSize=1000000`)
.reply(200, {
page: 0,
pageTotal: 1,
_embedded: {
repositories: [
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 () => {
httpMock
.scope(endpoint)
.get(
`/pull-requests/${repo.namespace}/${repo.name}?status=ALL&pageSize=1000000`,
)
.reply(200, {
page: 0,
pageTotal: 1,
_embedded: {
pullRequests: [],
},
});
expect(await getPrList()).toIncludeAllMembers([]);
});
it('should return empty array, because API request failed', async () => {
httpMock
.scope(endpoint)
.get(
`/pull-requests/${repo.namespace}/${repo.name}?status=ALL&pageSize=1000000`,
)
.reply(400);
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: [],
},
];
httpMock
.scope(endpoint)
.get(
`/pull-requests/${repo.namespace}/${repo.name}?status=ALL&pageSize=1000000`,
)
.reply(200, {
page: 0,
pageTotal: 1,
_embedded: {
pullRequests: [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 () => {
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 findPr({
branchName: pullRequest.source,
prTitle: pullRequest.title,
}),
).toEqual(renovatePr);
});
it.each`
availablePullRequest | branchName | prTitle | state | result
${[]} | ${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 available pull requests for branch name "$branchName", pr title "$prTitle" and state "$state" with result $result ',
async ({
availablePullRequest,
branchName,
prTitle,
state,
result,
}: {
availablePullRequest: PullRequest[];
branchName: string;
prTitle: string | undefined | null;
state: string;
result: Pr | null;
}) => {
httpMock
.scope(endpoint)
.get(
`/pull-requests/${repo.namespace}/${repo.name}?status=ALL&pageSize=1000000`,
)
.reply(200, {
page: 0,
pageTotal: 1,
_embedded: {
pullRequests: availablePullRequest,
},
});
expect(
await findPr({
branchName,
prTitle,
state: state as PrFilterByState,
}),
).toEqual(result);
},
);
});
describe(getBranchPr, () => {
it.each`
availablePullRequest | branchName | result
${[]} | ${pullRequest.source} | ${null}
${[pullRequest]} | ${'invalid branchName'} | ${null}
${[pullRequest]} | ${pullRequest.source} | ${renovatePr}
`(
'search within available pull requests for branch name "$branchName" with result $result',
async ({
availablePullRequest,
branchName,
result,
}: {
availablePullRequest: PullRequest[];
branchName: string;
result: Pr | null;
}) => {
httpMock
.scope(endpoint)
.get(
`/pull-requests/${repo.namespace}/${repo.name}?status=ALL&pageSize=1000000`,
)
.reply(200, {
page: 0,
pageTotal: 1,
_embedded: {
pullRequests: availablePullRequest,
},
});
expect(await getBranchPr(branchName)).toEqual(result);
},
);
});
describe(getPr, () => {
it('should return null, because PR was not found', async () => {
httpMock
.scope(endpoint)
.get(
`/pull-requests/${repo.namespace}/${repo.name}?status=ALL&pageSize=1000000`,
)
.reply(200, {
page: 0,
pageTotal: 1,
_embedded: {
pullRequests: [],
},
});
httpMock
.scope(endpoint)
.get(`/pull-requests/${repo.namespace}/${repo.name}/${pullRequest.id}`)
.reply(404);
expect(await getPr(1)).toBeNull();
});
it('should return PR from cache', 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 getPr(parseInt(pullRequest.id))).toEqual(renovatePr);
});
it('should return fetched pr', async () => {
httpMock
.scope(endpoint)
.get(
`/pull-requests/${repo.namespace}/${repo.name}?status=ALL&pageSize=1000000`,
)
.reply(200, {
page: 0,
pageTotal: 1,
_embedded: {
pullRequests: [],
},
});
httpMock
.scope(endpoint)
.get(`/pull-requests/${repo.namespace}/${repo.name}/${pullRequest.id}`)
.reply(200, pullRequest);
expect(await getPr(parseInt(pullRequest.id))).toEqual(renovatePr);
});
});
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,
) => {
httpMock
.scope(endpoint)
.post(`/pull-requests/${repo.namespace}/${repo.name}`)
.reply(201, undefined, {
location: `${endpoint}/pull-requests/${repo.namespace}/${repo.name}/1337`,
});
httpMock
.scope(endpoint)
.get(`/pull-requests/${repo.namespace}/${repo.name}/1337`)
.reply(200, {
id: '1337',
source: 'feature/test',
target: 'develop',
title: 'PR Title',
description: 'PR Body',
creationDate: '2023-01-01T13:37:00.000Z',
status: draftPR ? 'DRAFT' : '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,
) => {
httpMock
.scope(endpoint)
.get(
`/pull-requests/${repo.namespace}/${repo.name}/${pullRequest.id}`,
)
.reply(200, pullRequest);
httpMock
.scope(endpoint)
.put(`/pull-requests/${repo.namespace}/${repo.name}/1`)
.reply(204);
await updatePr({
number: 1,
prTitle: 'PR Title',
prBody: actualPrBody,
state: actualState as 'open' | 'closed' | undefined,
targetBranch: 'Target/Branch',
});
expect(httpMock.allUsed()).toBeTrue();
},
);
});
describe(mergePr, () => {
it('should Not implemented and return false', async () => {
const result = await mergePr({ id: 1 });
expect(result).toBeFalse();
});
});
describe(getBranchStatus, () => {
it('should Not implemented and return red', async () => {
const result = await getBranchStatus('test/branch', false);
expect(result).toBe('red');
});
});
describe(setBranchStatus, () => {
it('should Not implemented', async () => {
await expect(
setBranchStatus({
branchName: 'test/branch',
context: 'context',
description: 'description',
state: 'red',
}),
).resolves.not.toThrow();
});
});
describe(getBranchStatusCheck, () => {
it('should Not implemented and return null', async () => {
const result = await getBranchStatusCheck('test/branch', null);
expect(result).toBeNull();
});
});
describe(addReviewers, () => {
it('should Not implemented', async () => {
await expect(addReviewers(1, ['reviewer'])).resolves.not.toThrow();
});
});
describe(addAssignees, () => {
it('should Not implemented', async () => {
await expect(addAssignees(1, ['assignee'])).resolves.not.toThrow();
});
});
describe(deleteLabel, () => {
it('should Not implemented', async () => {
await expect(deleteLabel(1, 'label')).resolves.not.toThrow();
});
});
describe(getIssueList, () => {
it('should Not implemented and return empty list', async () => {
const result = await getIssueList();
expect(result).toEqual([]);
});
});
describe(findIssue, () => {
it('should Not implemented and return null', async () => {
const result = await findIssue('issue');
expect(result).toBeNull();
});
});
describe(ensureIssue, () => {
it('should Not implemented and return null', async () => {
const result = await ensureIssue({ title: 'issue', body: 'body' });
expect(result).toBeNull();
});
});
describe(ensureIssueClosing, () => {
it('should Not implemented', async () => {
await expect(ensureIssueClosing('issue')).resolves.not.toThrow();
});
});
describe(ensureCommentRemoval, () => {
it('should Not implemented', async () => {
await expect(
ensureCommentRemoval({
type: 'by-content',
number: 1,
content: 'content',
}),
).resolves.not.toThrow();
});
});
describe(massageMarkdown, () => {
it('should adjust smart link for Pull Requests', () => {
const result = massageMarkdown('[PR](../pull/1)');
expect(result).toBe('[PR](pulls/1)');
});
});
describe(getRepoForceRebase, () => {
it('should Not implemented and return false', async () => {
const result = await getRepoForceRebase();
expect(result).toBeFalse();
});
});
describe(getRawFile, () => {
it('should Not implemented and return null', async () => {
const result = await getRawFile('file');
expect(result).toBeNull();
});
});
describe(getJsonFile, () => {
it('should Not implemented and return undefined', async () => {
const result = await getJsonFile('package.json');
expect(result).toBeUndefined();
});
});
describe(maxBodyLength, () => {
it('should return the max body length allowed for an SCM-Manager request body', () => {
expect(maxBodyLength()).toBe(200000);
});
});
});

View file

@ -0,0 +1,336 @@
import { logger } from '../../../logger';
import type { BranchStatus } from '../../../types';
import * as git from '../../../util/git';
import ScmManagerHttp from '../../../util/http/scm-manager';
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 { getRepoUrl, mapPrState, matchPrState, smartLinks } from './utils';
interface SCMMRepoConfig {
repository: string;
prList: Pr[] | null;
defaultBranch: string;
}
export const id = 'scm-manager';
let config: SCMMRepoConfig = {} as any;
let scmManagerHttp: ScmManagerHttp;
export async function initPlatform({
endpoint,
token,
}: PlatformParams): Promise<PlatformResult> {
if (!endpoint) {
throw new Error('SCM-Manager endpoint not configured');
}
if (!token) {
throw new Error('SCM-Manager API token not configured');
}
scmManagerHttp = new ScmManagerHttp(endpoint, token);
try {
const me = await scmManagerHttp.getCurrentUser();
const gitAuthor = `${me.displayName} <${me.mail}>`;
const result = { endpoint, gitAuthor };
logger.info(`Plattform initialized ${JSON.stringify(result)}`);
return result;
} catch (err) {
logger.debug(
{ err },
'Error authenticating with SCM-Manager. Check your token',
);
throw new Error('Init: Authentication failure');
}
}
export async function initRepo({
repository,
gitUrl,
}: RepoParams): Promise<RepoResult> {
const repo = await scmManagerHttp.getRepo(repository);
const defaultBranch = await scmManagerHttp.getDefaultBranch(repo);
const url = getRepoUrl(repo, gitUrl, scmManagerHttp.getEndpoint());
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,
scmManagerHttp.getEndpoint(),
),
};
logger.trace({ result }, `Repo initialized`);
return result;
}
export async function getRepos(): Promise<string[]> {
const repos = (await scmManagerHttp.getAllRepos()).filter(
(repo) => repo.type === 'git',
);
const result = repos.map((repo) => `${repo.namespace}/${repo.name}`);
logger.debug(`Discovered ${repos.length} repos`);
return result;
}
export async function getBranchPr(branchName: string): Promise<Pr | null> {
return await findPr({ branchName, state: 'open' });
}
export async function findPr({
branchName,
prTitle,
state = 'all',
}: FindPRConfig): Promise<Pr | null> {
const inProgressPrs = await getPrList();
const result = inProgressPrs.find(
(pr) =>
branchName === pr.sourceBranch &&
(!prTitle || prTitle === pr.title) &&
matchPrState(pr, state),
);
if (result) {
logger.trace({ result }, `Found PR`);
return result;
}
logger.trace(
`Could not find PR with source branch ${branchName} and title ${
prTitle ?? ''
} and state ${state}`,
);
return null;
}
export async function getPr(number: number): Promise<Pr | null> {
const inProgressPrs = await getPrList();
const cachedPr = inProgressPrs.find((pr) => pr.number === number);
if (cachedPr) {
logger.trace('Returning from cached PRs');
return cachedPr;
}
try {
const result = await scmManagerHttp.getRepoPr(config.repository, number);
logger.trace('Returning PR from API');
return mapPrFromScmToRenovate(result);
} catch (error) {
logger.error({ error }, `Can not find a PR with id ${number}`);
return null;
}
}
export async function getPrList(): Promise<Pr[]> {
if (config.prList === null) {
try {
config.prList = (
await scmManagerHttp.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<Pr> {
const createdPr = await scmManagerHttp.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}'`,
);
return mapPrFromScmToRenovate(createdPr);
}
export async function updatePr({
number,
prTitle,
prBody,
state,
targetBranch,
}: UpdatePrConfig): Promise<void> {
await scmManagerHttp.updatePr(config.repository, number, {
title: prTitle,
description: sanitize(prBody) ?? undefined,
target: targetBranch,
status: mapPrState(state),
});
logger.info(`Updated PR #${number} with title ${prTitle}`);
}
export function mergePr(config: MergePRConfig): Promise<boolean> {
logger.debug('Not implemented mergePr');
return Promise.resolve(false);
}
export function getBranchStatus(
branchName: string,
internalChecksAsSuccess: boolean,
): Promise<BranchStatus> {
logger.debug('Not implemented getBranchStatus');
return Promise.resolve('red');
}
export function setBranchStatus(
branchStatusConfig: BranchStatusConfig,
): Promise<void> {
logger.debug('Not implemented setBranchStatus');
return Promise.resolve();
}
export function getBranchStatusCheck(
branchName: string,
context: string | null | undefined,
): Promise<BranchStatus | null> {
logger.debug('Not implemented setBranchStatus');
return Promise.resolve(null);
}
export function addReviewers(
number: number,
reviewers: string[],
): Promise<void> {
logger.debug('Not implemented addReviewers');
return Promise.resolve();
}
export function addAssignees(
number: number,
assignees: string[],
): Promise<void> {
logger.debug('Not implemented addAssignees');
return Promise.resolve();
}
export function deleteLabel(number: number, label: string): Promise<void> {
logger.debug('Not implemented deleteLabel');
return Promise.resolve();
}
export function getIssueList(): Promise<Issue[]> {
logger.debug('Not implemented getIssueList');
return Promise.resolve([]);
}
export function findIssue(title: string): Promise<Issue | null> {
logger.debug('Not implemented findIssue');
return Promise.resolve(null);
}
export function ensureIssue(
config: EnsureIssueConfig,
): Promise<'updated' | 'created' | null> {
logger.debug('Not implemented ensureIssue');
return Promise.resolve(null);
}
export function ensureIssueClosing(title: string): Promise<void> {
logger.debug('Not implemented ensureIssueClosing');
return Promise.resolve();
}
/* istanbul ignore next */
export function ensureComment(config: EnsureCommentConfig): Promise<boolean> {
logger.debug('Not implemented ensureComment');
return Promise.resolve(false);
}
export function ensureCommentRemoval(
ensureCommentRemoval:
| EnsureCommentRemovalConfigByTopic
| EnsureCommentRemovalConfigByContent,
): Promise<void> {
logger.debug('Not implemented ensureCommentRemoval');
return Promise.resolve();
}
export function massageMarkdown(prBody: string): string {
return smartTruncate(smartLinks(prBody), maxBodyLength());
}
export function getRepoForceRebase(): Promise<boolean> {
return Promise.resolve(false);
}
export function getRawFile(
fileName: string,
repoName?: string,
branchOrTag?: string,
): Promise<string | null> {
logger.debug('Not implemented getRawFile');
return Promise.resolve(null);
}
export function getJsonFile(
fileName: string,
repoName?: string,
branchOrTag?: string,
): Promise<any> {
logger.debug('Not implemented getJsonFile');
return Promise.resolve(undefined);
}
export function maxBodyLength(): number {
return 200000;
}
/* istanbul ignore next */
export function invalidatePrCache(): void {
config.prList = null;
}

View file

@ -0,0 +1,44 @@
import { mapPrFromScmToRenovate } from './mapper';
import type { PullRequest as SCMPullRequest } from './types';
describe('modules/platform/scm-manager/mapper', () => {
it('should correctly map the scm-manager 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,
});
});
});

View file

@ -0,0 +1,23 @@
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 ? pr.closeDate : undefined,
hasAssignees:
pr.reviewer !== undefined &&
pr.reviewer !== null &&
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',
};
}

View file

@ -0,0 +1,30 @@
# SCM-Manager
Renovate supports the [SCM-Manager](https://scm-manager.org) platform.
## Authentication
1. Create an API Key for your technical Renovate user in SCM-Manager
1. The technical user _must_ have a valid name and email address
1. Put the API key in the `RENOVATE_TOKEN` environment variable, so that Renovate can use it
## Set correct platform
You must set the [`platform`](../../../self-hosted-configuration.md#platform) config option to `scm-manager` in your Renovate config file.
## Set permissions
The technical user must have permission to read and write to your repository.
You can do this by granting the permission role "OWNER" to the technical Renovate user.
## Install Review Plugin
To let Renovate access the Pull Request API, you must install the Review Plugin.
Find the list of available plugins by going to to Administration -> Plugins -> Available.
## Supported versions of SCM-Manager
Renovate supports SCM-Manager major version `2.x` and `3.x`.
The minimum version for the `2.x` range is `2.48.0`.
The minimum version for the `3.x` range is `3.0.0`.

View file

@ -0,0 +1,114 @@
import { z } from 'zod';
export const UserSchema = z.object({
mail: z.string().optional().nullable(),
displayName: z.string(),
name: z.string(),
});
export const DefaultBranchSchema = z.object({
defaultBranch: z.string(),
});
export const LinkSchema = z.object({
href: z.string(),
name: z.string().optional().nullable(),
templated: z.boolean().optional().nullable(),
});
export const LinksSchema = z.record(
z.string(),
z.union([LinkSchema, z.array(LinkSchema)]),
);
export const PrStateSchema = z.enum(['DRAFT', 'OPEN', 'REJECTED', 'MERGED']);
export const PrMergeMethodSchema = z.enum([
'MERGE_COMMIT',
'REBASE',
'FAST_FORWARD_IF_POSSIBLE',
'SQUASH',
]);
export const PullRequestSchema = z.object({
id: z.string(),
author: z
.object({
mail: z.string().optional().nullable(),
displayName: z.string(),
id: z.string(),
})
.optional()
.nullable(),
reviser: z
.object({
id: z.string().optional().nullable(),
displayName: z.string().optional().nullable(),
})
.optional()
.nullable(),
closeDate: z.string().optional().nullable(),
source: z.string(),
target: z.string(),
title: z.string(),
description: z.string().optional().nullable(),
creationDate: z.string(),
lastModified: z.string().optional().nullable(),
status: PrStateSchema,
reviewer: z
.array(
z.object({
id: z.string(),
displayName: z.string(),
mail: z.string().optional().nullable(),
approved: z.boolean(),
}),
)
.optional()
.nullable(),
labels: z.string().array(),
tasks: z.object({
todo: z.number(),
done: z.number(),
}),
_links: LinksSchema,
_embedded: z.object({
defaultConfig: z.object({
mergeStrategy: PrMergeMethodSchema,
deleteBranchOnMerge: z.boolean(),
}),
}),
});
const RepoTypeSchema = z.enum(['git', 'svn', 'hg']);
export const RepoSchema = z.object({
contact: z.string().optional().nullable(),
creationDate: z.string().optional().nullable(),
description: z.string().optional().nullable(),
lastModified: z.string().optional().nullable(),
namespace: z.string(),
name: z.string(),
type: RepoTypeSchema,
archived: z.boolean(),
exporting: z.boolean(),
healthCheckRunning: z.boolean(),
_links: LinksSchema,
});
const PagedSchema = z.object({
page: z.number(),
pageTotal: z.number(),
});
export const PagedPullRequestSchema = PagedSchema.extend({
_embedded: z.object({
pullRequests: z.array(PullRequestSchema),
}),
});
export const PagedRepoSchema = PagedSchema.extend({
_embedded: z.object({
repositories: z.array(RepoSchema),
}),
});

View file

@ -0,0 +1,37 @@
import type { z } from 'zod';
import type {
LinkSchema,
LinksSchema,
PrMergeMethodSchema,
PrStateSchema,
PullRequestSchema,
RepoSchema,
UserSchema,
} from './schema';
export type Link = z.infer<typeof LinkSchema>;
export type Links = z.infer<typeof LinksSchema>;
export type User = z.infer<typeof UserSchema>;
export interface PullRequestCreateParams extends PullRequestUpdateParams {
source: string;
target: string;
}
export interface PullRequestUpdateParams {
title: string;
description?: string;
status?: PrState;
target?: string;
}
export type PullRequest = z.infer<typeof PullRequestSchema>;
type PrState = z.infer<typeof PrStateSchema>;
export type PrMergeMethod = z.infer<typeof PrMergeMethodSchema>;
export type Repo = z.infer<typeof RepoSchema>;
export type PrFilterByState = 'open' | 'closed' | '!open' | 'all';

View file

@ -0,0 +1,224 @@
import type { MergeStrategy } from '../../../config/types';
import * as hostRules from '../../../util/host-rules';
import type { GitUrlOption, Pr } from '../types';
import type { PrFilterByState, Repo } from './types';
import { getMergeMethod, getRepoUrl, matchPrState, smartLinks } from './utils';
import { invalidatePrCache } from './index';
describe('modules/platform/scm-manager/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 endpoint = 'http://localhost:8081/scm/api/v2';
const gitHttpEndpoint = 'http://localhost:8081/scm/repo/default/repo';
const gitSshEndpoint = 'ssh://localhost:2222/scm/repo/default/repo';
beforeEach(() => {
hostRules.add({ token: 'token', username: 'tzerr' });
invalidatePrCache();
});
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, endpoint),
).toThrow('Missing protocol links.');
},
);
it('should throw error because of missing SSH link', () => {
expect(() =>
getRepoUrl(
{
...repo,
_links: { protocol: [{ name: 'http', href: gitHttpEndpoint }] },
},
'ssh',
endpoint,
),
).toThrow('MISSING_SSH_LINK');
});
it('should throw error because protocol links are not an array', () => {
expect(() =>
getRepoUrl(
{
...repo,
_links: { protocol: { name: 'http', href: gitHttpEndpoint } },
},
'ssh',
endpoint,
),
).toThrow('Expected protocol links to be an array of links.');
});
it('should use the provided ssh link', () => {
expect(
getRepoUrl(
{
...repo,
_links: { protocol: [{ name: 'ssh', href: gitSshEndpoint }] },
},
'ssh',
endpoint,
),
).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,
endpoint,
),
).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,
endpoint,
),
).toThrow('MALFORMED_HTTP_LINK');
},
);
it.each([['endpoint'], ['default'], [undefined]])(
'should use empty string, because username was not provided. With option %p',
(gitUrl: string | undefined) => {
hostRules.clear();
expect(
getRepoUrl(
{
...repo,
_links: { protocol: [{ name: 'http', href: gitHttpEndpoint }] },
},
gitUrl as GitUrlOption | undefined,
endpoint,
),
).toBe('http://localhost:8081/scm/repo/default/repo');
},
);
it.each([['endpoint'], ['default'], [undefined]])(
'should use empty string, because token was not provided. With option %p',
(gitUrl: string | undefined) => {
hostRules.clear();
hostRules.add({ username: 'tzerr' });
expect(
getRepoUrl(
{
...repo,
_links: { protocol: [{ name: 'http', href: gitHttpEndpoint }] },
},
gitUrl as GitUrlOption | undefined,
endpoint,
),
).toBe('http://tzerr@localhost:8081/scm/repo/default/repo');
},
);
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,
endpoint,
),
).toBe('http://tzerr:token@localhost:8081/scm/repo/default/repo');
},
);
});
});

View file

@ -0,0 +1,112 @@
import type { MergeStrategy } from '../../../config/types';
import { logger } from '../../../logger';
import * as hostRules from '../../../util/host-rules';
import { regEx } from '../../../util/regex';
import { parseUrl } from '../../../util/url';
import type { GitUrlOption, Pr } from '../types';
import type { PrFilterByState, PrMergeMethod, 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,
endpoint: string,
): string {
const protocolLinks = repo._links.protocol;
if (!protocolLinks) {
throw new Error('Missing protocol links.');
}
if (!Array.isArray(protocolLinks)) {
throw new Error('Expected protocol links to be an array of 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');
}
const hostOptions = hostRules.find({
hostType: 'scm-manager',
url: endpoint,
});
repoUrl.username = hostOptions.username ?? '';
repoUrl.password = hostOptions.token ?? '';
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;
}
}

View file

@ -17,6 +17,7 @@ platformScmImpls.set('gitea', DefaultGitScm);
platformScmImpls.set('github', GithubScm);
platformScmImpls.set('gitlab', DefaultGitScm);
platformScmImpls.set('local', LocalFs);
platformScmImpls.set('scm-manager', DefaultGitScm);
let _scm: PlatformScm | undefined;

View file

@ -0,0 +1,343 @@
import * as httpMock from '../../../test/http-mock';
import type {
PullRequest,
PullRequestCreateParams,
PullRequestUpdateParams,
Repo,
User,
} from '../../modules/platform/scm-manager/types';
import ScmManagerHttp from './scm-manager';
describe('util/http/scm-manager', () => {
const endpoint = 'http://localhost:8080/scm/api/v2';
const token = 'validApiToken';
const scmManagerHttp = new ScmManagerHttp(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', id: '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(scmManagerHttp.getEndpoint, () => {
it('should return the endpoint', () => {
expect(scmManagerHttp.getEndpoint()).toEqual(endpoint);
});
});
describe(scmManagerHttp.getCurrentUser, () => {
it('should return the current user', async () => {
const expectedUser: User = {
mail: 'test@test.de',
displayName: 'Test User',
name: 'test',
};
httpMock.scope(endpoint).get('/me').reply(200, expectedUser);
expect(await scmManagerHttp.getCurrentUser()).toEqual(expectedUser);
});
it.each([[401, 500]])(
'should throw %p response',
async (response: number) => {
httpMock.scope(endpoint).get('/me').reply(response);
await expect(scmManagerHttp.getCurrentUser()).rejects.toThrow();
},
);
});
describe(scmManagerHttp.getRepo, () => {
it('should return the repo', async () => {
httpMock
.scope(endpoint)
.get(`/repositories/${repo.namespace}/${repo.name}`)
.reply(200, repo);
expect(
await scmManagerHttp.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(
scmManagerHttp.getRepo(`${repo.namespace}/${repo.name}`),
).rejects.toThrow();
},
);
});
describe(scmManagerHttp.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 scmManagerHttp.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(scmManagerHttp.getAllRepos()).rejects.toThrow();
},
);
});
describe(scmManagerHttp.getDefaultBranch, () => {
it('should return the default branch', async () => {
httpMock
.scope(endpoint)
.get('/config/git/default/repo/default-branch')
.reply(200, {
defaultBranch: 'develop',
});
expect(await scmManagerHttp.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(scmManagerHttp.getDefaultBranch(repo)).rejects.toThrow();
},
);
});
describe(scmManagerHttp.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 scmManagerHttp.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(
scmManagerHttp.getAllRepoPrs(`${repo.namespace}/${repo.name}`),
).rejects.toThrow();
},
);
});
describe(scmManagerHttp.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 scmManagerHttp.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(
scmManagerHttp.getRepoPr(`${repo.namespace}/${repo.name}`, 1337),
).rejects.toThrow();
},
);
});
describe(scmManagerHttp.createPr, () => {
it('should create PR for a repo', async () => {
const expectedCreateParams: PullRequestCreateParams = {
source: 'feature/test',
target: 'develop',
title: 'Test Title',
description: 'PR description',
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 scmManagerHttp.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(
scmManagerHttp.createPr(`${repo.namespace}/${repo.name}`, {
source: 'feature/test',
target: 'develop',
title: 'Test Title',
description: 'PR description',
status: 'OPEN',
}),
).rejects.toThrow();
},
);
});
describe(scmManagerHttp.updatePr, () => {
it('should update PR for a repo', async () => {
const expectedUpdateParams: PullRequestUpdateParams = {
title: 'Test Title',
description: 'PR description',
status: 'OPEN',
target: 'new/target',
};
const expectedPrId = 1337;
httpMock
.scope(endpoint)
.get(`/pull-requests/${repo.namespace}/${repo.name}/${expectedPrId}`)
.reply(200, pullRequest);
httpMock
.scope(endpoint)
.put(`/pull-requests/${repo.namespace}/${repo.name}/${expectedPrId}`)
.reply(204);
await expect(
scmManagerHttp.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)
.get(`/pull-requests/${repo.namespace}/${repo.name}/${expectedPrId}`)
.reply(200, pullRequest);
httpMock
.scope(endpoint)
.put(`/pull-requests/${repo.namespace}/${repo.name}/${expectedPrId}`)
.reply(response);
await expect(
scmManagerHttp.updatePr(
`${repo.namespace}/${repo.name}`,
expectedPrId,
{
title: 'Test Title',
description: 'PR description',
status: 'OPEN',
},
),
).rejects.toThrow();
},
);
});
});

View file

@ -0,0 +1,193 @@
import {
DefaultBranchSchema,
PagedPullRequestSchema,
PagedRepoSchema,
PullRequestSchema,
RepoSchema,
UserSchema,
} from '../../modules/platform/scm-manager/schema';
import type {
Link,
PullRequest,
PullRequestCreateParams,
PullRequestUpdateParams,
Repo,
User,
} from '../../modules/platform/scm-manager/types';
import { resolveBaseUrl } from '../url';
import type { HttpOptions, HttpResponse, InternalHttpOptions } from './types';
import { Http } from './index';
const URLS = {
ME: 'me',
ALL_REPOS: 'repositories?pageSize=1000000',
REPO: (repoPath: string) => `repositories/${repoPath}`,
PULLREQUESTS: (repoPath: string) => `pull-requests/${repoPath}`,
PULLREQUESTS_WITH_PAGINATION: (repoPath: string) =>
`pull-requests/${repoPath}?status=ALL&pageSize=1000000`,
PULLREQUEST_BY_ID: (repoPath: string, id: number) =>
`pull-requests/${repoPath}/${id}`,
};
const CONTENT_TYPES = {
ME: 'application/vnd.scmm-me+json;v=2',
REPOSITORY: 'application/vnd.scmm-repository+json;v=2',
REPOSITORIES: 'application/vnd.scmm-repositoryCollection+json;v=2',
GIT_CONFIG: 'application/vnd.scmm-gitDefaultBranch+json;v=2',
PULLREQUEST: 'application/vnd.scmm-pullRequest+json;v=2',
PULLREQUESTS: 'application/vnd.scmm-pullRequestCollection+json;v=2',
};
export interface ScmManagerHttpOptions extends HttpOptions {
scmmContentType?: string;
}
export default class ScmManagerHttp extends Http<ScmManagerHttpOptions> {
private readonly endpoint: string;
constructor(endpoint: string, token: string) {
super('scm-manager', { throwHttpErrors: true, token });
this.endpoint = endpoint;
}
protected override async request<T>(
requestUrl: string | URL,
options?: InternalHttpOptions & ScmManagerHttpOptions,
): Promise<HttpResponse<T>> {
const opts = {
...options,
headers: {
...options?.headers,
accept: options?.scmmContentType,
},
};
return await super.request(resolveBaseUrl(this.endpoint, requestUrl), opts);
}
public getEndpoint(): string {
return this.endpoint;
}
public async getCurrentUser(): Promise<User> {
const response = await this.getJson(
URLS.ME,
{
scmmContentType: CONTENT_TYPES.ME,
},
UserSchema,
);
return response.body;
}
public async getRepo(repoPath: string): Promise<Repo> {
const response = await this.getJson(
URLS.REPO(repoPath),
{
scmmContentType: CONTENT_TYPES.REPOSITORY,
},
RepoSchema,
);
return response.body;
}
public async getAllRepos(): Promise<Repo[]> {
const response = await this.getJson(
URLS.ALL_REPOS,
{
scmmContentType: CONTENT_TYPES.REPOSITORIES,
},
PagedRepoSchema,
);
return response.body._embedded.repositories;
}
public async getDefaultBranch(repo: Repo): Promise<string> {
const defaultBranchUrl = repo._links['defaultBranch'] as Link;
const response = await this.getJson(
defaultBranchUrl.href,
{
scmmContentType: CONTENT_TYPES.GIT_CONFIG,
},
DefaultBranchSchema,
);
return response.body.defaultBranch;
}
public async getAllRepoPrs(repoPath: string): Promise<PullRequest[]> {
const response = await this.getJson(
URLS.PULLREQUESTS_WITH_PAGINATION(repoPath),
{
scmmContentType: CONTENT_TYPES.PULLREQUESTS,
},
PagedPullRequestSchema,
);
return response.body._embedded.pullRequests;
}
public async getRepoPr(repoPath: string, id: number): Promise<PullRequest> {
const response = await this.getJson(
URLS.PULLREQUEST_BY_ID(repoPath, id),
{
scmmContentType: CONTENT_TYPES.PULLREQUEST,
},
PullRequestSchema,
);
return response.body;
}
public async createPr(
repoPath: string,
params: PullRequestCreateParams,
): Promise<PullRequest> {
const createPrResponse = await this.postJson(URLS.PULLREQUESTS(repoPath), {
scmmContentType: CONTENT_TYPES.PULLREQUEST,
body: params,
headers: {
'Content-Type': CONTENT_TYPES.PULLREQUEST,
},
});
const getCreatedPrResponse = await this.getJson(
/* istanbul ignore next: Just to please the compiler, location would never be undefined */
createPrResponse.headers.location ?? '',
{
scmmContentType: CONTENT_TYPES.PULLREQUEST,
},
PullRequestSchema,
);
return getCreatedPrResponse.body;
}
public async updatePr(
repoPath: string,
id: number,
params: PullRequestUpdateParams,
): Promise<void> {
const currentPr = await this.getRepoPr(repoPath, id);
await this.putJson(URLS.PULLREQUEST_BY_ID(repoPath, id), {
body: this.mergePullRequestWithUpdate(currentPr, params),
headers: {
'Content-Type': CONTENT_TYPES.PULLREQUEST,
},
});
}
private mergePullRequestWithUpdate(
pr: PullRequest,
updateParams: PullRequestUpdateParams,
): PullRequest {
return {
...pr,
title: updateParams.title,
description: updateParams.description
? updateParams.description
: pr.description,
status: updateParams.status ? updateParams.status : pr.status,
target: updateParams.target ? updateParams.target : pr.target,
};
}
}

View file

@ -30,7 +30,7 @@ Supports over [90 different package managers](https://docs.renovatebot.com/modul
### Platforms
Renovate updates code repositories on the following platforms: GitHub, GitLab, Bitbucket, Azure DevOps, AWS Code Commit, Gitea, Forgejo, Gerrit (experimental)
Renovate updates code repositories on the following platforms: GitHub, GitLab, Bitbucket, Azure DevOps, AWS Code Commit, Gitea, Forgejo, Gerrit (experimental), SCM-Manager
## Ways to run Renovate