From 3cc8aa87da66e244895d1c3596901b41b1132255 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Tue, 1 Aug 2023 10:06:05 +0200 Subject: [PATCH 01/32] Spike scmm --- lib/constants/platforms.ts | 3 +- lib/modules/platform/api.ts | 2 + lib/modules/platform/scm.ts | 1 + .../scmm/__snapshots__/index.spec.ts.snap | 235 ++ lib/modules/platform/scmm/index.md | 38 + lib/modules/platform/scmm/index.spec.ts | 2006 +++++++++++++++++ lib/modules/platform/scmm/index.ts | 531 +++++ lib/modules/platform/scmm/scmm-helper.ts | 190 ++ lib/modules/platform/scmm/types.ts | 80 + lib/modules/platform/scmm/utils.spec.ts | 58 + lib/modules/platform/scmm/utils.ts | 83 + 11 files changed, 3226 insertions(+), 1 deletion(-) create mode 100644 lib/modules/platform/scmm/__snapshots__/index.spec.ts.snap create mode 100644 lib/modules/platform/scmm/index.md create mode 100644 lib/modules/platform/scmm/index.spec.ts create mode 100644 lib/modules/platform/scmm/index.ts create mode 100644 lib/modules/platform/scmm/scmm-helper.ts create mode 100644 lib/modules/platform/scmm/types.ts create mode 100644 lib/modules/platform/scmm/utils.spec.ts create mode 100644 lib/modules/platform/scmm/utils.ts diff --git a/lib/constants/platforms.ts b/lib/constants/platforms.ts index d1ee8156312ba6..f91756f58c1bbc 100644 --- a/lib/constants/platforms.ts +++ b/lib/constants/platforms.ts @@ -7,7 +7,8 @@ export type PlatformId = | 'gitea' | 'github' | 'gitlab' - | 'local'; + | 'local' + | 'scmm'; export const GITEA_API_USING_HOST_TYPES = [ 'gitea', diff --git a/lib/modules/platform/api.ts b/lib/modules/platform/api.ts index 7d0ab19cee6bcb..04414b939b711f 100644 --- a/lib/modules/platform/api.ts +++ b/lib/modules/platform/api.ts @@ -8,6 +8,7 @@ import * as gitea from './gitea'; import * as github from './github'; import * as gitlab from './gitlab'; import * as local from './local'; +import * as scmm from './scmm'; import type { Platform } from './types'; const api = new Map(); @@ -22,3 +23,4 @@ api.set(gitea.id, gitea); api.set(github.id, github); api.set(gitlab.id, gitlab); api.set(local.id, local); +api.set(scmm.id, scmm) diff --git a/lib/modules/platform/scm.ts b/lib/modules/platform/scm.ts index 7adc03b7fad70f..63735d094789b8 100644 --- a/lib/modules/platform/scm.ts +++ b/lib/modules/platform/scm.ts @@ -17,6 +17,7 @@ platformScmImpls.set('gitea', DefaultGitScm); platformScmImpls.set('github', GithubScm); platformScmImpls.set('gitlab', DefaultGitScm); platformScmImpls.set('local', LocalFs); +platformScmImpls.set('scmm', DefaultGitScm) let _scm: PlatformScm | undefined; diff --git a/lib/modules/platform/scmm/__snapshots__/index.spec.ts.snap b/lib/modules/platform/scmm/__snapshots__/index.spec.ts.snap new file mode 100644 index 00000000000000..c08c86e20b0000 --- /dev/null +++ b/lib/modules/platform/scmm/__snapshots__/index.spec.ts.snap @@ -0,0 +1,235 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`modules/platform/gitea/index createPr should use base branch by default 1`] = ` +{ + "bodyStruct": { + "hash": "9d586a6aedc4e7cb205276933c9e474cd3c2b341d3340458c31eb750795f197d", + }, + "cannotMergeReason": undefined, + "createdAt": "2014-04-01T05:14:20Z", + "hasAssignees": false, + "isDraft": false, + "number": 42, + "sha": "0d9c7726c3d628b7e28af234595cfd20febdbf8e", + "sourceBranch": "pr-branch", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "devel", + "title": "pr-title", +} +`; + +exports[`modules/platform/gitea/index createPr should use default branch if requested 1`] = ` +{ + "bodyStruct": { + "hash": "9d586a6aedc4e7cb205276933c9e474cd3c2b341d3340458c31eb750795f197d", + }, + "cannotMergeReason": undefined, + "createdAt": "2014-04-01T05:14:20Z", + "hasAssignees": false, + "isDraft": false, + "number": 42, + "sha": "0d9c7726c3d628b7e28af234595cfd20febdbf8e", + "sourceBranch": "pr-branch", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "master", + "title": "pr-title", +} +`; + +exports[`modules/platform/gitea/index getPr should fallback to direct fetching if cache fails 1`] = ` +{ + "bodyStruct": { + "hash": "f41557d6153a316ee747e13de8952c4068de931585c1a18d095d6703254de6af", + }, + "cannotMergeReason": "pr.mergeable="false"", + "createdAt": "2015-03-22T20:36:16Z", + "hasAssignees": false, + "isDraft": false, + "number": 1, + "sha": "some-head-sha", + "sourceBranch": "some-head-branch", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "some-base-branch", + "title": "Some PR", +} +`; + +exports[`modules/platform/gitea/index getPr should return enriched pull request which exists if open 1`] = ` +{ + "bodyStruct": { + "hash": "f41557d6153a316ee747e13de8952c4068de931585c1a18d095d6703254de6af", + }, + "cannotMergeReason": undefined, + "createdAt": "2015-03-22T20:36:16Z", + "hasAssignees": false, + "isDraft": false, + "number": 1, + "sha": "some-head-sha", + "sourceBranch": "some-head-branch", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "some-base-branch", + "title": "Some PR", +} +`; + +exports[`modules/platform/gitea/index getPrList should filter list by creator 1`] = ` +{ + "endpoint": "https://gitea.com/", + "gitAuthor": "Renovate Bot ", +} +`; + +exports[`modules/platform/gitea/index getPrList should filter list by creator 2`] = ` +[ + { + "bodyStruct": { + "hash": "f41557d6153a316ee747e13de8952c4068de931585c1a18d095d6703254de6af", + }, + "cannotMergeReason": undefined, + "createdAt": "2015-03-22T20:36:16Z", + "hasAssignees": false, + "isDraft": false, + "number": 1, + "sha": "some-head-sha", + "sourceBranch": "some-head-branch", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "some-base-branch", + "title": "Some PR", + }, + { + "bodyStruct": { + "hash": "916e5965a20785df1883ff5dc219508a1070ae1f37ccb64e954526f3ca1d22f4", + }, + "cannotMergeReason": undefined, + "createdAt": "2011-08-18T22:30:38Z", + "hasAssignees": false, + "isDraft": false, + "number": 2, + "sha": "other-head-sha", + "sourceBranch": "other-head-branch", + "sourceRepo": "some/repo", + "state": "closed", + "targetBranch": "other-base-branch", + "title": "Other PR", + }, + { + "bodyStruct": { + "hash": "916e5965a20785df1883ff5dc219508a1070ae1f37ccb64e954526f3ca1d22f4", + }, + "cannotMergeReason": undefined, + "createdAt": "2011-08-18T22:30:39Z", + "hasAssignees": false, + "isDraft": true, + "number": 3, + "sha": "draft-head-sha", + "sourceBranch": "draft-head-branch", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "draft-base-branch", + "title": "Draft PR", + }, +] +`; + +exports[`modules/platform/gitea/index getPrList should return list of pull requests 1`] = ` +[ + { + "bodyStruct": { + "hash": "f41557d6153a316ee747e13de8952c4068de931585c1a18d095d6703254de6af", + }, + "cannotMergeReason": undefined, + "createdAt": "2015-03-22T20:36:16Z", + "hasAssignees": false, + "isDraft": false, + "number": 1, + "sha": "some-head-sha", + "sourceBranch": "some-head-branch", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "some-base-branch", + "title": "Some PR", + }, + { + "bodyStruct": { + "hash": "916e5965a20785df1883ff5dc219508a1070ae1f37ccb64e954526f3ca1d22f4", + }, + "cannotMergeReason": undefined, + "createdAt": "2011-08-18T22:30:38Z", + "hasAssignees": false, + "isDraft": false, + "number": 2, + "sha": "other-head-sha", + "sourceBranch": "other-head-branch", + "sourceRepo": "some/repo", + "state": "closed", + "targetBranch": "other-base-branch", + "title": "Other PR", + }, + { + "bodyStruct": { + "hash": "916e5965a20785df1883ff5dc219508a1070ae1f37ccb64e954526f3ca1d22f4", + }, + "cannotMergeReason": undefined, + "createdAt": "2011-08-18T22:30:39Z", + "hasAssignees": false, + "isDraft": true, + "number": 3, + "sha": "draft-head-sha", + "sourceBranch": "draft-head-branch", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "draft-base-branch", + "title": "Draft PR", + }, +] +`; + +exports[`modules/platform/gitea/index initPlatform() should support custom endpoint 1`] = ` +{ + "endpoint": "https://gitea.renovatebot.com/", + "gitAuthor": "Renovate Bot ", +} +`; + +exports[`modules/platform/gitea/index initPlatform() should support default endpoint 1`] = ` +{ + "endpoint": "https://gitea.com/", + "gitAuthor": "Renovate Bot ", +} +`; + +exports[`modules/platform/gitea/index initPlatform() should use username as author name if full name is missing 1`] = ` +{ + "endpoint": "https://gitea.com/", + "gitAuthor": "renovate ", +} +`; + +exports[`modules/platform/gitea/index initRepo should fall back to merge method "merge" 1`] = ` +{ + "defaultBranch": "master", + "isFork": false, + "repoFingerprint": "c48ad9428365701f1a7f4798a410db2401b13267c205e345beb5b469a4a1480b163e1ce663ce483cfe579b2748a807cbeeba2035dc55eca5fe46d60d182510ec", +} +`; + +exports[`modules/platform/gitea/index initRepo should fall back to merge method "rebase-merge" 1`] = ` +{ + "defaultBranch": "master", + "isFork": false, + "repoFingerprint": "c48ad9428365701f1a7f4798a410db2401b13267c205e345beb5b469a4a1480b163e1ce663ce483cfe579b2748a807cbeeba2035dc55eca5fe46d60d182510ec", +} +`; + +exports[`modules/platform/gitea/index initRepo should fall back to merge method "squash" 1`] = ` +{ + "defaultBranch": "master", + "isFork": false, + "repoFingerprint": "c48ad9428365701f1a7f4798a410db2401b13267c205e345beb5b469a4a1480b163e1ce663ce483cfe579b2748a807cbeeba2035dc55eca5fe46d60d182510ec", +} +`; diff --git a/lib/modules/platform/scmm/index.md b/lib/modules/platform/scmm/index.md new file mode 100644 index 00000000000000..bfcb5276148535 --- /dev/null +++ b/lib/modules/platform/scmm/index.md @@ -0,0 +1,38 @@ +# SCM-Manager + +Renovate supports [SCM-Manager](https://scm-manager.org). + +## Authentication + +First, [create a Personal Access Token (PAT)](https://docs.gitea.io/en-us/api-usage/#authentication) for the bot account. +The bot account should have full name and email address configured. +Then let Renovate use your PAT by doing _one_ of the following: + +- Set your PAT as a `token` in your `config.js` file +- Set your PAT as an environment variable `RENOVATE_TOKEN` +- Set your PAT when you run Renovate in the CLI with `--token=` + +You must set `platform=gitea` in your Renovate config file. + +The PAT should have these permissions: + +- `repo` +- `read:user` and `read:email` + +If you use Gitea packages, add the `read:packages` scope. + +## Unsupported platform features/concepts + +- **Adding reviewers to PRs not supported**: Gitea versions older than `v1.14.0` do not have the required API. +- **`platformAutomerge` (`true` by default) for platform-native automerge not supported**: Gitea versions older than v1.17.0 do not have the required API. +- **Git upload filters**: If you're using a Gitea version older than `v1.16.0` then you must enable [clone filters](https://docs.gitea.io/en-us/clone-filters/). + +## Features awaiting implementation + +- none + +## Repo autodiscover sorting + +You can change the default server-side sort method and order for autodiscover API. +Set those via [`RENOVATE_X_AUTODISCOVER_REPO_SORT`](https://docs.renovatebot.com/self-hosted-experimental/#renovate_x_autodiscover_repo_sort) and [`RENOVATE_X_AUTODISCOVER_REPO_ORDER`](https://docs.renovatebot.com/self-hosted-experimental/#renovate_x_autodiscover_repo_order). +Read the [Gitea swagger docs](https://try.gitea.io/api/swagger#/repository/repoSearch) for more details. diff --git a/lib/modules/platform/scmm/index.spec.ts b/lib/modules/platform/scmm/index.spec.ts new file mode 100644 index 00000000000000..b534ad8c38f0fe --- /dev/null +++ b/lib/modules/platform/scmm/index.spec.ts @@ -0,0 +1,2006 @@ +import type { + BranchStatusConfig, + EnsureIssueConfig, + Platform, + RepoParams, + RepoResult, +} from '..'; +import { mocked, partial } from '../../../../test/util'; +import { + CONFIG_GIT_URL_UNAVAILABLE, + REPOSITORY_ACCESS_FORBIDDEN, + REPOSITORY_ARCHIVED, + REPOSITORY_BLOCKED, + REPOSITORY_CHANGED, + REPOSITORY_EMPTY, + REPOSITORY_MIRRORED, +} from '../../../constants/error-messages'; +import type { logger as _logger } from '../../../logger'; +import type { BranchStatus, PrState } from '../../../types'; +import type * as _git from '../../../util/git'; +import { setBaseUrl } from '../../../util/http/gitea'; +import type { PlatformResult } from '../types'; +import type { + Branch, + CombinedCommitStatus, + Comment, + CommitStatus, + CommitStatusType, + CommitUser, + Issue, + Label, + PR, + Repo, + RepoContents, + User, +} from './types'; + +/** + * latest tested gitea version. + */ +const GITEA_VERSION = '1.14.0+dev-754-g5d2b7ba63'; + +describe('modules/platform/gitea/index', () => { + let gitea: Platform; + let helper: jest.Mocked; + let logger: jest.Mocked; + let gitvcs: jest.Mocked; + let hostRules: jest.Mocked; + + const mockCommitHash = '0d9c7726c3d628b7e28af234595cfd20febdbf8e'; + + const mockUser: User = { + id: 1, + username: 'renovate', + full_name: 'Renovate Bot', + email: 'renovate@example.com', + }; + + const mockRepo = partial({ + allow_rebase: true, + clone_url: 'https://gitea.renovatebot.com/some/repo.git', + ssh_url: 'git@gitea.renovatebot.com/some/repo.git', + default_branch: 'master', + full_name: 'some/repo', + permissions: { + pull: true, + push: true, + admin: false, + }, + }); + + type MockPr = PR & Required>; + + const mockRepos: Repo[] = [ + partial({ full_name: 'a/b' }), + partial({ full_name: 'c/d' }), + partial({ full_name: 'e/f', mirror: true }), + ]; + + const mockPRs: MockPr[] = [ + partial({ + number: 1, + title: 'Some PR', + body: 'some random pull request', + state: 'open', + diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/1.diff', + created_at: '2015-03-22T20:36:16Z', + closed_at: undefined, + mergeable: true, + base: { ref: 'some-base-branch' }, + head: { + label: 'some-head-branch', + sha: 'some-head-sha', + repo: partial({ full_name: mockRepo.full_name }), + }, + }), + partial({ + number: 2, + title: 'Other PR', + body: 'other random pull request', + state: 'closed', + diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/2.diff', + created_at: '2011-08-18T22:30:38Z', + closed_at: '2016-01-09T10:03:21Z', + mergeable: true, + base: { ref: 'other-base-branch' }, + head: { + label: 'other-head-branch', + sha: 'other-head-sha', + repo: partial({ full_name: mockRepo.full_name }), + }, + }), + partial({ + number: 3, + title: 'WIP: Draft PR', + body: 'other random pull request', + state: 'open', + diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/3.diff', + created_at: '2011-08-18T22:30:39Z', + closed_at: '2016-01-09T10:03:22Z', + mergeable: true, + base: { ref: 'draft-base-branch' }, + head: { + label: 'draft-head-branch', + sha: 'draft-head-sha', + repo: partial({ full_name: mockRepo.full_name }), + }, + }), + ]; + + 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 as never, // 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(async () => { + jest.resetModules(); + jest.mock('./gitea-helper'); + jest.mock('../../../util/git'); + jest.mock('../../../logger'); + + gitea = await import('.'); + helper = mocked(await import('./scmm-helper')); + logger = mocked((await import('../../../logger')).logger); + gitvcs = require('../../../util/git'); + gitvcs.isBranchBehindBase.mockResolvedValue(false); + gitvcs.getBranchCommit.mockReturnValue(mockCommitHash); + hostRules = mocked(await import('../../../util/host-rules')); + hostRules.clear(); + + setBaseUrl('https://gitea.renovatebot.com/'); + + delete process.env.RENOVATE_X_AUTODISCOVER_REPO_SORT; + delete process.env.RENOVATE_X_AUTODISCOVER_REPO_ORDER; + }); + + function initFakePlatform(version = GITEA_VERSION): Promise { + helper.getCurrentUser.mockResolvedValueOnce(mockUser); + helper.getVersion.mockResolvedValueOnce(version); + return gitea.initPlatform({ token: 'abc' }); + } + + function initFakeRepo( + repo?: Partial, + config?: Partial + ): Promise { + helper.getRepo.mockResolvedValueOnce({ ...mockRepo, ...repo }); + + return gitea.initRepo({ + repository: mockRepo.full_name, + ...config, + }); + } + + describe('initPlatform()', () => { + it('should throw if no token', async () => { + await expect(gitea.initPlatform({})).rejects.toThrow(); + }); + + it('should throw if auth fails', async () => { + helper.getCurrentUser.mockRejectedValueOnce(new Error()); + + await expect( + gitea.initPlatform({ token: 'some-token' }) + ).rejects.toThrow(); + }); + + it('should support default endpoint', async () => { + helper.getCurrentUser.mockResolvedValueOnce(mockUser); + + expect( + await gitea.initPlatform({ token: 'some-token' }) + ).toMatchSnapshot(); + }); + + it('should support custom endpoint', async () => { + helper.getCurrentUser.mockResolvedValueOnce(mockUser); + + expect( + await gitea.initPlatform({ + token: 'some-token', + endpoint: 'https://gitea.renovatebot.com', + }) + ).toMatchSnapshot(); + }); + + it('should support custom endpoint including api path', async () => { + helper.getCurrentUser.mockResolvedValueOnce(mockUser); + + expect( + await gitea.initPlatform({ + token: 'some-token', + endpoint: 'https://gitea.renovatebot.com/api/v1', + }) + ).toMatchObject({ + endpoint: 'https://gitea.renovatebot.com/', + }); + }); + + it('should use username as author name if full name is missing', async () => { + helper.getCurrentUser.mockResolvedValueOnce({ + ...mockUser, + full_name: undefined, + }); + + expect( + await gitea.initPlatform({ token: 'some-token' }) + ).toMatchSnapshot(); + }); + }); + + describe('getRepos', () => { + it('should propagate any other errors', async () => { + helper.searchRepos.mockRejectedValueOnce(new Error('searchRepos()')); + + await expect(gitea.getRepos()).rejects.toThrow('searchRepos()'); + }); + + it('should return an array of repos', async () => { + helper.searchRepos.mockResolvedValueOnce(mockRepos); + + const repos = await gitea.getRepos(); + expect(repos).toEqual(['a/b', 'c/d']); + expect(helper.searchRepos).toHaveBeenCalledWith({ + uid: undefined, + archived: false, + }); + }); + + it('Sorts repos', async () => { + process.env.RENOVATE_X_AUTODISCOVER_REPO_SORT = 'updated'; + process.env.RENOVATE_X_AUTODISCOVER_REPO_ORDER = 'desc'; + helper.searchRepos.mockResolvedValueOnce(mockRepos); + + const repos = await gitea.getRepos(); + expect(repos).toEqual(['a/b', 'c/d']); + + expect(helper.searchRepos).toHaveBeenCalledWith({ + uid: undefined, + archived: false, + sort: 'updated', + order: 'desc', + }); + }); + }); + + describe('initRepo', () => { + const initRepoCfg: RepoParams = { + repository: mockRepo.full_name, + }; + + it('should propagate API errors', async () => { + helper.getRepo.mockRejectedValueOnce(new Error('getRepo()')); + + await expect(gitea.initRepo(initRepoCfg)).rejects.toThrow('getRepo()'); + }); + + it('should abort when repo is archived', async () => { + await expect(initFakeRepo({ archived: true })).rejects.toThrow( + REPOSITORY_ARCHIVED + ); + }); + + it('should abort when repo is mirrored', async () => { + await expect(initFakeRepo({ mirror: true })).rejects.toThrow( + REPOSITORY_MIRRORED + ); + }); + + it('should abort when repo is empty', async () => { + await expect(initFakeRepo({ empty: true })).rejects.toThrow( + REPOSITORY_EMPTY + ); + }); + + it('should abort when repo has insufficient permissions', async () => { + await expect( + initFakeRepo({ + permissions: { + pull: false, + push: false, + admin: false, + }, + }) + ).rejects.toThrow(REPOSITORY_ACCESS_FORBIDDEN); + }); + + it('should abort when repo has no available merge methods', async () => { + await expect(initFakeRepo({ allow_rebase: false })).rejects.toThrow( + REPOSITORY_BLOCKED + ); + }); + + it('should fall back to merge method "rebase-merge"', async () => { + expect( + await initFakeRepo({ allow_rebase: false, allow_rebase_explicit: true }) + ).toMatchSnapshot(); + }); + + it('should fall back to merge method "squash"', async () => { + expect( + await initFakeRepo({ allow_rebase: false, allow_squash_merge: true }) + ).toMatchSnapshot(); + }); + + it('should fall back to merge method "merge"', async () => { + expect( + await initFakeRepo({ + allow_rebase: false, + allow_merge_commits: true, + }) + ).toMatchSnapshot(); + }); + + it('should use clone_url of repo if gitUrl is not specified', async () => { + expect.assertions(1); + + helper.getRepo.mockResolvedValueOnce(mockRepo); + const repoCfg: RepoParams = { + repository: mockRepo.full_name, + }; + await gitea.initRepo(repoCfg); + + expect(gitvcs.initRepo).toHaveBeenCalledWith( + expect.objectContaining({ url: mockRepo.clone_url }) + ); + }); + + it('should use clone_url of repo if gitUrl has value default', async () => { + expect.assertions(1); + + helper.getRepo.mockResolvedValueOnce(mockRepo); + const repoCfg: RepoParams = { + repository: mockRepo.full_name, + gitUrl: 'default', + }; + await gitea.initRepo(repoCfg); + + expect(gitvcs.initRepo).toHaveBeenCalledWith( + expect.objectContaining({ url: mockRepo.clone_url }) + ); + }); + + it('should use ssh_url of repo if gitUrl has value ssh', async () => { + expect.assertions(1); + + helper.getRepo.mockResolvedValueOnce(mockRepo); + const repoCfg: RepoParams = { + repository: mockRepo.full_name, + gitUrl: 'ssh', + }; + await gitea.initRepo(repoCfg); + + expect(gitvcs.initRepo).toHaveBeenCalledWith( + expect.objectContaining({ url: mockRepo.ssh_url }) + ); + }); + + it('should abort when gitUrl has value ssh but ssh_url is empty', async () => { + expect.assertions(1); + + helper.getRepo.mockResolvedValueOnce({ ...mockRepo, ssh_url: undefined }); + const repoCfg: RepoParams = { + repository: mockRepo.full_name, + gitUrl: 'ssh', + }; + + await expect(gitea.initRepo(repoCfg)).rejects.toThrow( + CONFIG_GIT_URL_UNAVAILABLE + ); + }); + + it('should use generated url of repo if gitUrl has value endpoint', async () => { + expect.assertions(1); + + helper.getRepo.mockResolvedValueOnce(mockRepo); + const repoCfg: RepoParams = { + repository: mockRepo.full_name, + gitUrl: 'endpoint', + }; + await gitea.initRepo(repoCfg); + + expect(gitvcs.initRepo).toHaveBeenCalledWith( + expect.objectContaining({ + url: `https://gitea.com/${mockRepo.full_name}.git`, + }) + ); + }); + + it('should abort when clone_url is empty', async () => { + expect.assertions(1); + + helper.getRepo.mockResolvedValueOnce({ + ...mockRepo, + clone_url: undefined, + }); + const repoCfg: RepoParams = { + repository: mockRepo.full_name, + }; + + await expect(gitea.initRepo(repoCfg)).rejects.toThrow( + CONFIG_GIT_URL_UNAVAILABLE + ); + }); + + it('should use given access token if gitUrl has value endpoint', async () => { + expect.assertions(1); + + const token = 'abc'; + hostRules.add({ + hostType: 'gitea', + matchHost: 'https://gitea.com/', + token, + }); + + helper.getRepo.mockResolvedValueOnce(mockRepo); + const repoCfg: RepoParams = { + repository: mockRepo.full_name, + gitUrl: 'endpoint', + }; + await gitea.initRepo(repoCfg); + + // TODO: types (#7154) + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const url = new URL(`${mockRepo.clone_url}`); + url.username = token; + expect(gitvcs.initRepo).toHaveBeenCalledWith( + expect.objectContaining({ + url: `https://${token}@gitea.com/${mockRepo.full_name}.git`, + }) + ); + }); + + it('should use given access token if gitUrl is not specified', async () => { + expect.assertions(1); + + const token = 'abc'; + hostRules.add({ + hostType: 'gitea', + matchHost: 'https://gitea.com/', + token, + }); + + helper.getRepo.mockResolvedValueOnce(mockRepo); + const repoCfg: RepoParams = { + repository: mockRepo.full_name, + }; + await gitea.initRepo(repoCfg); + + // TODO: types (#7154) + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const url = new URL(`${mockRepo.clone_url}`); + url.username = token; + expect(gitvcs.initRepo).toHaveBeenCalledWith( + expect.objectContaining({ url: url.toString() }) + ); + }); + + it('should abort when clone_url is not valid', async () => { + expect.assertions(1); + + helper.getRepo.mockResolvedValueOnce({ + ...mockRepo, + clone_url: 'abc', + }); + const repoCfg: RepoParams = { + repository: mockRepo.full_name, + }; + + await expect(gitea.initRepo(repoCfg)).rejects.toThrow( + CONFIG_GIT_URL_UNAVAILABLE + ); + }); + }); + + describe('setBranchStatus', () => { + const setBranchStatus = async (bsc?: Partial) => { + await initFakeRepo(); + await gitea.setBranchStatus({ + branchName: 'some-branch', + state: 'green', + context: 'some-context', + description: 'some-description', + ...bsc, + }); + }; + + it('should create a new commit status', async () => { + await setBranchStatus(); + + expect(helper.createCommitStatus).toHaveBeenCalledTimes(1); + expect(helper.createCommitStatus).toHaveBeenCalledWith( + mockRepo.full_name, + mockCommitHash, + { + state: 'success', + context: 'some-context', + description: 'some-description', + } + ); + }); + + it('should default to pending state', async () => { + await setBranchStatus({ state: undefined }); + + expect(helper.createCommitStatus).toHaveBeenCalledTimes(1); + expect(helper.createCommitStatus).toHaveBeenCalledWith( + mockRepo.full_name, + mockCommitHash, + { + state: 'pending', + context: 'some-context', + description: 'some-description', + } + ); + }); + + it('should include url if specified', async () => { + await setBranchStatus({ url: 'some-url' }); + + expect(helper.createCommitStatus).toHaveBeenCalledTimes(1); + expect(helper.createCommitStatus).toHaveBeenCalledWith( + mockRepo.full_name, + mockCommitHash, + { + state: 'success', + context: 'some-context', + description: 'some-description', + target_url: 'some-url', + } + ); + }); + + it('should gracefully fail with warning', async () => { + helper.createCommitStatus.mockRejectedValueOnce(new Error()); + await setBranchStatus(); + + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + }); + + describe('getBranchStatus', () => { + const getBranchStatus = async (state: string): Promise => { + await initFakeRepo(); + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial({ + worstStatus: state as CommitStatusType, + }) + ); + + return gitea.getBranchStatus('some-branch', true); + }; + + it('should return yellow for unknown result', async () => { + expect(await getBranchStatus('unknown')).toBe('yellow'); + }); + + it('should return pending state for pending result', async () => { + expect(await getBranchStatus('pending')).toBe('yellow'); + }); + + it('should return success state for success result', async () => { + expect(await getBranchStatus('success')).toBe('green'); + }); + + it('should return null for all other results', async () => { + expect(await getBranchStatus('invalid')).toBe('yellow'); + }); + + it('should abort when branch status returns 404', async () => { + helper.getCombinedCommitStatus.mockRejectedValueOnce({ statusCode: 404 }); + + await expect(gitea.getBranchStatus('some-branch', true)).rejects.toThrow( + REPOSITORY_CHANGED + ); + }); + + it('should propagate any other errors', async () => { + helper.getCombinedCommitStatus.mockRejectedValueOnce( + new Error('getCombinedCommitStatus()') + ); + + await expect(gitea.getBranchStatus('some-branch', true)).rejects.toThrow( + 'getCombinedCommitStatus()' + ); + }); + + it('should treat internal checks as success', async () => { + helper.getCombinedCommitStatus.mockResolvedValueOnce({ + worstStatus: 'success', + statuses: [ + { + id: 1, + status: 'success', + context: 'renovate/stability-days', + description: 'internal check', + target_url: '', + created_at: '', + }, + ], + }); + expect(await gitea.getBranchStatus('some-branch', true)).toBe('green'); + }); + + it('should not treat internal checks as success', async () => { + await initFakeRepo(); + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial({ + worstStatus: 'success', + statuses: [ + { + id: 1, + status: 'success', + context: 'renovate/stability-days', + description: 'internal check', + target_url: '', + created_at: '', + }, + ], + }) + ); + expect(await gitea.getBranchStatus('some-branch', false)).toBe('yellow'); + }); + }); + + describe('getBranchStatusCheck', () => { + it('should return null with no results', async () => { + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial({ + statuses: [], + }) + ); + + expect( + await gitea.getBranchStatusCheck('some-branch', 'some-context') + ).toBeNull(); + }); + + it('should return null with no matching results', async () => { + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial({ + statuses: [partial({ context: 'other-context' })], + }) + ); + + expect( + await gitea.getBranchStatusCheck('some-branch', 'some-context') + ).toBeNull(); + }); + + it('should return yellow with unknown status', async () => { + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial({ + statuses: [ + partial({ + context: 'some-context', + }), + ], + }) + ); + + expect( + await gitea.getBranchStatusCheck('some-branch', 'some-context') + ).toBe('yellow'); + }); + + it('should return green of matching result', async () => { + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial({ + statuses: [ + partial({ + status: 'success', + context: 'some-context', + }), + ], + }) + ); + + expect( + await gitea.getBranchStatusCheck('some-branch', 'some-context') + ).toBe('green'); + }); + }); + + describe('getPrList', () => { + it('should return list of pull requests', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + const res = await gitea.getPrList(); + expect(res).toHaveLength(mockPRs.length); + expect(res).toMatchSnapshot(); + }); + + it('should filter list by creator', async () => { + helper.getCurrentUser.mockResolvedValueOnce(mockUser); + + expect( + await gitea.initPlatform({ token: 'some-token' }) + ).toMatchSnapshot(); + + await initFakeRepo(); + + helper.searchPRs.mockResolvedValueOnce([ + partial({ + number: 3, + title: 'Third-party PR', + body: 'other random pull request', + state: 'open', + diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/3.diff', + created_at: '2011-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: 'other-head-sha', + repo: partial({ full_name: mockRepo.full_name }), + }, + user: { username: 'not-renovate' }, + }), + ...mockPRs.map((pr) => ({ ...pr, user: { username: 'renovate' } })), + ]); + + const res = await gitea.getPrList(); + expect(res).toHaveLength(mockPRs.length); + expect(res).toMatchSnapshot(); + }); + + it('should cache results after first query', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + const res1 = await gitea.getPrList(); + const res2 = await gitea.getPrList(); + expect(res1).toEqual(res2); + expect(helper.searchPRs).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPr', () => { + it('should return enriched pull request which exists if open', async () => { + const mockPR = mockPRs[0]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + helper.getBranch.mockResolvedValueOnce( + partial({ + commit: { + id: mockCommitHash, + author: partial({ + email: 'renovate@whitesourcesoftware.com', + }), + }, + }) + ); + await initFakeRepo(); + + const res = await gitea.getPr(mockPR.number); + expect(res).toHaveProperty('number', mockPR.number); + expect(res).toMatchSnapshot(); + }); + + it('should fallback to direct fetching if cache fails', async () => { + const mockPR = mockPRs[0]; + helper.searchPRs.mockResolvedValueOnce([]); + helper.getPR.mockResolvedValueOnce({ ...mockPR, mergeable: false }); + await initFakeRepo(); + + const res = await gitea.getPr(mockPR.number); + expect(res).toHaveProperty('number', mockPR.number); + expect(res).toMatchSnapshot(); + expect(helper.getPR).toHaveBeenCalledTimes(1); + }); + + it('should return null for missing pull request', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + expect(await gitea.getPr(42)).toBeNull(); + }); + + it('should block modified pull request for rebasing', async () => { + const mockPR = mockPRs[0]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + const res = await gitea.getPr(mockPR.number); + expect(res).toHaveProperty('number', mockPR.number); + }); + }); + + describe('findPr', () => { + it('should find pull request without title or state', async () => { + const mockPR = mockPRs[0]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + const res = await gitea.findPr({ branchName: mockPR.head.label }); + expect(res).toHaveProperty('sourceBranch', mockPR.head.label); + }); + + it('should find pull request with title', async () => { + const mockPR = mockPRs[0]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + const res = await gitea.findPr({ + branchName: mockPR.head.label, + prTitle: mockPR.title, + }); + expect(res).toHaveProperty('sourceBranch', mockPR.head.label); + expect(res).toHaveProperty('title', mockPR.title); + }); + + it('should find pull request with state', async () => { + const mockPR = mockPRs[1]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + const res = await gitea.findPr({ + branchName: mockPR.head.label, + state: mockPR.state, + }); + expect(res).toHaveProperty('sourceBranch', mockPR.head.label); + expect(res).toHaveProperty('state', mockPR.state); + }); + + it('should not find pull request with inverted state', async () => { + const mockPR = mockPRs[1]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + expect( + await gitea.findPr({ + branchName: mockPR.head.label, + state: `!${mockPR.state as PrState}` as never, // wrong argument being passed intentionally + }) + ).toBeNull(); + }); + + it('should find pull request with title and state', async () => { + const mockPR = mockPRs[1]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + const res = await gitea.findPr({ + branchName: mockPR.head.label, + prTitle: mockPR.title, + state: mockPR.state, + }); + expect(res).toHaveProperty('sourceBranch', mockPR.head.label); + expect(res).toHaveProperty('title', mockPR.title); + expect(res).toHaveProperty('state', mockPR.state); + }); + + it('should find pull request with draft', async () => { + const mockPR = mockPRs[2]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + const res = await gitea.findPr({ + branchName: mockPR.head.label, + prTitle: 'Draft PR', + state: mockPR.state, + }); + expect(res).toHaveProperty('sourceBranch', mockPR.head.label); + expect(res).toHaveProperty('title', 'Draft PR'); + expect(res).toHaveProperty('state', mockPR.state); + }); + + it('should return null for missing pull request', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + expect(await gitea.findPr({ branchName: 'missing' })).toBeNull(); + }); + }); + + describe('createPr', () => { + const mockNewPR: MockPr = { + number: 42, + state: 'open', + head: { + label: 'pr-branch', + sha: mockCommitHash, + repo: partial({ full_name: mockRepo.full_name }), + }, + base: { + ref: mockRepo.default_branch, + }, + diff_url: 'https://gitea.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', + }; + + it('should use base branch by default', async () => { + helper.createPR.mockResolvedValueOnce({ + ...mockNewPR, + base: { ref: 'devel' }, + }); + + await initFakeRepo(); + const res = await gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'devel', + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + }); + + expect(res).toHaveProperty('number', mockNewPR.number); + expect(res).toHaveProperty('targetBranch', 'devel'); + expect(res).toMatchSnapshot(); + expect(helper.createPR).toHaveBeenCalledTimes(1); + expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, { + base: 'devel', + head: mockNewPR.head.label, + title: mockNewPR.title, + body: mockNewPR.body, + labels: [], + }); + }); + + it('should use default branch if requested', async () => { + helper.createPR.mockResolvedValueOnce(mockNewPR); + + await initFakeRepo(); + const res = await gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'master', + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + draftPR: true, + }); + + expect(res).toHaveProperty('number', mockNewPR.number); + expect(res).toHaveProperty('targetBranch', mockNewPR.base.ref); + expect(res).toMatchSnapshot(); + expect(helper.createPR).toHaveBeenCalledTimes(1); + expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, { + base: mockNewPR.base.ref, + head: mockNewPR.head.label, + title: `WIP: ${mockNewPR.title}`, + body: mockNewPR.body, + labels: [], + }); + }); + + it('should resolve and apply optional labels to pull request', async () => { + helper.createPR.mockResolvedValueOnce(mockNewPR); + helper.getRepoLabels.mockResolvedValueOnce(mockRepoLabels); + helper.getOrgLabels.mockResolvedValueOnce(mockOrgLabels); + + const mockLabels = mockRepoLabels.concat(mockOrgLabels); + + await initFakeRepo(); + await gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'master', + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + labels: mockLabels.map((l) => l.name), + }); + + expect(helper.createPR).toHaveBeenCalledTimes(1); + expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, { + base: mockNewPR.base.ref, + head: mockNewPR.head.label, + title: mockNewPR.title, + body: mockNewPR.body, + labels: mockLabels.map((l) => l.id), + }); + }); + + it('should ensure new pull request gets added to cached pull requests', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + helper.createPR.mockResolvedValueOnce(mockNewPR); + + await initFakeRepo(); + await gitea.getPrList(); + await gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'master', + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + }); + const res = gitea.getPr(mockNewPR.number); + + expect(res).not.toBeNull(); + expect(helper.searchPRs).toHaveBeenCalledTimes(1); + }); + + it('should attempt to resolve 409 conflict error (w/o update)', async () => { + helper.createPR.mockRejectedValueOnce({ statusCode: 409 }); + helper.searchPRs.mockResolvedValueOnce([mockNewPR]); + + await initFakeRepo(); + const res = await gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'master', + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + }); + + expect(res).toHaveProperty('number', mockNewPR.number); + }); + + it('should attempt to resolve 409 conflict error (w/ update)', async () => { + helper.createPR.mockRejectedValueOnce({ statusCode: 409 }); + helper.searchPRs.mockResolvedValueOnce([mockNewPR]); + + await initFakeRepo(); + const res = await gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'master', + prTitle: 'new-title', + prBody: 'new-body', + }); + + expect(res).toHaveProperty('number', mockNewPR.number); + expect(helper.updatePR).toHaveBeenCalledTimes(1); + expect(helper.updatePR).toHaveBeenCalledWith( + mockRepo.full_name, + mockNewPR.number, + { title: 'new-title', body: 'new-body' } + ); + }); + + it('should abort when response for created pull request is invalid', async () => { + helper.createPR.mockResolvedValueOnce(partial()); + + await initFakeRepo(); + await expect( + gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'master', + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + }) + ).rejects.toThrow(); + }); + + it('should use platform automerge', async () => { + helper.createPR.mockResolvedValueOnce(mockNewPR); + await initFakePlatform('1.17.0'); + await initFakeRepo(); + const res = await gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'master', + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + platformOptions: { usePlatformAutomerge: true }, + }); + + expect(res).toHaveProperty('number', mockNewPR.number); + expect(res).toHaveProperty('targetBranch', mockNewPR.base.ref); + + expect(helper.createPR).toHaveBeenCalledTimes(1); + expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, { + base: mockNewPR.base.ref, + head: mockNewPR.head.label, + title: mockNewPR.title, + body: mockNewPR.body, + labels: [], + }); + expect(helper.mergePR).toHaveBeenCalledWith( + mockRepo.full_name, + mockNewPR.number, + { + Do: 'rebase', + merge_when_checks_succeed: true, + } + ); + }); + + it('continues on platform automerge error', async () => { + helper.createPR.mockResolvedValueOnce(mockNewPR); + await initFakePlatform('1.17.0'); + await initFakeRepo(); + helper.mergePR.mockRejectedValueOnce(new Error('fake')); + const res = await gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'master', + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + platformOptions: { usePlatformAutomerge: true }, + }); + + expect(res).toHaveProperty('number', mockNewPR.number); + expect(res).toHaveProperty('targetBranch', mockNewPR.base.ref); + + expect(helper.createPR).toHaveBeenCalledTimes(1); + expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, { + base: mockNewPR.base.ref, + head: mockNewPR.head.label, + title: mockNewPR.title, + body: mockNewPR.body, + labels: [], + }); + expect(helper.mergePR).toHaveBeenCalledWith( + mockRepo.full_name, + mockNewPR.number, + { + Do: 'rebase', + merge_when_checks_succeed: true, + } + ); + }); + + it('continues if platform automerge is not supported', async () => { + helper.createPR.mockResolvedValueOnce(mockNewPR); + await initFakeRepo(); + const res = await gitea.createPr({ + sourceBranch: mockNewPR.head.label, + targetBranch: 'master', + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + platformOptions: { usePlatformAutomerge: true }, + }); + + expect(res).toHaveProperty('number', mockNewPR.number); + expect(res).toHaveProperty('targetBranch', mockNewPR.base.ref); + + expect(helper.createPR).toHaveBeenCalledTimes(1); + expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, { + base: mockNewPR.base.ref, + head: mockNewPR.head.label, + title: mockNewPR.title, + body: mockNewPR.body, + labels: [], + }); + expect(helper.mergePR).not.toHaveBeenCalled(); + }); + }); + + describe('updatePr', () => { + it('should update pull request with title', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + await gitea.updatePr({ number: 1, prTitle: 'New Title' }); + + expect(helper.updatePR).toHaveBeenCalledTimes(1); + expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, { + title: 'New Title', + }); + }); + + it('should update pull target branch', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + await gitea.updatePr({ + number: 1, + prTitle: 'New Title', + targetBranch: 'New Base', + }); + + expect(helper.updatePR).toHaveBeenCalledTimes(1); + expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, { + title: 'New Title', + base: 'New Base', + }); + }); + + it('should update pull request with title and body', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + await gitea.updatePr({ + number: 1, + prTitle: 'New Title', + prBody: 'New Body', + }); + + expect(helper.updatePR).toHaveBeenCalledTimes(1); + expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, { + title: 'New Title', + body: 'New Body', + }); + }); + + it('should update pull request with draft', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + await gitea.updatePr({ + number: 3, + prTitle: 'New Title', + prBody: 'New Body', + }); + + expect(helper.updatePR).toHaveBeenCalledTimes(1); + expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 3, { + title: 'WIP: New Title', + body: 'New Body', + }); + }); + + it('should close pull request', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + await gitea.updatePr({ + number: 1, + prTitle: 'New Title', + prBody: 'New Body', + state: 'closed', + }); + + expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, { + title: 'New Title', + body: 'New Body', + state: 'closed', + }); + }); + }); + + describe('mergePr', () => { + it('should return true when merging succeeds', async () => { + await initFakeRepo(); + + expect( + await gitea.mergePr({ + branchName: 'some-branch', + id: 1, + }) + ).toBe(true); + expect(helper.mergePR).toHaveBeenCalledTimes(1); + expect(helper.mergePR).toHaveBeenCalledWith(mockRepo.full_name, 1, { + Do: 'rebase', + }); + }); + + it('should return false when merging fails', async () => { + helper.mergePR.mockRejectedValueOnce(new Error()); + await initFakeRepo(); + + expect( + await gitea.mergePr({ + branchName: 'some-branch', + id: 1, + strategy: 'squash', + }) + ).toBe(false); + }); + }); + + describe('getIssue', () => { + it('should return the issue', async () => { + const mockIssue = mockIssues.find((i) => i.number === 1)!; + helper.getIssue.mockResolvedValueOnce(mockIssue); + await initFakeRepo(); + + expect(await gitea.getIssue?.(mockIssue.number)).toHaveProperty( + 'number', + mockIssue.number + ); + }); + }); + + describe('findIssue', () => { + it('should return existing open issue', async () => { + const mockIssue = mockIssues.find((i) => i.title === 'open-issue')!; + helper.searchIssues.mockResolvedValueOnce(mockIssues); + helper.getIssue.mockResolvedValueOnce(mockIssue); + await initFakeRepo(); + + expect(await gitea.findIssue(mockIssue.title)).toHaveProperty( + 'number', + mockIssue.number + ); + }); + + it('should not return existing closed issue', async () => { + const mockIssue = mockIssues.find((i) => i.title === 'closed-issue')!; + helper.searchIssues.mockResolvedValueOnce(mockIssues); + await initFakeRepo(); + + expect(await gitea.findIssue(mockIssue.title)).toBeNull(); + }); + + it('should return null for missing issue', async () => { + helper.searchIssues.mockResolvedValueOnce(mockIssues); + await initFakeRepo(); + + expect(await gitea.findIssue('missing')).toBeNull(); + }); + }); + + describe('ensureIssue', () => { + it('should create issue if not found', async () => { + const mockIssue = { + title: 'new-title', + body: 'new-body', + shouldReOpen: false, + once: false, + }; + + helper.searchIssues.mockResolvedValueOnce(mockIssues); + helper.createIssue.mockResolvedValueOnce(partial({ number: 42 })); + + await initFakeRepo(); + const res = await gitea.ensureIssue(mockIssue); + + expect(res).toBe('created'); + expect(helper.createIssue).toHaveBeenCalledTimes(1); + expect(helper.createIssue).toHaveBeenCalledWith(mockRepo.full_name, { + body: mockIssue.body, + title: mockIssue.title, + }); + }); + + 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 mockLabels: Label[] = [ + partial