Files
renovate/lib/modules/platform/forgejo/index.spec.ts
Sebastian Poxhofer 05e1271376 test: use expect().resolves instead of awaiting inside expect (#45672)
Enables the vitest/prefer-expect-resolves oxlint rule and applies its
autofix across the spec suite.
2026-09-04 10:40:46 +00:00

3221 lines
95 KiB
TypeScript

import * as httpMock from '~test/http-mock.ts';
import { fakeSha, git, hostRules, logger, partial } from '~test/util.ts';
import { GlobalConfig } from '../../../config/global.ts';
import type { RepoGlobalConfig } from '../../../config/types.ts';
import {
CONFIG_GIT_URL_UNAVAILABLE,
REPOSITORY_ACCESS_FORBIDDEN,
REPOSITORY_ARCHIVED,
REPOSITORY_BLOCKED,
REPOSITORY_CHANGED,
REPOSITORY_EMPTY,
REPOSITORY_MIRRORED,
TEMPORARY_ERROR,
} from '../../../constants/error-messages.ts';
import * as memCache from '../../../util/cache/memory/index.ts';
import * as repoCache from '../../../util/cache/repository/index.ts';
import { toBase64 } from '../../../util/string.ts';
import { parseUrl } from '../../../util/url.ts';
import type { EnsureIssueConfig, RepoParams } from '../index.ts';
import * as helper from './forgejo-helper.ts';
import * as forgejo from './index.ts';
import type {
Comment,
CommitStatus,
CommitStatusType,
Issue,
Label,
PR,
Repo,
User,
} from './schema.ts';
/**
* latest tested forgejo version.
*/
const FORGEJO_VERSION = '11.0.1-99-c504062+gitea-1.22.0';
describe('modules/platform/forgejo/index', () => {
function mockedRepo(opts: Partial<Repo>): Repo {
return partial<Repo>({
id: 0,
permissions: { push: true, pull: true, admin: false },
has_issues: true,
has_pull_requests: true,
default_branch: 'master',
owner: partial<User>({ id: 0, login: 'some', full_name: '' }),
...opts,
});
}
const mockCommitHash = fakeSha('forgejo');
const mockUser: User = {
id: 1,
login: 'renovate',
full_name: 'Renovate Bot',
email: 'renovate@example.com',
};
const mockRepo = mockedRepo({
allow_rebase: true,
default_merge_style: 'rebase',
clone_url: 'https://forgejo.renovatebot.com/some/repo.git',
ssh_url: 'git@forgejo.renovatebot.com/some/repo.git',
default_branch: 'master',
full_name: 'some/repo',
owner: partial<User>({
id: 0,
login: 'some',
full_name: '',
}),
});
type MockPr = PR & Required<Pick<PR, 'head' | 'base'>>;
const mockRepos: Repo[] = [
mockedRepo({ full_name: 'a/b' }),
mockedRepo({ full_name: 'c/d' }),
mockedRepo({ full_name: 'e/f', mirror: true }),
];
const mockTopicRepos: Repo[] = [mockedRepo({ full_name: 'a/b' })];
const mockNamespaceRepos: Repo[] = [
mockedRepo({ full_name: 'org1/repo' }),
mockedRepo({ full_name: 'org2/repo' }),
mockedRepo({ full_name: 'org2/repo2', archived: true }),
];
const mockPRs: MockPr[] = [
partial<MockPr>({
number: 1,
title: 'Some PR',
body: 'some random pull request',
state: 'open',
diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/1.diff',
created_at: '2015-03-22T20:36:16Z',
closed_at: '2015-03-22T21:36:16Z',
updated_at: '2015-03-22T21:36:16Z',
mergeable: true,
base: { ref: 'some-base-branch' },
head: {
label: 'some-head-branch',
sha: fakeSha('some-head-sha'),
repo: partial<Repo>({ full_name: mockRepo.full_name }),
},
}),
partial<MockPr>({
number: 2,
title: 'Other PR',
body: 'other random pull request',
state: 'closed',
diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/2.diff',
created_at: '2011-08-18T22:30:38Z',
closed_at: '2016-01-09T10:03:21Z',
updated_at: '2016-01-09T10:03:21Z',
mergeable: true,
base: { ref: 'other-base-branch' },
head: {
label: 'other-head-branch',
sha: fakeSha('other-head-sha'),
repo: partial<Repo>({ full_name: mockRepo.full_name }),
},
labels: [
{
id: 1,
name: 'bug',
},
],
}),
partial<MockPr>({
number: 3,
title: 'WIP: Draft PR',
body: 'other random pull request',
state: 'open',
diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/3.diff',
created_at: '2011-08-18T22:30:39Z',
closed_at: '2016-01-09T10:03:22Z',
updated_at: '2017-01-09T10:03:22Z',
mergeable: false,
base: { ref: 'draft-base-branch' },
head: {
label: 'draft-head-branch',
sha: fakeSha('draft-head-sha'),
repo: partial<Repo>({ full_name: mockRepo.full_name }),
},
}),
partial<MockPr>({
number: 4,
title: 'Merged PR',
body: 'other random merged pull request',
state: 'closed',
diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/4.diff',
created_at: '2011-08-18T22:30:38Z',
closed_at: '2016-01-09T10:03:21Z',
updated_at: '2016-01-09T10:03:21Z',
mergeable: true,
merged: true,
base: { ref: 'other-base-branch' },
head: {
label: 'merged-head-branch',
sha: fakeSha('merged-head-sha'),
repo: partial<Repo>({ full_name: mockRepo.full_name }),
},
labels: [
{
id: 1,
name: 'bug',
},
],
}),
];
const mockIssues: Issue[] = [
{
number: 1,
title: 'open-issue',
state: 'open',
body: 'some-content',
assignees: [],
labels: [],
},
{
number: 2,
title: 'closed-issue',
state: 'closed',
body: 'other-content',
assignees: [],
labels: undefined, // coverage
},
{
number: 3,
title: 'duplicate-issue',
state: 'open',
body: 'duplicate-content',
assignees: [],
labels: [],
},
{
number: 4,
title: 'duplicate-issue',
state: 'open',
body: 'duplicate-content',
assignees: [],
labels: [],
},
{
number: 5,
title: 'duplicate-issue',
state: 'open',
body: 'duplicate-content',
assignees: [],
labels: [],
},
];
const mockComments: Comment[] = [
{ id: 11, body: 'some-body' },
{ id: 12, body: 'other-body' },
{ id: 13, body: '### some-topic\n\nsome-content' },
];
const mockRepoLabels: Label[] = [
{ id: 1, name: 'some-label', description: 'its a me', color: '#000000' },
{ id: 2, name: 'other-label', description: 'labelario', color: '#ffffff' },
];
const mockOrgLabels: Label[] = [
{
id: 3,
name: 'some-org-label',
description: 'its a org me',
color: '#0000aa',
},
{
id: 4,
name: 'other-org-label',
description: 'org labelario',
color: '#ffffaa',
},
];
beforeEach(() => {
GlobalConfig.reset();
forgejo.resetPlatform();
memCache.init();
repoCache.resetCache();
git.isBranchBehindBase.mockResolvedValue(false);
git.getBranchCommit.mockReturnValue(mockCommitHash);
hostRules.clear();
});
async function initFakePlatform(
scope: httpMock.Scope,
version = FORGEJO_VERSION,
): Promise<void> {
scope
.get('/user')
.reply(200, mockUser)
.get('/version')
.reply(200, { version });
await forgejo.initPlatform({ token: 'abc' });
}
async function initFakeRepo(
scope: httpMock.Scope,
repo?: Partial<Repo>,
config?: RepoGlobalConfig,
orgCode = 200,
): Promise<void> {
const repoResult = { ...mockRepo, ...repo };
const repository = repoResult.full_name;
scope
.get(`/repos/${repository}`)
.reply(200, repoResult)
.get(`/orgs/${repoResult.owner.login}`)
.reply(orgCode, {});
GlobalConfig.set({ ignorePrAuthor: true, ...config });
await forgejo.initRepo({ repository });
}
describe('initPlatform()', () => {
it('should throw if no token', async () => {
await expect(forgejo.initPlatform({})).rejects.toThrow(
'Init: You must configure a Forgejo personal access token',
);
});
it('should throw if auth fails', async () => {
const scope = httpMock.scope('https://code.forgejo.org/api/v1');
scope.get('/user').reply(500);
await expect(
forgejo.initPlatform({ token: 'some-token' }),
).rejects.toThrow('Init: Authentication failure');
});
it('should support default endpoint', async () => {
const scope = httpMock.scope('https://code.forgejo.org/api/v1');
scope
.get('/user')
.reply(200, { ...mockUser, full_name: '' })
.get('/version')
.reply(200, { version: FORGEJO_VERSION });
await expect(
forgejo.initPlatform({ token: 'some-token' }),
).resolves.toEqual({
endpoint: 'https://code.forgejo.org/',
gitAuthor: 'renovate <renovate@example.com>',
});
});
it('should support custom endpoint', async () => {
const scope = httpMock.scope('https://forgejo.renovatebot.com/api/v1');
scope
.get('/user')
.reply(200, mockUser)
.get('/version')
.reply(200, { version: FORGEJO_VERSION });
await expect(
forgejo.initPlatform({
token: 'some-token',
endpoint: 'https://forgejo.renovatebot.com',
}),
).resolves.toEqual({
endpoint: 'https://forgejo.renovatebot.com/',
gitAuthor: 'Renovate Bot <renovate@example.com>',
});
});
it('should support custom endpoint including api path', async () => {
const scope = httpMock.scope('https://forgejo.renovatebot.com/api/v1');
scope
.get('/user')
.reply(200, mockUser)
.get('/version')
.reply(200, { version: FORGEJO_VERSION });
await expect(
forgejo.initPlatform({
token: 'some-token',
endpoint: 'https://forgejo.renovatebot.com',
}),
).resolves.toEqual({
endpoint: 'https://forgejo.renovatebot.com/',
gitAuthor: 'Renovate Bot <renovate@example.com>',
});
});
it('should use login as author name if full name is missing', async () => {
const scope = httpMock.scope('https://code.forgejo.org/api/v1');
scope
.get('/user')
.reply(200, {
...mockUser,
full_name: undefined,
})
.get('/version')
.reply(200, { version: FORGEJO_VERSION });
await expect(
forgejo.initPlatform({ token: 'some-token' }),
).resolves.toEqual({
endpoint: 'https://code.forgejo.org/',
gitAuthor: 'renovate <renovate@example.com>',
});
});
});
describe('getRepos', () => {
it('should propagate any other errors from getRepos', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/search')
.query({
uid: 1,
archived: false,
})
.replyWithError(httpMock.error('searchRepos()'));
await initFakePlatform(scope);
await expect(forgejo.getRepos()).rejects.toThrow('searchRepos()');
});
it('should return an array of repos', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/search')
.query({
uid: 1,
archived: false,
})
.reply(200, {
ok: true,
data: mockRepos,
});
await initFakePlatform(scope);
const repos = await forgejo.getRepos();
expect(repos).toEqual(['a/b', 'c/d']);
});
it('should return an filtered array of repos', async () => {
const scope = httpMock.scope('https://code.forgejo.org/api/v1');
scope
.get('/repos/search')
.query({
uid: 1,
archived: false,
q: 'renovate',
topic: true,
})
.reply(200, {
ok: true,
data: mockTopicRepos,
});
scope
.get('/repos/search')
.query({
uid: 1,
archived: false,
q: 'renovatebot',
topic: true,
})
.reply(200, {
ok: true,
data: mockTopicRepos,
});
await initFakePlatform(scope);
const repos = await forgejo.getRepos({
topics: ['renovate', 'renovatebot'],
});
expect(repos).toEqual(['a/b']);
});
it('should query the organization endpoint for each namespace', async () => {
const scope = httpMock.scope('https://code.forgejo.org/api/v1');
scope.get('/orgs/org1/repos').reply(200, mockNamespaceRepos);
scope.get('/orgs/org2/repos').reply(200, mockNamespaceRepos);
await initFakePlatform(scope);
const repos = await forgejo.getRepos({
namespaces: ['org1', 'org2'],
});
expect(repos).toEqual(['org1/repo', 'org2/repo']);
});
it('Sorts repos', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/search')
.query({
uid: 1,
archived: false,
sort: 'updated',
order: 'desc',
})
.reply(200, {
ok: true,
data: mockRepos,
});
await initFakePlatform(scope);
const repos = await forgejo.getRepos({
sort: 'updated',
order: 'desc',
});
expect(repos).toEqual(['a/b', 'c/d']);
});
});
describe('initRepo', () => {
const initRepoCfg: RepoParams = {
repository: mockRepo.full_name,
};
it('should propagate API errors', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/repos/${initRepoCfg.repository}`)
.replyWithError(httpMock.error('getRepo()'));
await initFakePlatform(scope);
await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow('getRepo()');
});
it('should propagate org API errors', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.replyWithError(httpMock.error('isOrg()'))
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
});
await initFakePlatform(scope);
await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow('isOrg()');
});
it('should abort when repo is archived', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
archived: true,
});
await initFakePlatform(scope);
await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
REPOSITORY_ARCHIVED,
);
});
it('should abort when repo is mirrored', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
mirror: true,
});
await initFakePlatform(scope);
await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
REPOSITORY_MIRRORED,
);
});
it('should abort when repo is empty', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
empty: true,
});
await initFakePlatform(scope);
await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
REPOSITORY_EMPTY,
);
});
it('should abort when repo has insufficient permissions', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
permissions: {
pull: false,
push: false,
admin: false,
},
});
await initFakePlatform(scope);
await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
REPOSITORY_ACCESS_FORBIDDEN,
);
});
it('should abort when repo has pulls disabled', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
has_pull_requests: false,
});
await initFakePlatform(scope);
await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
REPOSITORY_BLOCKED,
);
});
it('should abort when repo has no available merge methods', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
allow_rebase: false,
});
await initFakePlatform(scope);
await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
REPOSITORY_BLOCKED,
);
});
it('should select default merge method when it is allowed', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
allow_rebase: false,
allow_fast_forward_only_merge: true,
default_merge_style: 'fast-forward-only',
});
await initFakePlatform(scope);
await forgejo.initRepo(initRepoCfg);
expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
mergeMethod: 'fast-forward-only',
}),
);
});
it('should fall back to merge method as per ordered list when default not allowed', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
allow_merge_commits: true,
allow_squash_merge: false,
default_merge_style: 'squash',
});
await initFakePlatform(scope);
await forgejo.initRepo(initRepoCfg);
expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
mergeMethod: 'merge',
}),
);
});
it('should fall back to merge method when default merge style is unrecognized', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
default_merge_style: 'manually-merged',
});
await initFakePlatform(scope);
await forgejo.initRepo(initRepoCfg);
expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
mergeMethod: 'rebase',
}),
);
});
it('should use clone_url of repo if gitUrl is not specified', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, mockRepo);
await initFakePlatform(scope);
const repoCfg: RepoParams = {
repository: mockRepo.full_name,
};
await forgejo.initRepo(repoCfg);
expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ url: mockRepo.clone_url }),
);
});
it('should use clone_url of repo if gitUrl has value default', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, mockRepo);
await initFakePlatform(scope);
const repoCfg: RepoParams = {
repository: mockRepo.full_name,
gitUrl: 'default',
};
await forgejo.initRepo(repoCfg);
expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ url: mockRepo.clone_url }),
);
});
it('should use ssh_url of repo if gitUrl has value ssh', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, mockRepo);
await initFakePlatform(scope);
const repoCfg: RepoParams = {
repository: mockRepo.full_name,
gitUrl: 'ssh',
};
await forgejo.initRepo(repoCfg);
expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ url: mockRepo.ssh_url }),
);
});
it('should abort when gitUrl has value ssh but ssh_url is empty', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, { ...mockRepo, ssh_url: undefined });
await initFakePlatform(scope);
const repoCfg: RepoParams = {
repository: mockRepo.full_name,
gitUrl: 'ssh',
};
await expect(forgejo.initRepo(repoCfg)).rejects.toThrow(
CONFIG_GIT_URL_UNAVAILABLE,
);
});
it('should use generated url of repo if gitUrl has value endpoint', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, mockRepo);
await initFakePlatform(scope);
const repoCfg: RepoParams = {
repository: mockRepo.full_name,
gitUrl: 'endpoint',
};
await forgejo.initRepo(repoCfg);
expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
url: `https://code.forgejo.org/${mockRepo.full_name}.git`,
}),
);
});
it('should abort when clone_url is empty', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
clone_url: undefined,
});
await initFakePlatform(scope);
const repoCfg: RepoParams = {
repository: mockRepo.full_name,
};
await expect(forgejo.initRepo(repoCfg)).rejects.toThrow(
CONFIG_GIT_URL_UNAVAILABLE,
);
});
it('should use given access token if gitUrl has value endpoint', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, mockRepo);
await initFakePlatform(scope);
const token = 'abc';
hostRules.add({
hostType: 'forgejo',
matchHost: 'https://code.forgejo.org/',
token,
});
const repoCfg: RepoParams = {
repository: mockRepo.full_name,
gitUrl: 'endpoint',
};
await forgejo.initRepo(repoCfg);
const url = parseUrl(`${mockRepo.clone_url}`)!;
url.username = token;
expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
url: `https://${token}@code.forgejo.org/${mockRepo.full_name}.git`,
}),
);
});
it('should use given access token if gitUrl is not specified', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, mockRepo);
await initFakePlatform(scope);
const token = 'abc';
hostRules.add({
hostType: 'forgejo',
matchHost: 'https://code.forgejo.org/',
token,
});
const repoCfg: RepoParams = {
repository: mockRepo.full_name,
};
await forgejo.initRepo(repoCfg);
const url = parseUrl(`${mockRepo.clone_url}`)!;
url.username = token;
expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ url: url.toString() }),
);
});
it('should abort when clone_url is not valid', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get(`/orgs/some`)
.reply(200, {})
.get(`/repos/${initRepoCfg.repository}`)
.reply(200, {
...mockRepo,
clone_url: 'abc',
});
await initFakePlatform(scope);
const repoCfg: RepoParams = {
repository: mockRepo.full_name,
};
await expect(forgejo.initRepo(repoCfg)).rejects.toThrow(
CONFIG_GIT_URL_UNAVAILABLE,
);
});
});
describe('setBranchStatus', () => {
it('should create a new commit status', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post(`/repos/some/repo/statuses/${mockCommitHash}`, {
state: 'success',
context: 'some-context',
description: 'some-description',
})
.reply(200, {
id: 1,
status: 'success',
context: 'some-context',
created_at: '2024-01-01T00:00:00Z',
})
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, []);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.setBranchStatus({
branchName: 'some-branch',
state: 'green',
context: 'some-context',
description: 'some-description',
}),
).toResolve();
});
it('should default to pending state', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post(`/repos/some/repo/statuses/${mockCommitHash}`, {
state: 'pending',
context: 'some-context',
description: 'some-description',
})
.reply(200, {
id: 1,
status: 'pending',
context: 'some-context',
created_at: '2024-01-01T00:00:00Z',
})
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, []);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.setBranchStatus({
branchName: 'some-branch',
context: 'some-context',
description: 'some-description',
state: undefined as never,
}),
).toResolve();
});
it('should include url if specified', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post(`/repos/some/repo/statuses/${mockCommitHash}`, {
state: 'success',
context: 'some-context',
description: 'some-description',
target_url: 'some-url',
})
.reply(200, {
id: 1,
status: 'success',
context: 'some-context',
created_at: '2024-01-01T00:00:00Z',
})
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, []);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.setBranchStatus({
branchName: 'some-branch',
state: 'green',
context: 'some-context',
description: 'some-description',
url: 'some-url',
}),
).toResolve();
});
it('should gracefully fail with warning when setting branch status', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post(`/repos/some/repo/statuses/${mockCommitHash}`)
.replyWithError('unknown error');
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.setBranchStatus({
branchName: 'some-branch',
state: 'green',
context: 'some-context',
description: 'some-description',
}),
).toResolve();
expect(logger.logger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
},
'Failed to set branch status',
);
});
});
describe('getBranchStatus', () => {
function commitStatus(status: CommitStatusType): CommitStatus {
return {
id: 1,
status,
context: '',
description: '',
target_url: '',
created_at: '',
};
}
it('should return yellow for unknown result', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, [commitStatus('unknown')]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchStatus('some-branch', true);
expect(res).toBe('yellow');
});
it('should return pending state for pending result', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, [commitStatus('pending')]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchStatus('some-branch', true);
expect(res).toBe('yellow');
});
it('should return green state for success result', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, [commitStatus('success')]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchStatus('some-branch', true);
expect(res).toBe('green');
});
it('should return yellow for all other results', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, [commitStatus('invalid' as never)]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchStatus('some-branch', true);
expect(res).toBe('yellow');
});
it('should abort when branch status returns 404', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(404);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.getBranchStatus('some-branch', true),
).rejects.toThrow(REPOSITORY_CHANGED);
});
it('should propagate any other errors from getBranchStatus', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.replyWithError('unknown error');
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.getBranchStatus('some-branch', true),
).rejects.toThrow('unknown error');
});
it('should treat internal checks as success', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, [
{
id: 1,
status: 'success',
context: 'renovate/stability-days',
description: 'internal check',
target_url: '',
created_at: '',
},
]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchStatus('some-branch', true);
expect(res).toBe('green');
});
it('should not treat internal checks as success', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, [
{
id: 1,
status: 'success',
context: 'renovate/stability-days',
description: 'internal check',
target_url: '',
created_at: '',
},
]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchStatus('some-branch', false);
expect(res).toBe('yellow');
});
});
describe('getBranchStatusCheck', () => {
it('should return null with no results', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, []);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.getBranchStatusCheck('some-branch', 'some-context'),
).resolves.toBeNull();
});
it('should return null with no matching results', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, [
{
id: 1,
status: 'success',
context: 'other-context',
description: 'internal check',
target_url: '',
created_at: '',
},
]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchStatusCheck(
'some-branch',
'some-context',
);
expect(res).toBeNull();
});
it('should return yellow with unknown status', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, [
{
id: 1,
status: 'xyz',
context: 'some-context',
description: '',
target_url: '',
created_at: '',
},
]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchStatusCheck(
'some-branch',
'some-context',
);
expect(res).toBe('yellow');
});
it('should return green of matching result', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/commits/some-branch/statuses')
.reply(200, [
{
id: 1,
status: 'success',
context: 'some-context',
description: '',
target_url: '',
created_at: '',
},
]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchStatusCheck(
'some-branch',
'some-context',
);
expect(res).toBe('green');
});
});
describe('getPrList', () => {
it('should return list of pull requests', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getPrList();
expect(res).toMatchObject([
{ number: 4, title: 'Merged PR' },
{ number: 3, title: 'Draft PR' },
{ number: 2, title: 'Other PR' },
{ number: 1, title: 'Some PR' },
]);
});
it('should filter list by creator', async () => {
const thirdPartyPr = partial<PR>({
number: 42,
title: 'Third-party PR',
body: 'other random pull request',
state: 'open',
diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/3.diff',
created_at: '2011-08-18T22:30:38Z',
updated_at: '2012-08-18T22:30:38Z',
closed_at: '2016-01-09T10:03:21Z',
mergeable: true,
base: { ref: 'third-party-base-branch' },
head: {
label: 'other-head-branch',
sha: fakeSha('other-head-sha'),
repo: partial<Repo>({ full_name: mockRepo.full_name }),
},
user: { id: 2, login: 'not-renovate' },
});
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, [
thirdPartyPr,
...mockPRs.map((pr) => ({
...pr,
user: { id: 1, login: 'renovate' },
})),
]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getPrList();
expect(res).toMatchObject([
{ number: 4, title: 'Merged PR' },
{ number: 3, title: 'Draft PR' },
{ number: 2, title: 'Other PR' },
{ number: 1, title: 'Some PR' },
]);
});
it('should cache results after first query', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({
state: 'all',
sort: 'recentupdate',
limit: 100,
poster: mockUser.login,
})
.reply(200, mockPRs.slice(0, 2), {
// test correct pagination handling, domain should be ignored
link: '<https://domain.test/some/path/api/v1/repos/some/repo/pulls?state=all&sort=recentupdate&limit=50&page=2>; rel="next",<https://domain.test/some/path/api/v1/repos/some/repo/pulls?state=all&sort=recentupdate&page=4512>; rel="last"',
})
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 50, page: 2 })
.reply(200, mockPRs.slice(2));
await initFakePlatform(scope);
await initFakeRepo(scope, undefined, { ignorePrAuthor: false });
const res1 = await forgejo.getPrList();
const res2 = await forgejo.getPrList();
expect(res1).toEqual(res2);
});
it('should update cache results', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs.slice(0, 2))
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 20 })
.reply(200, mockPRs.slice(1));
await initFakePlatform(scope);
await initFakeRepo(scope);
const res1 = await forgejo.getPrList();
expect(res1).toMatchObject([{ number: 2 }, { number: 1 }]);
memCache.set('forgejo-pr-cache-synced', false);
const res2 = await forgejo.getPrList();
expect(res2).toMatchObject([
{ number: 4 },
{ number: 3 },
{ number: 2 },
{ number: 1 },
]);
});
});
describe('getPr', () => {
it('should return enriched pull request which exists if open', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getPr(1);
expect(res).toMatchObject({ number: 1, title: 'Some PR' });
});
it('should fallback to direct fetching if cache fails', async () => {
const pr = mockPRs.find((pr) => pr.number === 1);
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, [])
.get('/repos/some/repo/pulls/1')
.reply(200, pr);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getPr(1);
expect(res).toMatchObject({ number: 1, title: 'Some PR' });
});
it('should return null for missing pull request in getPr', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, [])
.get('/repos/some/repo/pulls/42')
.reply(200, {
number: 42,
state: 'open',
title: 'Missing PR',
body: '',
mergeable: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
}); // no head/base => toRenovatePR returns null
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getPr(42);
expect(res).toBeNull();
});
it('should throw temporary error for null pull request', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, [null]); // TODO: 404 should be handled
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.getPr(42)).rejects.toThrow(TEMPORARY_ERROR);
});
});
describe('findPr', () => {
it('should find pull request without title or state', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({ branchName: 'some-head-branch' });
expect(res).toMatchObject({
number: 1,
sourceBranch: 'some-head-branch',
});
});
it('should find pull request with title', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({
branchName: 'some-head-branch',
prTitle: 'Some PR',
});
expect(res).toMatchObject({
number: 1,
title: 'Some PR',
});
});
it('should find pull request with state', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({
branchName: 'some-head-branch',
state: 'open',
});
expect(res).toMatchObject({
number: 1,
state: 'open',
});
});
it('should not find pull request with inverted state', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({
branchName: 'other-head-branch',
state: `!open`,
});
expect(res).toMatchObject({
number: 2,
state: 'closed',
title: 'Other PR',
});
});
it('should find pull request with title and state', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({
branchName: 'other-head-branch',
prTitle: 'Other PR',
state: 'closed',
});
expect(res).toMatchObject({
number: 2,
state: 'closed',
title: 'Other PR',
});
});
it('should find pull request with draft', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({
branchName: 'draft-head-branch',
prTitle: 'Draft PR',
state: 'open',
});
expect(res).toMatchObject({
number: 3,
title: 'Draft PR',
isDraft: true,
});
});
it('should find merged pull request', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({ branchName: 'merged-head-branch' });
expect(res).toMatchObject({
number: 4,
sourceBranch: 'merged-head-branch',
state: 'merged',
});
});
it('should return null for missing pull request in findPr', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({ branchName: 'missing' });
expect(res).toBeNull();
});
it('finds pr from other authors using base and head', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls/some-base-branch/some-head-branch')
.reply(200, {
number: 42,
state: 'open',
head: {
label: 'some-head-branch',
sha: mockCommitHash,
repo: partial<Repo>({ full_name: mockRepo.full_name }),
},
base: {
ref: 'some-base-branch',
},
title: 'from other author',
body: 'pr-body',
mergeable: true,
created_at: '2014-04-01T05:14:20Z',
updated_at: '2017-12-28T12:17:48Z',
});
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({
branchName: 'some-head-branch',
targetBranch: 'some-base-branch',
includeOtherAuthors: true,
});
expect(res).toMatchObject({
number: 42,
sourceBranch: 'some-head-branch',
targetBranch: 'some-base-branch',
title: 'from other author',
});
});
it('returns null if cannot find pr from other author', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls/some-base-branch/some-head-branch')
.reply(404);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findPr({
branchName: 'some-head-branch',
targetBranch: 'some-base-branch',
includeOtherAuthors: true,
});
expect(res).toBeNull();
});
});
describe('createPr', () => {
beforeEach(() => {
vi.restoreAllMocks();
memCache.set('forgejo-pr-cache-synced', true);
});
const mockNewPR: MockPr = {
number: 42,
state: 'open',
head: {
label: 'pr-branch',
sha: mockCommitHash,
repo: partial<Repo>({ full_name: mockRepo.full_name }),
},
base: {
ref: mockRepo.default_branch,
},
diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/42.diff',
title: 'pr-title',
body: 'pr-body',
mergeable: true,
created_at: '2014-04-01T05:14:20Z',
closed_at: '2017-12-28T12:17:48Z',
updated_at: '2017-12-28T12:17:48Z',
};
it('should use base branch by default', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, {
...mockNewPR,
base: { ref: 'devel' },
});
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'devel',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
});
it('should use default branch if requested', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
draftPR: true,
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
});
it('should resolve and apply optional repo and org labels to pull request', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR)
.get('/repos/some/repo/labels')
.reply(200, mockRepoLabels)
.get('/orgs/some/labels')
.reply(200, mockOrgLabels);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
labels: [...mockRepoLabels, ...mockOrgLabels].map(({ name }) => name),
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
});
it('should resolve and apply optional repo labels to pull request', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR)
.get('/repos/some/repo/labels')
.reply(200, mockRepoLabels);
await initFakePlatform(scope);
await initFakeRepo(scope, undefined, undefined, 404);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
labels: [...mockRepoLabels, ...mockOrgLabels].map(({ name }) => name),
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
});
it('should ensure new pull request gets added to cached pull requests', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR);
await initFakePlatform(scope);
await initFakeRepo(scope);
await forgejo.getPrList();
await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
});
const res = await forgejo.getPr(mockNewPR.number);
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
});
it('should attempt to resolve 409 conflict error (w/o update)', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(409)
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, [mockNewPR]);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
});
it('should attempt to resolve 409 conflict error (w/ update)', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(409)
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, [mockNewPR])
.patch('/repos/some/repo/pulls/42')
.reply(200, mockNewPR);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: 'new-title',
prBody: 'new-body',
});
expect(res).toMatchObject({
number: 42,
title: 'new-title',
});
});
it('should abort when response for created pull request is invalid', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, {});
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
}),
).rejects.toThrow('Invalid input: expected number, received undefined');
});
it('should abort when the created pull request author is not the bot', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, { ...mockNewPR, user: { id: 2, login: 'not-the-bot' } });
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
}),
).rejects.toThrow('Can not parse newly created Pull Request');
});
it('should use platform automerge', async () => {
const mergePR = vi.spyOn(helper, 'mergePR');
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR)
.post('/repos/some/repo/pulls/42/merge')
.reply(200);
await initFakePlatform(scope, '10.0.0-13-c504062+gitea-1.22.0');
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
platformPrOptions: { usePlatformAutomerge: true },
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
expect(mergePR).toHaveBeenCalled();
});
it('should not use platform automerge on forgejo v7', async () => {
const mergePR = vi.spyOn(helper, 'mergePR');
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR);
await initFakePlatform(scope, '7.0.0-dev-2136-f075579c95+forgejo-1.22.0');
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
platformPrOptions: { usePlatformAutomerge: true },
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
expect(mergePR).not.toHaveBeenCalled();
});
it('should not use platform automerge on forgejo v7 LTS', async () => {
const mergePR = vi.spyOn(helper, 'mergePR');
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR);
await initFakePlatform(scope, '7.0.0+LTS-forgejo-1.22.0');
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
platformPrOptions: { usePlatformAutomerge: true },
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
expect(mergePR).not.toHaveBeenCalled();
});
it('continues on platform automerge error', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR)
.post('/repos/some/repo/pulls/42/merge')
.replyWithError('unknown error');
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
platformPrOptions: { usePlatformAutomerge: true },
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
expect(logger.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ prNumber: 42 }),
'Forgejo-native automerge: fail',
);
});
it('continues if platform automerge is not supported', async () => {
memCache.set('forgejo-pr-cache-synced', true);
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR);
await initFakePlatform(scope, '1.10.0');
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
platformPrOptions: { usePlatformAutomerge: true },
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
expect(logger.logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ prNumber: 42 }),
'Forgejo-native automerge: not supported on this version of Forgejo. Use 10.0.0 or newer.',
);
});
it('should create PR with repository merge method when automergeStrategy is auto', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR)
.post('/repos/some/repo/pulls/42/merge')
.reply(200);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
platformPrOptions: {
automergeStrategy: 'auto',
usePlatformAutomerge: true,
},
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
});
it.each`
automergeStrategy | prMergeStrategy
${'fast-forward'} | ${'rebase'}
${'merge-commit'} | ${'merge'}
${'rebase'} | ${'rebase-merge'}
${'squash'} | ${'squash'}
`(
'should create PR with mergeStrategy $prMergeStrategy',
async ({ automergeStrategy, prMergeStrategy }) => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls')
.reply(200, mockNewPR)
.post('/repos/some/repo/pulls/42/merge')
.reply(200, {
Do: prMergeStrategy,
merge_when_checks_succeed: true,
});
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.createPr({
sourceBranch: mockNewPR.head.label,
targetBranch: 'master',
prTitle: mockNewPR.title,
prBody: mockNewPR.body,
platformPrOptions: {
automergeStrategy,
usePlatformAutomerge: true,
},
});
expect(res).toMatchObject({
number: 42,
title: 'pr-title',
});
},
);
});
describe('updatePr', () => {
it('should update pull request with title', async () => {
const pr = mockPRs.find((pr) => pr.number === 1);
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs)
.patch('/repos/some/repo/pulls/1', { title: 'New Title' })
.reply(200, pr);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.updatePr({ number: 1, prTitle: 'New Title' }),
).toResolve();
});
it('should update pull target branch', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs)
.patch('/repos/some/repo/pulls/1', {
title: 'New Title',
base: 'New Base',
})
.reply(200, mockPRs[0]);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.updatePr({
number: 1,
prTitle: 'New Title',
targetBranch: 'New Base',
}),
).toResolve();
});
it('should update pull request with title and body', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs)
.patch('/repos/some/repo/pulls/1', {
title: 'New Title',
body: 'New Body',
})
.reply(200, mockPRs[0]);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.updatePr({
number: 1,
prTitle: 'New Title',
prBody: 'New Body',
}),
).toResolve();
});
it('should update pull request with draft', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs)
.patch('/repos/some/repo/pulls/3', {
title: 'WIP: New Title',
body: 'New Body',
})
.reply(200, mockPRs[2]);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.updatePr({
number: 3,
prTitle: 'New Title',
prBody: 'New Body',
}),
).toResolve();
});
it('should close pull request', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs)
.patch('/repos/some/repo/pulls/1', {
title: 'New Title',
body: 'New Body',
state: 'closed',
})
.reply(200, mockPRs[0]);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.updatePr({
number: 1,
prTitle: 'New Title',
prBody: 'New Body',
state: 'closed',
}),
).toResolve();
});
it('should update labels', async () => {
const updatedMockPR = partial<PR>({
...mockPRs[0],
number: 1,
title: 'New Title',
body: 'New Body',
state: 'open',
labels: [
{
id: 1,
name: 'some-label',
},
],
});
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs)
.get('/repos/some/repo/labels')
.reply(200, mockRepoLabels)
.get('/orgs/some/labels')
.reply(200, mockOrgLabels)
.patch('/repos/some/repo/pulls/1')
.reply(200, updatedMockPR);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.updatePr({
number: 1,
prTitle: 'New Title',
prBody: 'New Body',
state: 'open',
labels: ['some-label'],
}),
).toResolve();
});
it('should log a warning if labels could not be looked up', async () => {
const updatedMockPR = partial<PR>({
...mockPRs[0],
number: 1,
title: 'New Title',
body: 'New Body',
state: 'open',
labels: [
{
id: 1,
name: 'some-label',
},
],
});
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs)
.get('/repos/some/repo/labels')
.reply(200, mockRepoLabels)
.get('/orgs/some/labels')
.reply(200, mockOrgLabels)
.patch('/repos/some/repo/pulls/1')
.reply(200, updatedMockPR);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.updatePr({
number: 1,
prTitle: 'New Title',
prBody: 'New Body',
state: 'open',
labels: ['some-label', 'unavailable-label'],
}),
).toResolve();
expect(logger.logger.warn).toHaveBeenCalledWith(
'Some labels could not be looked up. Renovate may halt label updates assuming changes by others.',
);
});
});
describe('mergePr', () => {
it('should return true when merging succeeds', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls/1/merge', {
Do: 'rebase',
})
.reply(200);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.mergePr({
branchName: 'some-branch',
id: 1,
});
expect(res).toBe(true);
});
it('should return false when merging fails', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls/1/merge', {
Do: 'squash',
})
.replyWithError('unknown');
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.mergePr({
branchName: 'some-branch',
id: 1,
strategy: 'squash',
});
expect(res).toBe(false);
});
});
describe('getIssueList', () => {
it('should return empty for disabled issues', async () => {
const scope = httpMock.scope('https://code.forgejo.org/api/v1');
await initFakePlatform(scope);
await initFakeRepo(scope, { has_issues: false });
const res = await forgejo.getIssueList();
expect(res).toBeEmptyArray();
});
});
describe('getIssue', () => {
it('should return the issue', async () => {
const mockIssue = mockIssues.find((i) => i.number === 1)!;
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1')
.reply(200, mockIssue);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getIssue?.(mockIssue.number);
expect(res).toEqual({
body: 'some-content',
number: 1,
});
});
it('should return null for disabled issues in getIssue', async () => {
const scope = httpMock.scope('https://code.forgejo.org/api/v1');
await initFakePlatform(scope);
await initFakeRepo(scope, { has_issues: false });
const res = await forgejo.getIssue!(1);
expect(res).toBeNull();
});
it('should return null on error', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1')
.reply(404);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getIssue!(1);
expect(res).toBeNull();
});
});
describe('findIssue', () => {
it('should return existing open issue', async () => {
const mockIssue = mockIssues.find(({ title }) => title === 'open-issue')!;
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, mockIssues)
.get('/repos/some/repo/issues/1')
.reply(200, mockIssue);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findIssue(mockIssue.title);
expect(res).toMatchObject({
body: 'some-content',
number: 1,
});
});
it('should not return existing closed issue', async () => {
const mockIssue = mockIssues.find(
({ title }) => title === 'closed-issue',
)!;
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, mockIssues);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findIssue(mockIssue.title);
expect(res).toBeNull();
});
it('should return null for missing issue', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, mockIssues);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.findIssue('missing');
expect(res).toBeNull();
});
});
describe('ensureIssue', () => {
it('should create issue if not found', async () => {
const mockIssue = {
title: 'new-title',
body: 'new-body',
shouldReOpen: false,
once: false,
};
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, mockIssues)
.post('/repos/some/repo/issues', {
body: mockIssue.body,
title: mockIssue.title,
})
.reply(200, {
number: 42,
title: mockIssue.title,
body: mockIssue.body,
});
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureIssue(mockIssue);
expect(res).toBe('created');
});
it('should create issue with the correct labels', async () => {
const mockIssue: EnsureIssueConfig = {
title: 'new-title',
body: 'new-body',
shouldReOpen: false,
once: false,
labels: ['Renovate', 'Maintenance'],
};
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, mockIssues)
.get('/repos/some/repo/labels')
.reply(200, [
partial<Label>({ id: 1, name: 'Renovate' }),
partial<Label>({ id: 3, name: 'Maintenance' }),
] satisfies Label[])
.get('/orgs/some/labels')
.reply(200, mockOrgLabels)
.post('/repos/some/repo/issues', {
body: 'new-body',
title: 'new-title',
labels: [1, 3],
})
.reply(200, { number: 42, title: 'new-title', body: 'new-body' });
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureIssue(mockIssue);
expect(res).toBe('created');
});
it('should not reopen closed issue by default', async () => {
const closedIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, mockIssues)
.post('/repos/some/repo/issues', {
body: closedIssue.body,
title: closedIssue.title,
})
.reply(200, {
number: 42,
title: closedIssue.title,
body: closedIssue.body,
});
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureIssue({
title: closedIssue.title,
body: closedIssue.body,
shouldReOpen: false,
once: false,
});
expect(res).toBe('created');
});
it('should not update labels when not necessary', async () => {
const mockLabels: Label[] = [
partial<Label>({ id: 1, name: 'Renovate' }),
partial<Label>({ id: 3, name: 'Maintenance' }),
];
const mockIssue: Issue = {
number: 10,
title: 'label-issue',
body: 'label-body',
assignees: [],
labels: mockLabels,
state: 'open',
};
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, [mockIssue])
.patch('/repos/some/repo/issues/10')
.reply(200, mockIssue)
.get('/repos/some/repo/labels')
.reply(200, mockLabels)
.get('/orgs/some/labels')
.reply(200, []);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureIssue({
title: mockIssue.title,
body: 'new-body',
labels: ['Renovate', 'Maintenance'],
});
expect(res).toBe('updated');
});
it('should update labels when missing', async () => {
const mockLabels: Label[] = [
partial<Label>({ id: 1, name: 'Renovate' }),
partial<Label>({ id: 3, name: 'Maintenance' }),
];
const mockIssue: Issue = {
number: 10,
title: 'label-issue',
body: 'label-body',
assignees: [],
labels: [mockLabels[0]],
state: 'open',
};
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, [mockIssue])
.patch('/repos/some/repo/issues/10')
.reply(200, mockIssue)
.get('/repos/some/repo/labels')
.reply(200, mockLabels)
.get('/orgs/some/labels')
.reply(200, [])
.put('/repos/some/repo/issues/10/labels', { labels: [1, 3] })
.reply(200, mockLabels);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureIssue({
title: mockIssue.title,
body: 'new-body',
labels: ['Renovate', 'Maintenance'],
});
expect(res).toBe('updated');
});
it('should reset labels when others have been set', async () => {
const mockLabels: Label[] = [
partial<Label>({ id: 1, name: 'Renovate' }),
partial<Label>({ id: 2, name: 'Other label' }),
partial<Label>({ id: 3, name: 'Maintenance' }),
];
const mockIssue: Issue = {
number: 10,
title: 'label-issue',
body: 'label-body',
assignees: [],
labels: mockLabels,
state: 'open',
};
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, [mockIssue])
.patch('/repos/some/repo/issues/10')
.reply(200, mockIssue)
.get('/repos/some/repo/labels')
.reply(200, mockLabels)
.get('/orgs/some/labels')
.reply(200, [])
.put('/repos/some/repo/issues/10/labels', { labels: [1, 3] })
.reply(200, mockLabels);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureIssue({
title: mockIssue.title,
body: 'new-body',
labels: ['Renovate', 'Maintenance'],
});
expect(res).toBe('updated');
});
it('should reopen closed issue if desired', async () => {
const closedIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, mockIssues)
.patch('/repos/some/repo/issues/2', {
body: closedIssue.body,
state: 'open',
title: 'closed-issue',
})
.reply(200, { ...closedIssue, state: 'open' });
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureIssue({
title: closedIssue.title,
body: closedIssue.body,
shouldReOpen: true,
once: false,
});
expect(res).toBe('updated');
});
it('should not update existing closed issue if desired', async () => {
const closedIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, mockIssues);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureIssue({
title: closedIssue.title,
body: closedIssue.body,
shouldReOpen: false,
once: true,
});
expect(res).toBeNull();
});
it('should close all open duplicate issues except first one when updating', async () => {
const duplicates = mockIssues.filter(
(i) => i.title === 'duplicate-issue',
);
const [first, second, third] = duplicates;
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, duplicates)
.patch(`/repos/some/repo/issues/${second.number}`, {
state: 'closed',
})
.reply(200, { ...second, state: 'closed' })
.patch(`/repos/some/repo/issues/${third.number}`, {
state: 'closed',
})
.reply(200, { ...third, state: 'closed' });
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureIssue({
title: first.title,
body: first.body,
shouldReOpen: false,
once: false,
});
expect(res).toBeNull();
});
it('should reset issue cache when creating an issue', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.twice()
.reply(200, mockIssues)
.post('/repos/some/repo/issues')
.reply(200, { number: 42, title: 'new-title', body: 'new-body' });
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.ensureIssue({
title: 'new-title',
body: 'new-body',
shouldReOpen: false,
once: false,
}),
).resolves.toBe('created');
await expect(forgejo.getIssueList()).toResolve();
});
it('should gracefully fail with warning when ensuring issue', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.replyWithError('unknown');
await initFakePlatform(scope);
await initFakeRepo(scope);
await forgejo.ensureIssue({
title: 'new-title',
body: 'new-body',
shouldReOpen: false,
once: false,
});
expect(logger.logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Could not ensure issue',
);
});
it('should return null for disabled issues in ensureIssue', async () => {
const scope = httpMock.scope('https://code.forgejo.org/api/v1');
await initFakePlatform(scope);
await initFakeRepo(scope, { has_issues: false });
await expect(
forgejo.ensureIssue({
title: 'new-title',
body: 'new-body',
shouldReOpen: false,
once: false,
}),
).resolves.toBeNull();
});
});
describe('ensureIssueClosing', () => {
it('should close issues with matching title', async () => {
const mockIssue = mockIssues[0];
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues')
.query({ state: 'all', type: 'issues' })
.reply(200, mockIssues)
.patch('/repos/some/repo/issues/1', { state: 'closed' })
.reply(200, { ...mockIssue, state: 'closed' });
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.ensureIssueClosing(mockIssue.title)).toResolve();
});
it('should return for disabled issues', async () => {
const scope = httpMock.scope('https://code.forgejo.org/api/v1');
await initFakePlatform(scope);
await initFakeRepo(scope, { has_issues: false });
await expect(forgejo.ensureIssueClosing('new-title')).toResolve();
});
});
describe('deleteLabel', () => {
it('should delete a label which exists', async () => {
const mockLabel = mockRepoLabels[0];
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/orgs/some/labels')
.replyWithError('unknown')
.get('/repos/some/repo/labels')
.reply(200, mockRepoLabels)
.delete(`/repos/some/repo/issues/42/labels/${mockLabel.id}`)
.reply(200);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.deleteLabel(42, mockLabel.name)).toResolve();
});
it('should gracefully fail with warning if label is missing', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/orgs/some/labels')
.reply(200, [])
.get('/repos/some/repo/labels')
.reply(200, mockRepoLabels);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.deleteLabel(42, 'missing')).toResolve();
expect(logger.logger.warn).toHaveBeenCalledWith(
{ issue: 42, labelName: 'missing' },
'Failed to lookup label for deletion',
);
});
});
describe('ensureComment', () => {
it('should add comment with topic if not found', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1/comments')
.reply(200, mockComments)
.post('/repos/some/repo/issues/1/comments', {
body: '### other-topic\n\nother-content',
})
.reply(200, partial<Comment>({ id: 33, body: 'x' }));
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureComment({
number: 1,
topic: 'other-topic',
content: 'other-content',
});
expect(res).toBeTrue();
});
it('should add comment without topic if not found', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1/comments')
.reply(200, mockComments)
.post('/repos/some/repo/issues/1/comments', { body: 'other-content' })
.reply(200, partial<Comment>({ id: 33, body: 'x' }));
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureComment({
number: 1,
content: 'other-content',
topic: null,
});
expect(res).toBeTrue();
});
it('should update comment with topic if found', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1/comments')
.reply(200, mockComments)
.patch('/repos/some/repo/issues/comments/13', {
body: '### some-topic\n\nsome-new-content',
})
.reply(200, partial<Comment>({ id: 13, body: 'x' }));
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureComment({
number: 1,
topic: 'some-topic',
content: 'some-new-content',
});
expect(res).toBeTrue();
});
it('should skip if comment is up-to-date', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1/comments')
.reply(200, mockComments);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureComment({
number: 1,
topic: 'some-topic',
content: 'some-content',
});
expect(res).toBeTrue();
});
it('should gracefully fail with warning when ensuring comment', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1/comments')
.replyWithError('unknown');
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.ensureComment({
number: 1,
topic: 'some-topic',
content: 'some-content',
});
expect(res).toBe(false);
expect(logger.logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error), issue: 1, subject: 'some-topic' },
'Error ensuring comment',
);
});
});
describe('ensureCommentRemoval', () => {
it('should remove existing comment by topic', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1/comments')
.reply(200, mockComments)
.delete('/repos/some/repo/issues/comments/13')
.reply(200);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.ensureCommentRemoval({
type: 'by-topic',
number: 1,
topic: 'some-topic',
}),
).toResolve();
});
it('should remove existing comment by content', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1/comments')
.reply(200, mockComments)
.delete('/repos/some/repo/issues/comments/11')
.reply(200);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.ensureCommentRemoval({
type: 'by-content',
number: 1,
content: 'some-body',
}),
).toResolve();
});
it('should gracefully fail with warning when removing comment', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1/comments')
.reply(200, mockComments)
.delete('/repos/some/repo/issues/comments/13')
.replyWithError('unknown');
await initFakePlatform(scope);
await initFakeRepo(scope);
await forgejo.ensureCommentRemoval({
type: 'by-topic',
number: 1,
topic: 'some-topic',
});
expect(logger.logger.warn).toHaveBeenCalledWith(
{
config: { number: 1, topic: 'some-topic', type: 'by-topic' },
err: expect.any(Error),
issue: 1,
},
'Error deleting comment',
);
});
it('should abort silently if comment is missing', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/issues/1/comments')
.reply(200, mockComments);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.ensureCommentRemoval({
type: 'by-topic',
number: 1,
topic: 'missing',
}),
).toResolve();
});
});
describe('getBranchPr', () => {
it('should return existing pull request for branch', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getBranchPr('some-head-branch');
expect(res).toMatchObject({ number: 1 });
});
it('should return null if no pull request exists', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/pulls')
.query({ state: 'all', sort: 'recentupdate', limit: 100 })
.reply(200, mockPRs);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.getBranchPr('missing')).resolves.toBeNull();
});
});
describe('addAssignees', () => {
it('should add assignees to the issue', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.patch('/repos/some/repo/issues/1', {
assignees: ['me', 'you'],
})
.reply(200, mockIssues[0]);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.addAssignees(1, ['me', 'you'])).toResolve();
});
});
describe('addReviewers', () => {
it('should assign user and team reviewers', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls/1/requested_reviewers', {
reviewers: ['me', 'you'],
team_reviewers: ['team'],
})
.reply(200);
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(
forgejo.addReviewers(1, ['me', 'you', 'team:team']),
).toResolve();
expect(logger.logger.warn).not.toHaveBeenCalled();
});
it('should assign user reviewers', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls/1/requested_reviewers', {
reviewers: ['me', 'you'],
})
.reply(200);
await initFakePlatform(scope);
await initFakeRepo(scope, undefined, undefined, 404);
await expect(forgejo.addReviewers(1, ['me', 'you'])).toResolve();
expect(logger.logger.warn).not.toHaveBeenCalled();
});
it('catches errors', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.post('/repos/some/repo/pulls/1/requested_reviewers', {
reviewers: ['me', 'you'],
})
.replyWithError('unknown');
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.addReviewers(1, ['me', 'you'])).toResolve();
expect(logger.logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error), number: 1, reviewers: ['me', 'you'] },
'Failed to assign reviewer',
);
});
});
describe('massageMarkdown', () => {
it('replaces pr links', () => {
const body =
'[#123](../pull/123) [#124](../pull/124) [#125](../pull/125)';
expect(forgejo.massageMarkdown(body)).toBe(
'[#123](pulls/123) [#124](pulls/124) [#125](pulls/125)',
);
});
it('replaces issue links', () => {
const body =
'[#123](../issues/123) [#124](../issues/124) [#125](../issues/125)';
expect(forgejo.massageMarkdown(body)).toBe(
'[#123](issues/123) [#124](issues/124) [#125](issues/125)',
);
});
});
it('maxBodyLength', () => {
expect(forgejo.maxBodyLength()).toBe(1000000);
});
describe('getJsonFile()', () => {
it('returns file content', async () => {
const data = { foo: 'bar' };
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/contents/file.json')
.reply(200, {
type: 'file',
name: 'file.json',
path: 'file.json',
content: toBase64(JSON.stringify(data)),
});
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getJsonFile('file.json');
expect(res).toEqual(data);
});
it('returns file content from given repo', async () => {
const data = { foo: 'bar' };
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/different/repo/contents/file.json')
.reply(200, {
type: 'file',
name: 'file.json',
path: 'file.json',
content: toBase64(JSON.stringify(data)),
});
await initFakePlatform(scope);
await initFakeRepo(scope, { full_name: 'different/repo' });
const res = await forgejo.getJsonFile('file.json', 'different/repo');
expect(res).toEqual(data);
});
it('returns file content from branch or tag', async () => {
const data = { foo: 'bar' };
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/contents/file.json?ref=dev')
.reply(200, {
type: 'file',
name: 'file.json',
path: 'file.json',
content: toBase64(JSON.stringify(data)),
});
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getJsonFile('file.json', 'some/repo', 'dev');
expect(res).toEqual(data);
});
it('returns file content in json5 format', async () => {
const json5Data = `
{
// json5 comment
foo: 'bar'
}
`;
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/contents/file.json5')
.reply(200, {
type: 'file',
name: 'file.json5',
path: 'file.json5',
content: toBase64(json5Data),
});
await initFakePlatform(scope);
await initFakeRepo(scope);
const res = await forgejo.getJsonFile('file.json5');
expect(res).toEqual({ foo: 'bar' });
});
it('throws on malformed JSON', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/contents/file.json')
.reply(200, {
type: 'file',
name: 'file.json',
path: 'file.json',
content: toBase64('!@#'),
});
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.getJsonFile('file.json')).rejects.toThrow(
"JSON5: invalid character '!' at 1:1",
);
});
it('throws when file content is missing', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/contents/file.json')
.reply(200, { type: 'file', name: 'file.json', path: 'file.json' });
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.getJsonFile('file.json')).rejects.toThrow(
'Invalid input: expected string, received undefined',
);
});
it('returns null for non-file entries', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/contents/file.json')
.reply(200, { type: 'dir', name: 'file.json', path: 'file.json' });
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.getJsonFile('file.json')).resolves.toBeNull();
});
it('throws on errors', async () => {
const scope = httpMock
.scope('https://code.forgejo.org/api/v1')
.get('/repos/some/repo/contents/file.json')
.replyWithError('unknown');
await initFakePlatform(scope);
await initFakeRepo(scope);
await expect(forgejo.getJsonFile('file.json')).rejects.toThrow('unknown');
});
});
});