import * as httpMock from '~test/http-mock.ts'; import { fakeSha, logger, partial } from '~test/util.ts'; import { setBaseUrl } from '../../../util/http/forgejo.ts'; import { toBase64 } from '../../../util/string.ts'; import { closeIssue, closePR, createComment, createCommitStatus, createIssue, createPR, deleteComment, getBranch, getCombinedCommitStatus, getComments, getCurrentUser, getIssue, getOrgLabels, getPR, getPRByBranch, getRepo, getRepoContents, getRepoLabels, getVersion, isOrg, mergePR, orgListRepos, requestPrReviewers, searchIssues, searchRepos, unassignLabel, updateComment, updateIssue, updateIssueLabels, updatePR, } from './forgejo-helper.ts'; import type { Branch, Comment, Commit, CommitStatus, CommitStatusType, Issue, Label, PR, Repo, RepoContents, User, } from './schema.ts'; describe('modules/platform/forgejo/forgejo-helper', () => { const forgejoApiHost = 'https://forgejo.renovatebot.com/'; const baseUrl = `${forgejoApiHost}api/v1`; const mockCommitHash = fakeSha('forgejo-helper'); const mockUser: User = { id: 1, login: 'admin', full_name: 'The Administrator', email: 'admin@example.com', }; const otherMockUser: User & Required> = { ...mockUser, login: 'renovate', full_name: 'Renovate Bot', email: 'renovate@example.com', }; const mockRepo: Repo = partial({ id: 123, allow_fast_forward_only_merge: true, allow_rebase: true, allow_rebase_explicit: true, allow_merge_commits: true, allow_squash_merge: true, 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', archived: false, mirror: false, empty: false, fork: false, owner: mockUser, permissions: { pull: true, push: true, admin: false, }, has_issues: true, has_pull_requests: true, }); const otherMockRepo: Repo = { ...mockRepo, full_name: 'other/repo', clone_url: 'https://forgejo.renovatebot.com/other/repo.git', }; const mockLabel: Label = { id: 100, name: 'some-label', description: 'just a label', color: '#000000', }; const otherMockLabel: Label = { ...mockLabel, id: 200, name: 'other-label', }; const mockPR: PR = { number: 13, state: 'open', title: 'Some PR', body: 'Lorem ipsum dolor sit amet', mergeable: true, diff_url: `https://forgejo.renovatebot.com/${mockRepo.full_name}/pulls/13.diff`, base: { ref: mockRepo.default_branch }, head: { label: 'pull-req-13', sha: mockCommitHash, repo: { full_name: mockRepo.full_name }, }, created_at: '2018-08-13T20:45:37Z', closed_at: '2020-04-01T19:19:22Z', updated_at: '2020-04-01T19:19:22Z', }; const mockIssue: Issue = { number: 7, state: 'open', title: 'Some Issue', body: 'just some issue', assignees: [mockUser], labels: [], }; const mockComment: Comment = { id: 31, body: 'some-comment', }; const mockCommitStatus: CommitStatus = { id: 121, status: 'success', context: 'some-context', description: 'some-description', target_url: 'https://forgejo.renovatebot.com/commit-status', created_at: '2020-03-25T00:00:00Z', }; const otherMockCommitStatus: CommitStatus = { ...mockCommitStatus, id: 242, status: 'error', context: 'other-context', }; const mockCommit: Commit = { id: mockCommitHash, }; const mockBranch: Branch = { name: 'some-branch', commit: mockCommit, }; const otherMockBranch: Branch = { ...mockBranch, name: 'other/branch/with/slashes', }; const mockContents: RepoContents = { type: 'file', name: 'dummy.txt', path: 'dummy.txt', content: toBase64('top secret'), contentString: 'top secret', }; const otherMockContents: RepoContents = { ...mockContents, path: 'nested/path/dummy.txt', }; beforeEach(() => { setBaseUrl(forgejoApiHost); }); describe('getCurrentUser', () => { it('should call /api/v1/user endpoint', async () => { httpMock.scope(baseUrl).get('/user').reply(200, mockUser); const res = await getCurrentUser(); expect(res).toEqual(mockUser); }); }); describe('getVersion', () => { it('should call /api/v1/version endpoint', async () => { const version = '1.13.01.14.0+dev-754-g5d2b7ba63'; httpMock.scope(baseUrl).get('/version').reply(200, { version }); const res = await getVersion(); expect(res).toEqual(version); }); }); describe('isOrg', () => { it('should call /api/v1/orgs/[org] endpoint', async () => { httpMock .scope(baseUrl) .get(`/orgs/${mockRepo.owner.login}`) .reply(200, {}) .get(`/orgs/user`) .reply(404) .get(`/orgs/error`) .reply(503); await expect(isOrg(mockRepo.owner.login)).resolves.toEqual(true); // uses cached result await expect(isOrg(mockRepo.owner.login)).resolves.toEqual(true); await expect(isOrg('user')).resolves.toEqual(false); await expect(isOrg('error')).rejects.toThrow( 'Request failed with status code 503 (Service Unavailable)', ); }); }); describe('searchRepos', () => { it('should call /api/v1/repos/search endpoint', async () => { httpMock .scope(baseUrl) .get('/repos/search') .reply(200, { ok: true, data: [mockRepo, otherMockRepo], }); const res = await searchRepos({}); expect(res).toEqual([mockRepo, otherMockRepo]); }); it('should construct proper query parameters', async () => { httpMock .scope(baseUrl) .get('/repos/search?uid=13&archived=false') .reply(200, { ok: true, data: [otherMockRepo], }); const res = await searchRepos({ uid: 13, archived: false, }); expect(res).toEqual([otherMockRepo]); }); it('should abort if ok flag was not set', async () => { httpMock.scope(baseUrl).get('/repos/search').reply(200, { ok: false, data: [], }); await expect(searchRepos({})).rejects.toThrow( 'Unable to search for repositories, ok flag has not been set', ); }); }); describe('orgListRepos', () => { it('should call /api/v1/orgs/[organization]/repos endpoint', async () => { httpMock.scope(baseUrl).get('/orgs/some/repos').reply(200, [mockRepo]); const res = await orgListRepos('some'); expect(res).toEqual([mockRepo]); }); }); describe('getRepo', () => { it('should call /api/v1/repos/[repo] endpoint', async () => { httpMock .scope(baseUrl) .get(`/repos/${mockRepo.full_name}`) .reply(200, mockRepo); const res = await getRepo(mockRepo.full_name); expect(res).toEqual(mockRepo); }); }); describe('getRepoContents', () => { it('should call /api/v1/repos/[repo]/contents/[file] endpoint', async () => { // The official API only returns the base64-encoded content, so we strip `contentString` // from our mock to verify base64 decoding. httpMock .scope(baseUrl) .get(`/repos/${mockRepo.full_name}/contents/${mockContents.path}`) .reply(200, { ...mockContents, contentString: undefined }); const res = await getRepoContents(mockRepo.full_name, mockContents.path); expect(res).toEqual(mockContents); }); it('should support passing reference by query', async () => { httpMock .scope(baseUrl) .get( `/repos/${mockRepo.full_name}/contents/${mockContents.path}?ref=${mockCommitHash}`, ) .reply(200, { ...mockContents, contentString: undefined }); const res = await getRepoContents( mockRepo.full_name, mockContents.path, mockCommitHash, ); expect(res).toEqual(mockContents); }); it('should properly escape paths', async () => { const escapedPath = encodeURIComponent(otherMockContents.path); httpMock .scope(baseUrl) .get(`/repos/${mockRepo.full_name}/contents/${escapedPath}`) .reply(200, otherMockContents); const res = await getRepoContents( mockRepo.full_name, otherMockContents.path, ); expect(res).toEqual(otherMockContents); }); }); describe('createPR', () => { it('should call /api/v1/repos/[repo]/pulls endpoint', async () => { httpMock .scope(baseUrl) .post(`/repos/${mockRepo.full_name}/pulls`) .reply(200, mockPR); const res = await createPR(mockRepo.full_name, { state: mockPR.state, title: mockPR.title, body: mockPR.body, base: mockPR.base?.ref, head: mockPR.head?.label, assignees: [mockUser.login], labels: [mockLabel.id], }); expect(res).toEqual(mockPR); }); }); describe('updatePR', () => { it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => { const updatedMockPR: PR = { ...mockPR, state: 'closed', title: 'new-title', body: 'new-body', }; httpMock .scope(baseUrl) .patch(`/repos/${mockRepo.full_name}/pulls/${mockPR.number}`) .reply(200, updatedMockPR); const res = await updatePR(mockRepo.full_name, mockPR.number, { state: 'closed', title: 'new-title', body: 'new-body', assignees: [otherMockUser.login], labels: [otherMockLabel.id], }); expect(res).toEqual(updatedMockPR); }); }); describe('closePR', () => { it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => { httpMock .scope(baseUrl) .patch(`/repos/${mockRepo.full_name}/pulls/${mockPR.number}`) .reply(200, mockPR); await expect(closePR(mockRepo.full_name, mockPR.number)).toResolve(); }); }); describe('mergePR', () => { it('should call /api/v1/repos/[repo]/pulls/[pull]/merge endpoint', async () => { httpMock .scope(baseUrl) .post(`/repos/${mockRepo.full_name}/pulls/${mockPR.number}/merge`) .reply(200); await expect( mergePR(mockRepo.full_name, mockPR.number, { Do: 'rebase', }), ).toResolve(); }); }); describe('getPR', () => { it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => { httpMock .scope(baseUrl) .get(`/repos/${mockRepo.full_name}/pulls/${mockPR.number}`) .reply(200, mockPR); const res = await getPR(mockRepo.full_name, mockPR.number); expect(res).toEqual(mockPR); }); }); describe('getPRByBranch', () => { it('should call /api/v1/repos/[repo]/pulls/[base]/[head] endpoint', async () => { httpMock .scope(baseUrl) .get( `/repos/${mockRepo.full_name}/pulls/${mockPR.base!.ref}/${mockPR.head!.label}`, ) .reply(200, mockPR); const res = await getPRByBranch( mockRepo.full_name, mockPR.base!.ref, mockPR.head!.label, ); expect(res?.number).toEqual(mockPR.number); }); it('should return null if pr not found', async () => { httpMock .scope(baseUrl) .get( `/repos/${mockRepo.full_name}/pulls/${mockPR.base!.ref}/${mockPR.head!.label}`, ) .reply(404); const res = await getPRByBranch( mockRepo.full_name, mockPR.base!.ref, mockPR.head!.label, ); expect(res).toBeNull(); }); it('should log error', async () => { httpMock .scope(baseUrl) .get( `/repos/${mockRepo.full_name}/pulls/${mockPR.base!.ref}/${mockPR.head!.label}`, ) .reply(410); const res = await getPRByBranch( mockRepo.full_name, mockPR.base!.ref, mockPR.head!.label, ); expect(res).toBeNull(); expect(logger.logger.debug).toHaveBeenCalledWith( { err: expect.any(Object), }, 'Error while fetching PR', ); }); }); describe('addReviewers', () => { it('should call /api/v1/repos/[repo]/pulls/[pull]/requested_reviewers endpoint', async () => { httpMock .scope(baseUrl) .post( `/repos/${mockRepo.full_name}/pulls/${mockPR.number}/requested_reviewers`, ) .reply(200); await expect( requestPrReviewers(mockRepo.full_name, mockPR.number, {}), ).toResolve(); }); }); describe('createIssue', () => { it('should call /api/v1/repos/[repo]/issues endpoint', async () => { httpMock .scope(baseUrl) .post(`/repos/${mockRepo.full_name}/issues`) .reply(200, mockIssue); const res = await createIssue(mockRepo.full_name, { state: mockIssue.state, title: mockIssue.title, body: mockIssue.body, assignees: [mockUser.login], }); expect(res).toEqual(mockIssue); }); }); describe('updateIssue', () => { it('should call /api/v1/repos/[repo]/issues/[issue] endpoint', async () => { const updatedMockIssue: Issue = { ...mockIssue, state: 'closed', title: 'new-title', body: 'new-body', assignees: [otherMockUser], }; httpMock .scope(baseUrl) .patch(`/repos/${mockRepo.full_name}/issues/${mockIssue.number}`) .reply(200, updatedMockIssue); const res = await updateIssue(mockRepo.full_name, mockIssue.number, { state: 'closed', title: 'new-title', body: 'new-body', assignees: [otherMockUser.login], }); expect(res).toEqual(updatedMockIssue); }); }); describe('updateIssueLabels', () => { it('should call /api/v1/repos/[repo]/issues/[issue]/labels endpoint', async () => { const updatedMockLabels: Partial