diff --git a/docs/development/self-hosting.md b/docs/development/self-hosting.md index 7e7482d52bc951ecee12d74dfb7ac783256669d4..69f3d3242602d4f0a61bf24b5879d0d6cacaef10 100644 --- a/docs/development/self-hosting.md +++ b/docs/development/self-hosting.md @@ -133,6 +133,12 @@ First, [create a personal access token](https://docs.microsoft.com/en-us/azure/d Configure it either as `token` in your `config.js` file, or in environment variable `RENOVATE_TOKEN`, or via CLI `--token=`. Don't forget to configure `platform=azure` somewhere in config. +### Gitea + +First, [create a access token](https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api) for your bot account. +Configure it as `token` in your `config.js` file, or in environment variable `RENOVATE_TOKEN`, or via CLI `--token=`. +Don't forget to configure `platform=gitea` somewhere in config. + ## GitHub.com token for release notes If you are running on any platform except github.com, it's important to also configure `GITHUB_COM_TOKEN` containing a personal access token for github.com. This account can actually be _any_ account on GitHub, and needs only read-only access. It's used when fetching release notes for repositories in order to increase the hourly API limit. diff --git a/docs/usage/modules/platform.md b/docs/usage/modules/platform.md index 0619e4e671a8f2fc679fd74b33c8daf299b4501d..8a6261c8969465d2f32d082a39d835dc2c8daeee 100644 --- a/docs/usage/modules/platform.md +++ b/docs/usage/modules/platform.md @@ -9,3 +9,4 @@ Currently supported platforms are: - Bitbucket Server - GitHub (github.com, GitHub Enterprise) - GitLab (gitlab.com, self-hosted) +- Gitea (gitea.com, self-hosted) diff --git a/lib/constants/platforms.ts b/lib/constants/platforms.ts index f3ba498bc417559f02e25b1b506826218d30a1cf..07b779c55ec8f5f25dd6895c4cae006ae719b3a8 100644 --- a/lib/constants/platforms.ts +++ b/lib/constants/platforms.ts @@ -1,5 +1,6 @@ export const PLATFORM_TYPE_AZURE = 'azure'; export const PLATFORM_TYPE_BITBUCKET = 'bitbucket'; export const PLATFORM_TYPE_BITBUCKET_SERVER = 'bitbucket-server'; +export const PLATFORM_TYPE_GITEA = 'gitea'; export const PLATFORM_TYPE_GITHUB = 'github'; export const PLATFORM_TYPE_GITLAB = 'gitlab'; diff --git a/lib/platform/common.ts b/lib/platform/common.ts index e85a0b7c7ed14e11406b461d5d47e775f8f98c48..c15596d35e9b207143a5d296ba9cdd46bc7bebc3 100644 --- a/lib/platform/common.ts +++ b/lib/platform/common.ts @@ -178,7 +178,7 @@ export interface Platform { deleteBranch(branchName: string, closePr?: boolean): Promise<void>; ensureComment(ensureComment: EnsureCommentConfig): Promise<boolean>; branchExists(branchName: string): Promise<boolean>; - setBaseBranch(baseBranch: string): Promise<void>; + setBaseBranch(baseBranch?: string): Promise<void>; commitFilesToBranch(commitFile: CommitFilesConfig): Promise<void>; getPr(number: number): Promise<Pr>; findPr(findPRConfig: FindPRConfig): Promise<Pr>; diff --git a/lib/platform/gitea/README.md b/lib/platform/gitea/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c24a112da439b9d9f41822158bd3321098d0f6d1 --- /dev/null +++ b/lib/platform/gitea/README.md @@ -0,0 +1,8 @@ +# Gitea + +Gitea support is considered in **beta** release status. Mostly, it just needs more feedback/testing. If you have been using it and think it's reliable, please let us know. + +## Unsupported platform features/concepts + +- **Adding reviewers to PRs not supported**: While Gitea supports a basic implementation for supporting PR reviews, no API support has been implemented so far. +- **Ignoring Renovate PRs by close**: As Gitea does not expose the branch name of a PR once it has been deleted, all issued pull requests are immortal. diff --git a/lib/platform/gitea/gitea-got-wrapper.ts b/lib/platform/gitea/gitea-got-wrapper.ts new file mode 100644 index 0000000000000000000000000000000000000000..84bac293aa631bdcd4bd06f2c041146e045e06ce --- /dev/null +++ b/lib/platform/gitea/gitea-got-wrapper.ts @@ -0,0 +1,68 @@ +import URL from 'url'; +import { GotApi, GotApiOptions, GotResponse } from '../common'; +import { PLATFORM_TYPE_GITEA } from '../../constants/platforms'; +import got from '../../util/got'; + +const hostType = PLATFORM_TYPE_GITEA; +let baseUrl: string; + +function getPaginationContainer(body: any): any[] { + if (Array.isArray(body) && body.length) { + return body; + } + if (Array.isArray(body?.data) && body.data.length) { + return body.data; + } + + return null; +} + +async function get(path: string, options?: any): Promise<GotResponse> { + const opts = { + hostType, + baseUrl, + json: true, + ...options, + }; + + const res = await got(path, opts); + const pc = getPaginationContainer(res.body); + if (opts.paginate && pc) { + const url = URL.parse(res.url, true); + const total = parseInt(res.headers['x-total-count'] as string, 10); + let nextPage = parseInt(url.query.page as string, 10) || 1 + 1; + + while (total && pc.length < total) { + nextPage += 1; + url.query.page = nextPage.toString(); + + const nextRes = await got(URL.format(url), opts); + pc.push(...getPaginationContainer(nextRes.body)); + } + } + + return res; +} + +const helpers = ['get', 'post', 'put', 'patch', 'head', 'delete']; + +export type GiteaGotOptions = { + paginate?: boolean; + token?: string; +} & GotApiOptions; + +export interface GiteaGotApi extends GotApi<GiteaGotOptions> { + setBaseUrl(url: string): void; +} + +export const api: GiteaGotApi = {} as any; + +for (const x of helpers) { + (api as any)[x] = (path: string, options: any): Promise<GotResponse> => + get(path, { ...options, method: x.toUpperCase() }); +} + +// eslint-disable-next-line @typescript-eslint/unbound-method +api.setBaseUrl = (e: string): void => { + baseUrl = e; +}; diff --git a/lib/platform/gitea/gitea-helper.ts b/lib/platform/gitea/gitea-helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..c96b559a9301816913c5bca606ffa3bf5c9fe786 --- /dev/null +++ b/lib/platform/gitea/gitea-helper.ts @@ -0,0 +1,507 @@ +import { URLSearchParams } from 'url'; +import { api, GiteaGotOptions } from './gitea-got-wrapper'; +import { GotResponse } from '../common'; + +export type PRState = 'open' | 'closed' | 'all'; +export type IssueState = 'open' | 'closed' | 'all'; +export type CommitStatusType = + | 'pending' + | 'success' + | 'error' + | 'failure' + | 'warning' + | 'unknown'; +export type PRMergeMethod = 'merge' | 'rebase' | 'rebase-merge' | 'squash'; + +export interface PR { + number: number; + state: PRState; + title: string; + body: string; + mergeable: boolean; + created_at: string; + closed_at: string; + diff_url: string; + base?: { + ref: string; + }; + head?: { + ref: string; + sha: string; + repo?: Repo; + }; +} + +export interface Issue { + number: number; + state: IssueState; + title: string; + body: string; + assignees: User[]; +} + +export interface User { + id: number; + email: string; + full_name: string; + username: string; +} + +export interface Repo { + allow_merge_commits: boolean; + allow_rebase: boolean; + allow_rebase_explicit: boolean; + allow_squash_merge: boolean; + archived: boolean; + clone_url: string; + default_branch: string; + empty: boolean; + fork: boolean; + full_name: string; + mirror: boolean; + owner: User; + permissions: RepoPermission; +} + +export interface RepoPermission { + admin: boolean; + pull: boolean; + push: boolean; +} + +export interface RepoSearchResults { + ok: boolean; + data: Repo[]; +} + +export interface RepoContents { + path: string; + content?: string; + contentString?: string; +} + +export interface Comment { + id: number; + body: string; +} + +export interface Label { + id: number; + name: string; + description: string; + color: string; +} + +export interface Branch { + name: string; + commit: Commit; +} + +export interface Commit { + id: string; + author: CommitUser; +} + +export interface CommitUser { + name: string; + email: string; + username: string; +} + +export interface CommitStatus { + id: number; + status: CommitStatusType; + context: string; + description: string; + target_url: string; +} + +export interface CombinedCommitStatus { + worstStatus: CommitStatusType; + statuses: CommitStatus[]; +} + +export type RepoSearchParams = { + uid?: number; +}; + +export type IssueCreateParams = {} & IssueUpdateParams; + +export type IssueUpdateParams = { + title?: string; + body?: string; + state?: IssueState; + assignees?: string[]; +}; + +export type IssueSearchParams = { + state?: IssueState; +}; + +export type PRCreateParams = { + base?: string; + head?: string; +} & PRUpdateParams; + +export type PRUpdateParams = { + title?: string; + body?: string; + assignees?: string[]; + labels?: number[]; + state?: PRState; +}; + +export type PRSearchParams = { + state?: PRState; + labels?: number[]; +}; + +export type PRMergeParams = { + Do: PRMergeMethod; +}; + +export type CommentCreateParams = {} & CommentUpdateParams; + +export type CommentUpdateParams = { + body: string; +}; + +export type CommitStatusCreateParams = { + context?: string; + description?: string; + state?: CommitStatusType; + target_url?: string; +}; + +const urlEscape = (raw: string): string => encodeURIComponent(raw); +const commitStatusStates: CommitStatusType[] = [ + 'unknown', + 'success', + 'pending', + 'warning', + 'failure', + 'error', +]; + +function queryParams(params: Record<string, any>): URLSearchParams { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (Array.isArray(v)) { + for (const item of v) { + usp.append(k, item.toString()); + } + } else { + usp.append(k, v.toString()); + } + } + return usp; +} + +export async function getCurrentUser(options?: GiteaGotOptions): Promise<User> { + const url = 'user'; + const res: GotResponse<User> = await api.get(url, options); + + return res.body; +} + +export async function searchRepos( + params: RepoSearchParams, + options?: GiteaGotOptions +): Promise<Repo[]> { + const query = queryParams(params).toString(); + const url = `repos/search?${query}`; + const res: GotResponse<RepoSearchResults> = await api.get(url, { + ...options, + paginate: true, + }); + + if (!res.body.ok) { + throw new Error( + 'Unable to search for repositories, ok flag has not been set' + ); + } + + return res.body.data; +} + +export async function getRepo( + repoPath: string, + options?: GiteaGotOptions +): Promise<Repo> { + const url = `repos/${repoPath}`; + const res: GotResponse<Repo> = await api.get(url, options); + + return res.body; +} + +export async function getRepoContents( + repoPath: string, + filePath: string, + ref?: string, + options?: GiteaGotOptions +): Promise<RepoContents> { + const query = queryParams(ref ? { ref } : {}).toString(); + const url = `repos/${repoPath}/contents/${urlEscape(filePath)}?${query}`; + const res: GotResponse<RepoContents> = await api.get(url, options); + + if (res.body.content) { + res.body.contentString = Buffer.from(res.body.content, 'base64').toString(); + } + + return res.body; +} + +export async function createPR( + repoPath: string, + params: PRCreateParams, + options?: GiteaGotOptions +): Promise<PR> { + const url = `repos/${repoPath}/pulls`; + const res: GotResponse<PR> = await api.post(url, { + ...options, + body: params, + }); + + return res.body; +} + +export async function updatePR( + repoPath: string, + idx: number, + params: PRUpdateParams, + options?: GiteaGotOptions +): Promise<PR> { + const url = `repos/${repoPath}/pulls/${idx}`; + const res: GotResponse<PR> = await api.patch(url, { + ...options, + body: params, + }); + + return res.body; +} + +export async function closePR( + repoPath: string, + idx: number, + options?: GiteaGotOptions +): Promise<void> { + await updatePR(repoPath, idx, { + ...options, + state: 'closed', + }); +} + +export async function mergePR( + repoPath: string, + idx: number, + method: PRMergeMethod, + options?: GiteaGotOptions +): Promise<void> { + const params: PRMergeParams = { Do: method }; + const url = `repos/${repoPath}/pulls/${idx}/merge`; + await api.post(url, { + ...options, + body: params, + }); +} + +export async function getPR( + repoPath: string, + idx: number, + options?: GiteaGotOptions +): Promise<PR> { + const url = `repos/${repoPath}/pulls/${idx}`; + const res: GotResponse<PR> = await api.get(url, options); + + return res.body; +} + +export async function searchPRs( + repoPath: string, + params: PRSearchParams, + options?: GiteaGotOptions +): Promise<PR[]> { + const query = queryParams(params).toString(); + const url = `repos/${repoPath}/pulls?${query}`; + const res: GotResponse<PR[]> = await api.get(url, { + ...options, + paginate: true, + }); + + return res.body; +} + +export async function createIssue( + repoPath: string, + params: IssueCreateParams, + options?: GiteaGotOptions +): Promise<Issue> { + const url = `repos/${repoPath}/issues`; + const res: GotResponse<Issue> = await api.post(url, { + ...options, + body: params, + }); + + return res.body; +} + +export async function updateIssue( + repoPath: string, + idx: number, + params: IssueUpdateParams, + options?: GiteaGotOptions +): Promise<Issue> { + const url = `repos/${repoPath}/issues/${idx}`; + const res: GotResponse<Issue> = await api.patch(url, { + ...options, + body: params, + }); + + return res.body; +} + +export async function closeIssue( + repoPath: string, + idx: number, + options?: GiteaGotOptions +): Promise<void> { + await updateIssue(repoPath, idx, { + ...options, + state: 'closed', + }); +} + +export async function searchIssues( + repoPath: string, + params: IssueSearchParams, + options?: GiteaGotOptions +): Promise<Issue[]> { + const query = queryParams(params).toString(); + const url = `repos/${repoPath}/issues?${query}`; + const res: GotResponse<Issue[]> = await api.get(url, { + ...options, + paginate: true, + }); + + return res.body; +} + +export async function getRepoLabels( + repoPath: string, + options?: GiteaGotOptions +): Promise<Label[]> { + const url = `repos/${repoPath}/labels`; + const res: GotResponse<Label[]> = await api.get(url, options); + + return res.body; +} + +export async function unassignLabel( + repoPath: string, + issue: number, + label: number, + options?: GiteaGotOptions +): Promise<void> { + const url = `repos/${repoPath}/issues/${issue}/labels/${label}`; + await api.delete(url, options); +} + +export async function createComment( + repoPath: string, + issue: number, + body: string, + options?: GiteaGotOptions +): Promise<Comment> { + const params: CommentCreateParams = { body }; + const url = `repos/${repoPath}/issues/${issue}/comments`; + const res: GotResponse<Comment> = await api.post(url, { + ...options, + body: params, + }); + + return res.body; +} + +export async function updateComment( + repoPath: string, + idx: number, + body: string, + options?: GiteaGotOptions +): Promise<Comment> { + const params: CommentUpdateParams = { body }; + const url = `repos/${repoPath}/issues/comments/${idx}`; + const res: GotResponse<Comment> = await api.patch(url, { + ...options, + body: params, + }); + + return res.body; +} + +export async function deleteComment( + repoPath, + idx: number, + options?: GiteaGotOptions +): Promise<void> { + const url = `repos/${repoPath}/issues/comments/${idx}`; + await api.delete(url, options); +} + +export async function getComments( + repoPath, + issue: number, + options?: GiteaGotOptions +): Promise<Comment[]> { + const url = `repos/${repoPath}/issues/${issue}/comments`; + const res: GotResponse<Comment[]> = await api.get(url, options); + + return res.body; +} + +export async function createCommitStatus( + repoPath: string, + branchCommit: string, + params: CommitStatusCreateParams, + options?: GiteaGotOptions +): Promise<CommitStatus> { + const url = `repos/${repoPath}/statuses/${branchCommit}`; + const res: GotResponse<CommitStatus> = await api.post(url, { + ...options, + body: params, + }); + + return res.body; +} + +export async function getCombinedCommitStatus( + repoPath: string, + branchName: string, + options?: GiteaGotOptions +): Promise<CombinedCommitStatus> { + const url = `repos/${repoPath}/commits/${urlEscape(branchName)}/statuses`; + const res: GotResponse<CommitStatus[]> = await api.get(url, { + ...options, + paginate: true, + }); + + let worstState = 0; + for (const cs of res.body) { + worstState = Math.max(worstState, commitStatusStates.indexOf(cs.status)); + } + + return { + worstStatus: commitStatusStates[worstState], + statuses: res.body, + }; +} + +export async function getBranch( + repoPath: string, + branchName: string, + options?: GiteaGotOptions +): Promise<Branch> { + const url = `repos/${repoPath}/branches/${urlEscape(branchName)}`; + const res: GotResponse<Branch> = await api.get(url, options); + + return res.body; +} diff --git a/lib/platform/gitea/index.ts b/lib/platform/gitea/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3308a8517f96b6aa57e810a2655de5066add029 --- /dev/null +++ b/lib/platform/gitea/index.ts @@ -0,0 +1,956 @@ +import URL from 'url'; +import GitStorage, { CommitFilesConfig, StatusResult } from '../git/storage'; +import * as hostRules from '../../util/host-rules'; +import { + BranchStatus, + BranchStatusConfig, + CreatePRConfig, + EnsureCommentConfig, + EnsureIssueConfig, + FindPRConfig, + Issue, + Platform, + PlatformConfig, + Pr, + RepoConfig, + RepoParams, + VulnerabilityAlert, +} from '../common'; +import { api } from './gitea-got-wrapper'; +import { PLATFORM_TYPE_GITEA } from '../../constants/platforms'; +import { logger } from '../../logger'; +import { + REPOSITORY_ACCESS_FORBIDDEN, + REPOSITORY_ARCHIVED, + REPOSITORY_BLOCKED, + REPOSITORY_CHANGED, + REPOSITORY_DISABLED, + REPOSITORY_EMPTY, + REPOSITORY_MIRRORED, +} from '../../constants/error-messages'; +import { RenovateConfig } from '../../config'; +import { configFileNames } from '../../config/app-strings'; +import { smartTruncate } from '../utils/pr-body'; +import { sanitize } from '../../util/sanitize'; +import { + BRANCH_STATUS_FAILED, + BRANCH_STATUS_PENDING, + BRANCH_STATUS_SUCCESS, +} from '../../constants/branch-constants'; +import * as helper from './gitea-helper'; + +type GiteaRenovateConfig = { + endpoint: string; + token: string; +} & RenovateConfig; + +interface GiteaRepoConfig { + storage: GitStorage; + repository: string; + localDir: string; + defaultBranch: string; + baseBranch: string; + mergeMethod: helper.PRMergeMethod; + + prList: Promise<Pr[]> | null; + issueList: Promise<Issue[]> | null; + labelList: Promise<helper.Label[]> | null; +} + +const defaults: any = { + hostType: PLATFORM_TYPE_GITEA, + endpoint: 'https://gitea.com/api/v1/', +}; +const defaultConfigFile = configFileNames[0]; + +let config: GiteaRepoConfig = {} as any; +let botUserID: number; + +function toRenovateIssue(data: helper.Issue): Issue { + return { + number: data.number, + state: data.state, + title: data.title, + body: data.body, + }; +} + +function toRenovatePR(data: helper.PR): Pr | null { + if (!data) { + return null; + } + + if ( + !data.base?.ref || + !data.head?.ref || + !data.head?.sha || + !data.head?.repo?.full_name + ) { + logger.trace( + `Skipping Pull Request #${data.number} due to missing base and/or head branch` + ); + return null; + } + + return { + number: data.number, + displayNumber: `Pull Request #${data.number}`, + state: data.state, + title: data.title, + body: data.body, + sha: data.head.sha, + branchName: data.head.ref, + targetBranch: data.base.ref, + sourceRepo: data.head.repo.full_name, + createdAt: data.created_at, + closedAt: data.closed_at, + canMerge: data.mergeable, + isConflicted: !data.mergeable, + isStale: undefined, + isModified: undefined, + }; +} + +function matchesState(actual: string, expected: string): boolean { + if (expected === 'all') { + return true; + } + if (expected.startsWith('!')) { + return actual !== expected.substring(1); + } + + return actual === expected; +} + +function findCommentByTopic( + comments: helper.Comment[], + topic: string +): helper.Comment | null { + return comments.find(c => c.body.startsWith(`### ${topic}\n\n`)); +} + +async function isPRModified( + repoPath: string, + branchName: string +): Promise<boolean> { + try { + const branch = await helper.getBranch(repoPath, branchName); + const branchCommitEmail = branch.commit.author.email; + const configEmail = global.gitAuthor.email; + + if (branchCommitEmail === configEmail) { + return false; + } + + logger.debug( + { branchCommitEmail, configEmail }, + 'Last committer to branch does not match bot, PR cannot be rebased' + ); + return true; + } catch (err) { + logger.warn({ err }, 'Error getting PR branch, marking as modified'); + return true; + } +} + +async function retrieveDefaultConfig( + repoPath: string, + branchName: string +): Promise<RenovateConfig> { + const contents = await helper.getRepoContents( + repoPath, + defaultConfigFile, + branchName + ); + + return JSON.parse(contents.contentString); +} + +function getLabelList(): Promise<helper.Label[]> { + if (config.labelList === null) { + config.labelList = helper + .getRepoLabels(config.repository, { + useCache: false, + }) + .then(labels => { + logger.debug(`Retrieved ${labels.length} Labels`); + return labels; + }); + } + + return config.labelList; +} + +async function lookupLabelByName(name: string): Promise<number | null> { + logger.debug(`lookupLabelByName(${name})`); + const labelList = await getLabelList(); + return labelList.find(l => l.name === name)?.id; +} + +const platform: Platform = { + async initPlatform({ + endpoint, + token, + }: GiteaRenovateConfig): Promise<PlatformConfig> { + if (!token) { + throw new Error('Init: You must configure a Gitea personal access token'); + } + + if (endpoint) { + // Ensure endpoint contains trailing slash + defaults.endpoint = endpoint.replace(/\/?$/, '/'); + } else { + logger.info('Using default Gitea endpoint: ' + defaults.endpoint); + } + api.setBaseUrl(defaults.endpoint); + + let gitAuthor: string; + try { + const user = await helper.getCurrentUser({ token }); + gitAuthor = `${user.full_name || user.username} <${user.email}>`; + botUserID = user.id; + } catch (err) { + logger.info({ err }, 'Error authenticating with Gitea. Check your token'); + throw new Error('Init: Authentication failure'); + } + + return { + endpoint: defaults.endpoint, + gitAuthor, + }; + }, + + async initRepo({ + repository, + localDir, + optimizeForDisabled, + }: RepoParams): Promise<RepoConfig> { + let renovateConfig: RenovateConfig; + let repo: helper.Repo; + + config = {} as any; + config.repository = repository; + config.localDir = localDir; + + // Attempt to fetch information about repository + try { + repo = await helper.getRepo(repository); + } catch (err) { + logger.info({ err }, 'Unknown Gitea initRepo error'); + throw err; + } + + // Ensure appropriate repository state and permissions + if (repo.archived) { + logger.info( + 'Repository is archived - throwing error to abort renovation' + ); + throw new Error(REPOSITORY_ARCHIVED); + } + if (repo.mirror) { + logger.info( + 'Repository is a mirror - throwing error to abort renovation' + ); + throw new Error(REPOSITORY_MIRRORED); + } + if (!repo.permissions.pull || !repo.permissions.push) { + logger.info( + 'Repository does not permit pull and push - throwing error to abort renovation' + ); + throw new Error(REPOSITORY_ACCESS_FORBIDDEN); + } + if (repo.empty) { + logger.info('Repository is empty - throwing error to abort renovation'); + throw new Error(REPOSITORY_EMPTY); + } + + if (repo.allow_rebase) { + config.mergeMethod = 'rebase'; + } else if (repo.allow_rebase_explicit) { + config.mergeMethod = 'rebase-merge'; + } else if (repo.allow_squash_merge) { + config.mergeMethod = 'squash'; + } else if (repo.allow_merge_commits) { + config.mergeMethod = 'merge'; + } else { + logger.info( + 'Repository has no allowed merge methods - throwing error to abort renovation' + ); + throw new Error(REPOSITORY_BLOCKED); + } + + // Determine author email and branches + config.defaultBranch = repo.default_branch; + config.baseBranch = config.defaultBranch; + logger.debug(`${repository} default branch = ${config.baseBranch}`); + + // Optionally check if Renovate is disabled by attempting to fetch default configuration file + if (optimizeForDisabled) { + try { + if (!renovateConfig) { + renovateConfig = await retrieveDefaultConfig( + config.repository, + config.defaultBranch + ); + } + } catch (err) { + // Do nothing + } + + if (renovateConfig && renovateConfig.enabled === false) { + throw new Error(REPOSITORY_DISABLED); + } + } + + // Find options for current host and determine Git endpoint + const opts = hostRules.find({ + hostType: PLATFORM_TYPE_GITEA, + url: defaults.endpoint, + }); + const gitEndpoint = URL.parse(repo.clone_url); + gitEndpoint.auth = opts.token; + + // Initialize Git storage + config.storage = new GitStorage(); + await config.storage.initRepo({ + ...config, + url: URL.format(gitEndpoint), + }); + + // Reset cached resources + config.prList = null; + config.issueList = null; + config.labelList = null; + + return { + baseBranch: config.baseBranch, + isFork: !!repo.fork, + }; + }, + + async getRepos(): Promise<string[]> { + logger.info('Auto-discovering Gitea repositories'); + try { + const repos = await helper.searchRepos({ uid: botUserID }); + return repos.map(r => r.full_name); + } catch (err) { + logger.error({ err }, 'Gitea getRepos() error'); + throw err; + } + }, + + cleanRepo(): Promise<void> { + if (config.storage) { + config.storage.cleanRepo(); + } + config = {} as any; + return Promise.resolve(); + }, + + async setBranchStatus({ + branchName, + context, + description, + state, + url: target_url, + }: BranchStatusConfig): Promise<void> { + try { + // Create new status for branch commit + const branchCommit = await config.storage.getBranchCommit(branchName); + await helper.createCommitStatus(config.repository, branchCommit, { + state: state ? (state as helper.CommitStatusType) : 'pending', + context, + description, + ...(target_url && { target_url }), + }); + + // Refresh caches by re-fetching commit status for branch + await helper.getCombinedCommitStatus(config.repository, branchName, { + useCache: false, + }); + } catch (err) { + logger.warn({ err }, 'Failed to set branch status'); + } + }, + + async getBranchStatus( + branchName: string, + requiredStatusChecks?: string[] | null + ): Promise<BranchStatus> { + if (!requiredStatusChecks) { + return BRANCH_STATUS_SUCCESS; + } + + if (Array.isArray(requiredStatusChecks) && requiredStatusChecks.length) { + logger.warn({ requiredStatusChecks }, 'Unsupported requiredStatusChecks'); + return BRANCH_STATUS_FAILED; + } + + let ccs: helper.CombinedCommitStatus; + try { + ccs = await helper.getCombinedCommitStatus(config.repository, branchName); + } catch (err) { + if (err.statusCode === 404) { + logger.info( + 'Received 404 when checking branch status, assuming branch deletion' + ); + throw new Error(REPOSITORY_CHANGED); + } + + logger.info('Unknown error when checking branch status'); + throw err; + } + + logger.debug({ ccs }, 'Branch status check result'); + switch (ccs.worstStatus) { + case 'unknown': + case 'pending': + return BRANCH_STATUS_PENDING; + case 'success': + return BRANCH_STATUS_SUCCESS; + default: + return BRANCH_STATUS_FAILED; + } + }, + + async getBranchStatusCheck( + branchName: string, + context: string + ): Promise<string> { + const ccs = await helper.getCombinedCommitStatus( + config.repository, + branchName + ); + const cs = ccs.statuses.find(s => s.context === context); + + return cs ? cs.status : null; + }, + + async setBaseBranch( + baseBranch: string = config.defaultBranch + ): Promise<void> { + config.baseBranch = baseBranch; + await config.storage.setBaseBranch(baseBranch); + }, + + getPrList(): Promise<Pr[]> { + if (config.prList === null) { + config.prList = helper + .searchPRs(config.repository, {}, { useCache: false }) + .then(prs => { + const prList = prs.map(toRenovatePR).filter(Boolean); + logger.debug(`Retrieved ${prList.length} Pull Requests`); + return prList; + }); + } + + return config.prList; + }, + + async getPr(number: number): Promise<Pr | null> { + // Search for pull request in cached list or attempt to query directly + const prList = await platform.getPrList(); + let pr = prList.find(p => p.number === number); + if (pr) { + logger.debug('Returning from cached PRs'); + } else { + logger.debug('PR not found in cached PRs - trying to fetch directly'); + const gpr = await helper.getPR(config.repository, number); + pr = toRenovatePR(gpr); + + // Add pull request to cache for further lookups / queries + if (config.prList !== null) { + (await config.prList).push(pr); + } + } + + // Abort and return null if no match was found + if (!pr) { + return null; + } + + // Enrich pull request with additional information which is more expensive to fetch + if (pr.isStale === undefined) { + pr.isStale = await platform.isBranchStale(pr.branchName); + } + if (pr.isModified === undefined) { + pr.isModified = await isPRModified(config.repository, pr.branchName); + } + + return pr; + }, + + async findPr({ + branchName, + prTitle: title, + state = 'all', + }: FindPRConfig): Promise<Pr> { + logger.debug(`findPr(${branchName}, ${title}, ${state})`); + const prList = await platform.getPrList(); + const pr = prList.find( + p => + p.sourceRepo === config.repository && + p.branchName === branchName && + matchesState(p.state, state) && + (!title || p.title === title) + ); + + if (pr) { + logger.debug(`Found PR #${pr.number}`); + } + return pr ?? null; + }, + + async createPr({ + branchName, + prTitle: title, + prBody: rawBody, + labels: labelNames, + useDefaultBranch, + }: CreatePRConfig): Promise<Pr> { + const base = useDefaultBranch ? config.defaultBranch : config.baseBranch; + const head = branchName; + const body = sanitize(rawBody); + + logger.debug(`Creating pull request: ${title} (${head} => ${base})`); + try { + const labels = Array.isArray(labelNames) + ? await Promise.all(labelNames.map(lookupLabelByName)) + : []; + const gpr = await helper.createPR(config.repository, { + base, + head, + title, + body, + labels: labels.filter(Boolean), + }); + + const pr = toRenovatePR(gpr); + if (!pr) { + throw new Error('Can not parse newly created Pull Request'); + } + if (config.prList !== null) { + (await config.prList).push(pr); + } + + return pr; + } catch (err) { + // When the user manually deletes a branch from Renovate, the PR remains but is no longer linked to any branch. In + // the most recent versions of Gitea, the PR gets automatically closed when that happens, but older versions do + // not handle this properly and keep the PR open. As pushing a branch with the same name resurrects the PR, this + // would cause a HTTP 409 conflict error, which we hereby gracefully handle. + if (err.statusCode === 409) { + logger.warn( + `Attempting to gracefully recover from 409 Conflict response in createPr(${title}, ${branchName})` + ); + + // Refresh cached PR list and search for pull request with matching information + config.prList = null; + const pr = await platform.findPr({ + branchName, + state: 'open', + }); + + // If a valid PR was found, return and gracefully recover from the error. Otherwise, abort and throw error. + if (pr) { + if (pr.title !== title || pr.body !== body) { + logger.info( + `Recovered from 409 Conflict, but PR for ${branchName} is outdated. Updating...` + ); + await platform.updatePr(pr.number, title, body); + pr.title = title; + pr.body = body; + } else { + logger.info( + `Recovered from 409 Conflict and PR for ${branchName} is up-to-date` + ); + } + + return pr; + } + } + + throw err; + } + }, + + async updatePr(number: number, title: string, body?: string): Promise<void> { + await helper.updatePR(config.repository, number, { + title, + ...(body && { body }), + }); + }, + + async mergePr(number: number, branchName: string): Promise<boolean> { + try { + await helper.mergePR(config.repository, number, config.mergeMethod); + return true; + } catch (err) { + logger.warn({ err, number }, 'Merging of PR failed'); + return false; + } + }, + + async getPrFiles(prNo: number): Promise<string[]> { + if (!prNo) { + return []; + } + + // Retrieving a diff for a PR is not officially supported by Gitea as of today + // See tracking issue: https://github.com/go-gitea/gitea/issues/5561 + // Workaround: Parse new paths in .diff file using regular expressions + const regex = /^diff --git a\/.+ b\/(.+)$/gm; + const pr = await helper.getPR(config.repository, prNo); + const diff = (await api.get(pr.diff_url)).body as string; + + const changedFiles: string[] = []; + let match: string[]; + do { + match = regex.exec(diff); + if (match) { + changedFiles.push(match[1]); + } + } while (match); + + return changedFiles; + }, + + getIssueList(): Promise<Issue[]> { + if (config.issueList === null) { + config.issueList = helper + .searchIssues(config.repository, {}, { useCache: false }) + .then(issues => { + const issueList = issues.map(toRenovateIssue); + logger.debug(`Retrieved ${issueList.length} Issues`); + return issueList; + }); + } + + return config.issueList; + }, + + async findIssue(title: string): Promise<Issue> { + const issueList = await platform.getIssueList(); + const issue = issueList.find(i => i.state === 'open' && i.title === title); + + if (issue) { + logger.debug(`Found Issue #${issue.number}`); + } + return issue ?? null; + }, + + async ensureIssue({ + title, + body, + shouldReOpen, + once, + }: EnsureIssueConfig): Promise<'updated' | 'created' | null> { + logger.debug(`ensureIssue(${title})`); + try { + const issueList = await platform.getIssueList(); + const issues = issueList.filter(i => i.title === title); + + // Update any matching issues which currently exist + if (issues.length) { + let activeIssue = issues.find(i => i.state === 'open'); + + // If no active issue was found, decide if it shall be skipped, re-opened or updated without state change + if (!activeIssue) { + if (once) { + logger.debug('Issue already closed - skipping update'); + return null; + } + if (shouldReOpen) { + logger.info('Reopening previously closed Issue'); + } + + // Pick the last issue in the list as the active one + activeIssue = issues[issues.length - 1]; + } + + // Close any duplicate issues + for (const issue of issues) { + if (issue.state === 'open' && issue.number !== activeIssue.number) { + logger.warn(`Closing duplicate Issue #${issue.number}`); + await helper.closeIssue(config.repository, issue.number); + } + } + + // Check if issue has already correct state + if (activeIssue.body === body && activeIssue.state === 'open') { + logger.info( + `Issue #${activeIssue.number} is open and up to date - nothing to do` + ); + return null; + } + + // Update issue body and re-open if enabled + logger.info(`Updating Issue #${activeIssue.number}`); + await helper.updateIssue(config.repository, activeIssue.number, { + body, + state: shouldReOpen + ? 'open' + : (activeIssue.state as helper.IssueState), + }); + + return 'updated'; + } + + // Create new issue and reset cache + const issue = await helper.createIssue(config.repository, { + body, + title, + }); + logger.info(`Created new Issue #${issue.number}`); + config.issueList = null; + + return 'created'; + } catch (err) { + logger.warn({ err }, 'Could not ensure issue'); + } + + return null; + }, + + async ensureIssueClosing(title: string): Promise<void> { + logger.debug(`ensureIssueClosing(${title})`); + const issueList = await platform.getIssueList(); + for (const issue of issueList) { + if (issue.state === 'open' && issue.title === title) { + logger.info({ number: issue.number }, 'Closing issue'); + await helper.closeIssue(config.repository, issue.number); + } + } + }, + + async deleteLabel(issue: number, labelName: string): Promise<void> { + logger.debug(`Deleting label ${labelName} from Issue #${issue}`); + const label = await lookupLabelByName(labelName); + if (label) { + await helper.unassignLabel(config.repository, issue, label); + } else { + logger.warn({ issue, labelName }, 'Failed to lookup label for deletion'); + } + + return null; + }, + + getRepoForceRebase(): Promise<boolean> { + return Promise.resolve(false); + }, + + async ensureComment({ + number: issue, + topic, + content, + }: EnsureCommentConfig): Promise<boolean> { + if (topic === 'Renovate Ignore Notification') { + logger.info( + `Skipping ensureComment(${topic}) as ignoring PRs is unsupported on Gitea.` + ); + return false; + } + + try { + let body = sanitize(content); + const commentList = await helper.getComments(config.repository, issue); + + // Search comment by either topic or exact body + let comment: helper.Comment = null; + if (topic) { + comment = findCommentByTopic(commentList, topic); + body = `### ${topic}\n\n${body}`; + } else { + comment = commentList.find(c => c.body === body); + } + + // Create a new comment if no match has been found, otherwise update if necessary + if (!comment) { + const c = await helper.createComment(config.repository, issue, body); + logger.info( + { repository: config.repository, issue, comment: c.id }, + 'Comment added' + ); + } else if (comment.body !== body) { + const c = await helper.updateComment(config.repository, issue, body); + logger.info( + { repository: config.repository, issue, comment: c.id }, + 'Comment updated' + ); + } else { + logger.debug(`Comment #${comment.id} is already up-to-date`); + } + + return true; + } catch (err) { + logger.warn({ err }, 'Error ensuring comment'); + return false; + } + }, + + async ensureCommentRemoval(issue: number, topic: string): Promise<void> { + const commentList = await helper.getComments(config.repository, issue); + const comment = findCommentByTopic(commentList, topic); + + // Abort and do nothing if no matching comment was found + if (!comment) { + return null; + } + + // Attempt to delete comment + try { + await helper.deleteComment(config.repository, comment.id); + } catch (err) { + logger.warn({ err, issue, subject: topic }, 'Error deleting comment'); + } + + return null; + }, + + async getBranchPr(branchName: string): Promise<Pr | null> { + logger.debug(`getBranchPr(${branchName})`); + const pr = await platform.findPr({ branchName, state: 'open' }); + return pr ? platform.getPr(pr.number) : null; + }, + + async deleteBranch(branchName: string, closePr?: boolean): Promise<void> { + logger.debug(`deleteBranch(${branchName})`); + if (closePr) { + const pr = await platform.getBranchPr(branchName); + if (pr) { + await helper.closePR(config.repository, pr.number); + } + } + + return config.storage.deleteBranch(branchName); + }, + + async addAssignees(number: number, assignees: string[]): Promise<void> { + logger.debug(`Updating assignees ${assignees} on Issue #${number}`); + await helper.updateIssue(config.repository, number, { + assignees, + }); + }, + + addReviewers(number: number, reviewers: string[]): Promise<void> { + // Adding reviewers to a PR through API is not supported by Gitea as of today + // See tracking issue: https://github.com/go-gitea/gitea/issues/5733 + logger.debug(`Updating reviewers ${reviewers} on Pull Request #${number}`); + logger.warn('Unimplemented in Gitea: Reviewers'); + return Promise.resolve(); + }, + + commitFilesToBranch({ + branchName, + files, + message, + parentBranch = config.baseBranch, + }: CommitFilesConfig): Promise<void> { + return config.storage.commitFilesToBranch({ + branchName, + files, + message, + parentBranch, + }); + }, + + getPrBody(prBody: string): string { + // Gitea does not preserve the branch name once the head branch gets deleted, so ignoring a PR by simply closing it + // results in an endless loop of Renovate creating the PR over and over again. This is not pretty, but can not be + // avoided without storing that information somewhere else, so at least warn the user about it. + return smartTruncate( + prBody.replace( + /:no_bell: \*\*Ignore\*\*: Close this PR and you won't be reminded about (this update|these updates) again./, + `:ghost: **Immortal**: This PR will be recreated if closed unmerged, as Gitea does not support ignoring PRs.` + ), + 1000000 + ); + }, + + isBranchStale(branchName: string): Promise<boolean> { + return config.storage.isBranchStale(branchName); + }, + + setBranchPrefix(branchPrefix: string): Promise<void> { + return config.storage.setBranchPrefix(branchPrefix); + }, + + branchExists(branchName: string): Promise<boolean> { + return config.storage.branchExists(branchName); + }, + + mergeBranch(branchName: string): Promise<void> { + return config.storage.mergeBranch(branchName); + }, + + getBranchLastCommitTime(branchName: string): Promise<Date> { + return config.storage.getBranchLastCommitTime(branchName); + }, + + getFile(lockFileName: string, branchName?: string): Promise<string> { + return config.storage.getFile(lockFileName, branchName); + }, + + getRepoStatus(): Promise<StatusResult> { + return config.storage.getRepoStatus(); + }, + + getFileList(): Promise<string[]> { + return config.storage.getFileList(config.baseBranch); + }, + + getAllRenovateBranches(branchPrefix: string): Promise<string[]> { + return config.storage.getAllRenovateBranches(branchPrefix); + }, + + getCommitMessages(): Promise<string[]> { + return config.storage.getCommitMessages(); + }, + + getVulnerabilityAlerts(): Promise<VulnerabilityAlert[]> { + return Promise.resolve([]); + }, +}; + +export const { + addAssignees, + addReviewers, + branchExists, + cleanRepo, + commitFilesToBranch, + createPr, + deleteBranch, + deleteLabel, + ensureComment, + ensureCommentRemoval, + ensureIssue, + ensureIssueClosing, + findIssue, + findPr, + getAllRenovateBranches, + getBranchLastCommitTime, + getBranchPr, + getBranchStatus, + getBranchStatusCheck, + getCommitMessages, + getFile, + getFileList, + getIssueList, + getPr, + getPrBody, + getPrFiles, + getPrList, + getRepoForceRebase, + getRepoStatus, + getRepos, + getVulnerabilityAlerts, + initPlatform, + initRepo, + isBranchStale, + mergeBranch, + mergePr, + setBaseBranch, + setBranchPrefix, + setBranchStatus, + updatePr, +} = platform; diff --git a/lib/util/got/auth.ts b/lib/util/got/auth.ts index dde19ac7c8db4d9918ff7d539e8fe2aefcc469b7..df329ea12e699deadd9fc3e058a91a4093e73d84 100644 --- a/lib/util/got/auth.ts +++ b/lib/util/got/auth.ts @@ -1,6 +1,7 @@ import { logger } from '../../logger'; import { create } from './util'; import { + PLATFORM_TYPE_GITEA, PLATFORM_TYPE_GITHUB, PLATFORM_TYPE_GITLAB, } from '../../constants/platforms'; @@ -17,7 +18,10 @@ export default create({ { hostname: options.hostname }, 'Converting token to Bearer auth' ); - if (options.hostType === PLATFORM_TYPE_GITHUB) { + if ( + options.hostType === PLATFORM_TYPE_GITHUB || + options.hostType === PLATFORM_TYPE_GITEA + ) { options.headers.authorization = `token ${options.token}`; // eslint-disable-line no-param-reassign } else if (options.hostType === PLATFORM_TYPE_GITLAB) { options.headers['Private-token'] = options.token; // eslint-disable-line no-param-reassign diff --git a/test/platform/__snapshots__/index.spec.ts.snap b/test/platform/__snapshots__/index.spec.ts.snap index 8b346faf5eac65e8cb639a76abed9693936de2ed..55090138c47f092212ed5d711dfbb127f74a778e 100644 --- a/test/platform/__snapshots__/index.spec.ts.snap +++ b/test/platform/__snapshots__/index.spec.ts.snap @@ -45,6 +45,51 @@ Array [ ] `; +exports[`platform has a list of supported methods for gitea 1`] = ` +Array [ + "addAssignees", + "addReviewers", + "branchExists", + "cleanRepo", + "commitFilesToBranch", + "createPr", + "deleteBranch", + "deleteLabel", + "ensureComment", + "ensureCommentRemoval", + "ensureIssue", + "ensureIssueClosing", + "findIssue", + "findPr", + "getAllRenovateBranches", + "getBranchLastCommitTime", + "getBranchPr", + "getBranchStatus", + "getBranchStatusCheck", + "getCommitMessages", + "getFile", + "getFileList", + "getIssueList", + "getPr", + "getPrBody", + "getPrFiles", + "getPrList", + "getRepoForceRebase", + "getRepoStatus", + "getRepos", + "getVulnerabilityAlerts", + "initPlatform", + "initRepo", + "isBranchStale", + "mergeBranch", + "mergePr", + "setBaseBranch", + "setBranchPrefix", + "setBranchStatus", + "updatePr", +] +`; + exports[`platform has a list of supported methods for github 1`] = ` Array [ "addAssignees", diff --git a/test/platform/gitea/__snapshots__/index.spec.ts.snap b/test/platform/gitea/__snapshots__/index.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..7635572019520d5ce94654fb06a8365a449885f4 --- /dev/null +++ b/test/platform/gitea/__snapshots__/index.spec.ts.snap @@ -0,0 +1,169 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`platform/gitea createPr should use base branch by default 1`] = ` +Object { + "body": "pr-body", + "branchName": "pr-branch", + "canMerge": true, + "closedAt": "2017-12-28T12:17:48Z", + "createdAt": "2014-04-01T05:14:20Z", + "displayNumber": "Pull Request #42", + "isConflicted": false, + "isModified": undefined, + "isStale": undefined, + "number": 42, + "sha": "0d9c7726c3d628b7e28af234595cfd20febdbf8e", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "devel", + "title": "pr-title", +} +`; + +exports[`platform/gitea createPr should use default branch if requested 1`] = ` +Object { + "body": "pr-body", + "branchName": "pr-branch", + "canMerge": true, + "closedAt": "2017-12-28T12:17:48Z", + "createdAt": "2014-04-01T05:14:20Z", + "displayNumber": "Pull Request #42", + "isConflicted": false, + "isModified": undefined, + "isStale": undefined, + "number": 42, + "sha": "0d9c7726c3d628b7e28af234595cfd20febdbf8e", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "master", + "title": "pr-title", +} +`; + +exports[`platform/gitea getPr should fallback to direct fetching if cache fails 1`] = ` +Object { + "body": "some random pull request", + "branchName": "some-head-ref", + "canMerge": true, + "closedAt": null, + "createdAt": "2015-03-22T20:36:16Z", + "displayNumber": "Pull Request #1", + "isConflicted": false, + "isModified": true, + "isStale": false, + "number": 1, + "sha": "some-head-sha", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "some-base-ref", + "title": "Some PR", +} +`; + +exports[`platform/gitea getPr should return enriched pull request which exists 1`] = ` +Object { + "body": "other random pull request", + "branchName": "other-head-ref", + "canMerge": true, + "closedAt": "2016-01-09T10:03:21Z", + "createdAt": "2011-08-18T22:30:38Z", + "displayNumber": "Pull Request #2", + "isConflicted": false, + "isModified": false, + "isStale": false, + "number": 2, + "sha": "other-head-sha", + "sourceRepo": "some/repo", + "state": "closed", + "targetBranch": "other-base-ref", + "title": "Other PR", +} +`; + +exports[`platform/gitea getPrList should return list of pull requests 1`] = ` +Array [ + Object { + "body": "some random pull request", + "branchName": "some-head-ref", + "canMerge": true, + "closedAt": null, + "createdAt": "2015-03-22T20:36:16Z", + "displayNumber": "Pull Request #1", + "isConflicted": false, + "isModified": undefined, + "isStale": undefined, + "number": 1, + "sha": "some-head-sha", + "sourceRepo": "some/repo", + "state": "open", + "targetBranch": "some-base-ref", + "title": "Some PR", + }, + Object { + "body": "other random pull request", + "branchName": "other-head-ref", + "canMerge": true, + "closedAt": "2016-01-09T10:03:21Z", + "createdAt": "2011-08-18T22:30:38Z", + "displayNumber": "Pull Request #2", + "isConflicted": false, + "isModified": undefined, + "isStale": undefined, + "number": 2, + "sha": "other-head-sha", + "sourceRepo": "some/repo", + "state": "closed", + "targetBranch": "other-base-ref", + "title": "Other PR", + }, +] +`; + +exports[`platform/gitea getRepos should return an array of repos 1`] = ` +Array [ + "a/b", + "c/d", +] +`; + +exports[`platform/gitea initPlatform() should support custom endpoint 1`] = ` +Object { + "endpoint": "https://gitea.renovatebot.com/", + "gitAuthor": "Renovate Bot <renovate@example.com>", +} +`; + +exports[`platform/gitea initPlatform() should support default endpoint 1`] = ` +Object { + "endpoint": "https://gitea.com/api/v1/", + "gitAuthor": "Renovate Bot <renovate@example.com>", +} +`; + +exports[`platform/gitea initPlatform() should use username as author name if full name is missing 1`] = ` +Object { + "endpoint": "https://gitea.com/api/v1/", + "gitAuthor": "renovate <renovate@example.com>", +} +`; + +exports[`platform/gitea initRepo should fall back to merge method "merge" 1`] = ` +Object { + "baseBranch": "master", + "isFork": false, +} +`; + +exports[`platform/gitea initRepo should fall back to merge method "rebase-merge" 1`] = ` +Object { + "baseBranch": "master", + "isFork": false, +} +`; + +exports[`platform/gitea initRepo should fall back to merge method "squash" 1`] = ` +Object { + "baseBranch": "master", + "isFork": false, +} +`; diff --git a/test/platform/gitea/gitea-got-wrapper.spec.ts b/test/platform/gitea/gitea-got-wrapper.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd1f5d1dfdbe0f22bf32e9130f2abcb6393a5013 --- /dev/null +++ b/test/platform/gitea/gitea-got-wrapper.spec.ts @@ -0,0 +1,84 @@ +import { GotResponse } from '../../../lib/platform'; +import { partial } from '../../util'; +import { GotFn } from '../../../lib/util/got'; +import { GiteaGotApi } from '../../../lib/platform/gitea/gitea-got-wrapper'; + +describe('platform/gitea/gitea-got-wrapper', () => { + let api: GiteaGotApi; + let got: jest.Mocked<GotFn> & jest.Mock; + + const baseURL = 'https://gitea.renovatebot.com/api/v1'; + + beforeEach(async () => { + jest.resetAllMocks(); + jest.mock('../../../lib/util/got'); + + api = (await import('../../../lib/platform/gitea/gitea-got-wrapper')) + .api as any; + got = (await import('../../../lib/util/got')).api as any; + api.setBaseUrl(baseURL); + }); + + it('supports responses without pagination when enabled', async () => { + got.mockResolvedValueOnce( + partial<GotResponse>({ + body: { hello: 'world' }, + }) + ); + + const res = await api.get('pagination-example-1', { paginate: true }); + expect(res.body).toEqual({ hello: 'world' }); + }); + + it('supports root-level pagination', async () => { + got.mockResolvedValueOnce( + partial<GotResponse>({ + body: ['abc', 'def', 'ghi'], + headers: { 'x-total-count': '5' }, + url: `${baseURL}/pagination-example-1`, + }) + ); + got.mockResolvedValueOnce( + partial<GotResponse>({ + body: ['jkl'], + }) + ); + got.mockResolvedValueOnce( + partial<GotResponse>({ + body: ['mno', 'pqr'], + }) + ); + + const res = await api.get('pagination-example-1', { paginate: true }); + + expect(res.body).toHaveLength(6); + expect(res.body).toEqual(['abc', 'def', 'ghi', 'jkl', 'mno', 'pqr']); + expect(got).toHaveBeenCalledTimes(3); + }); + + it('supports pagination on data property', async () => { + got.mockResolvedValueOnce( + partial<GotResponse>({ + body: { data: ['abc', 'def', 'ghi'] }, + headers: { 'x-total-count': '5' }, + url: `${baseURL}/pagination-example-2`, + }) + ); + got.mockResolvedValueOnce( + partial<GotResponse>({ + body: { data: ['jkl'] }, + }) + ); + got.mockResolvedValueOnce( + partial<GotResponse>({ + body: { data: ['mno', 'pqr'] }, + }) + ); + + const res = await api.get('pagination-example-2', { paginate: true }); + + expect(res.body.data).toHaveLength(6); + expect(res.body.data).toEqual(['abc', 'def', 'ghi', 'jkl', 'mno', 'pqr']); + expect(got).toHaveBeenCalledTimes(3); + }); +}); diff --git a/test/platform/gitea/gitea-helper.spec.ts b/test/platform/gitea/gitea-helper.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ff1cf830a0fbce196e93b15d34e3497976c23b3 --- /dev/null +++ b/test/platform/gitea/gitea-helper.spec.ts @@ -0,0 +1,836 @@ +import { URL } from 'url'; +import { GotResponse } from '../../../lib/platform'; +import { partial } from '../../util'; +import { + GiteaGotApi, + GiteaGotOptions, +} from '../../../lib/platform/gitea/gitea-got-wrapper'; +import * as ght from '../../../lib/platform/gitea/gitea-helper'; +import { PRSearchParams } from '../../../lib/platform/gitea/gitea-helper'; + +describe('platform/gitea/gitea-helper', () => { + let helper: typeof import('../../../lib/platform/gitea/gitea-helper'); + let api: jest.Mocked<GiteaGotApi>; + + const baseURL = 'https://gitea.renovatebot.com/api/v1'; + + const mockCommitHash = '0d9c7726c3d628b7e28af234595cfd20febdbf8e'; + + const mockUser: ght.User = { + id: 1, + username: 'admin', + full_name: 'The Administrator', + email: 'admin@example.com', + }; + + const otherMockUser: ght.User = { + ...mockUser, + username: 'renovate', + full_name: 'Renovate Bot', + email: 'renovate@example.com', + }; + + const mockRepo: ght.Repo = { + allow_rebase: true, + allow_rebase_explicit: true, + allow_merge_commits: true, + allow_squash_merge: true, + clone_url: 'https://gitea.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, + }, + }; + + const otherMockRepo: ght.Repo = { + ...mockRepo, + full_name: 'other/repo', + clone_url: 'https://gitea.renovatebot.com/other/repo.git', + }; + + const mockLabel: ght.Label = { + id: 100, + name: 'some-label', + description: 'just a label', + color: '#000000', + }; + + const otherMockLabel: ght.Label = { + ...mockLabel, + id: 200, + name: 'other-label', + }; + + const mockPR: ght.PR = { + number: 13, + state: 'open', + title: 'Some PR', + body: 'Lorem ipsum dolor sit amet', + mergeable: true, + diff_url: `https://gitea.renovatebot.com/${mockRepo.full_name}/pulls/13.diff`, + base: { ref: mockRepo.default_branch }, + head: { + ref: 'pull-req-13', + sha: mockCommitHash, + repo: mockRepo, + }, + created_at: '2018-08-13T20:45:37Z', + closed_at: '2020-04-01T19:19:22Z', + }; + + const mockIssue: ght.Issue = { + number: 7, + state: 'open', + title: 'Some Issue', + body: 'just some issue', + assignees: [mockUser], + }; + + const mockComment: ght.Comment = { + id: 31, + body: 'some-comment', + }; + + const mockCommitStatus: ght.CommitStatus = { + id: 121, + status: 'success', + context: 'some-context', + description: 'some-description', + target_url: 'https://gitea.renovatebot.com/commit-status', + }; + + const otherMockCommitStatus: ght.CommitStatus = { + ...mockCommitStatus, + id: 242, + status: 'error', + context: 'other-context', + }; + + const mockCommit: ght.Commit = { + id: mockCommitHash, + author: { + name: otherMockUser.full_name, + email: otherMockUser.email, + username: otherMockUser.username, + }, + }; + + const mockBranch: ght.Branch = { + name: 'some-branch', + commit: mockCommit, + }; + + const otherMockBranch: ght.Branch = { + ...mockBranch, + name: 'other/branch/with/slashes', + }; + + const mockContents: ght.RepoContents = { + path: 'dummy.txt', + content: Buffer.from('top secret').toString('base64'), + contentString: 'top secret', + }; + + const otherMockContents: ght.RepoContents = { + ...mockContents, + path: 'nested/path/dummy.txt', + }; + + const mockAPI = <B extends object = undefined, P extends object = {}>( + userOptions: { + method?: 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete'; + urlPattern?: string | RegExp; + queryParams?: Record<string, string[]>; + postParams?: P; + }, + body: B = undefined + ) => { + // Merge default options with user options + const options = { + method: 'get', + ...userOptions, + }; + + // Mock request implementation once and verify request + api[options.method].mockImplementationOnce( + (rawUrl: string, apiOpts?: GiteaGotOptions): Promise<GotResponse<B>> => { + // Construct and parse absolute URL + const absoluteUrl = rawUrl.includes('://') + ? rawUrl + : `${baseURL}/${rawUrl}`; + const url = new URL(absoluteUrl); + + // Check optional URL pattern matcher + if (options.urlPattern !== undefined) { + const regex = + options.urlPattern instanceof RegExp + ? options.urlPattern + : new RegExp(`^${options.urlPattern}$`); + + if (!regex.exec(url.pathname)) { + throw new Error( + `expected url [${url.pathname}] to match pattern: ${options.urlPattern}` + ); + } + } + + // Check optional query params + if (options.queryParams !== undefined) { + for (const [key, expected] of Object.entries(options.queryParams)) { + expect(url.searchParams.getAll(key)).toEqual(expected); + } + } + + // Check optional post parameters + if (options.postParams !== undefined) { + expect(apiOpts.body).toEqual(options.postParams); + } + + return Promise.resolve( + partial<GotResponse<B>>({ body }) + ); + } + ); + }; + + beforeEach(async () => { + jest.resetAllMocks(); + jest.mock('../../../lib/platform/gitea/gitea-got-wrapper'); + + helper = (await import('../../../lib/platform/gitea/gitea-helper')) as any; + api = (await import('../../../lib/platform/gitea/gitea-got-wrapper')) + .api as any; + }); + + describe('getCurrentUser', () => { + it('should call /api/v1/user endpoint', async () => { + mockAPI<ght.User>({ urlPattern: '/api/v1/user' }, mockUser); + + const res = await helper.getCurrentUser(); + expect(res).toEqual(mockUser); + }); + }); + + describe('searchRepos', () => { + it('should call /api/v1/repos/search endpoint', async () => { + mockAPI<ght.RepoSearchResults>( + { urlPattern: '/api/v1/repos/search' }, + { + ok: true, + data: [mockRepo, otherMockRepo], + } + ); + + const res = await helper.searchRepos({}); + expect(res).toEqual([mockRepo, otherMockRepo]); + }); + + it('should construct proper query parameters', async () => { + mockAPI<ght.RepoSearchResults>( + { + urlPattern: '/api/v1/repos/search', + queryParams: { + uid: ['13'], + }, + }, + { + ok: true, + data: [otherMockRepo], + } + ); + + const res = await helper.searchRepos({ + uid: 13, + }); + expect(res).toEqual([otherMockRepo]); + }); + + it('should abort if ok flag was not set', async () => { + mockAPI<ght.RepoSearchResults>( + { urlPattern: '/api/v1/repos/search' }, + { + ok: false, + data: [], + } + ); + + await expect(helper.searchRepos({})).rejects.toThrow(); + }); + }); + + describe('getRepo', () => { + it('should call /api/v1/repos/[repo] endpoint', async () => { + mockAPI<ght.Repo>( + { urlPattern: `/api/v1/repos/${mockRepo.full_name}` }, + mockRepo + ); + + const res = await helper.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. + mockAPI<ght.RepoContents>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${mockContents.path}`, + }, + { ...mockContents, contentString: undefined } + ); + + const res = await helper.getRepoContents( + mockRepo.full_name, + mockContents.path + ); + expect(res).toEqual(mockContents); + }); + + it('should support passing reference by query', async () => { + mockAPI<ght.RepoContents>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${mockContents.path}`, + queryParams: { + ref: [mockCommitHash], + }, + }, + { ...mockContents, contentString: undefined } + ); + + const res = await helper.getRepoContents( + mockRepo.full_name, + mockContents.path, + mockCommitHash + ); + expect(res).toEqual(mockContents); + }); + + it('should properly escape paths', async () => { + const escapedPath = encodeURIComponent(otherMockContents.path); + + mockAPI<ght.RepoContents>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${escapedPath}`, + }, + otherMockContents + ); + + const res = await helper.getRepoContents( + mockRepo.full_name, + otherMockContents.path + ); + expect(res).toEqual(otherMockContents); + }); + + it('should not fail if no content is returned', async () => { + mockAPI<ght.RepoContents>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${mockContents.path}`, + }, + { ...mockContents, content: undefined, contentString: undefined } + ); + + const res = await helper.getRepoContents( + mockRepo.full_name, + mockContents.path + ); + expect(res).toEqual({ + ...mockContents, + content: undefined, + contentString: undefined, + }); + }); + }); + + describe('createPR', () => { + it('should call /api/v1/repos/[repo]/pulls endpoint', async () => { + mockAPI<ght.PR, Required<ght.PRCreateParams>>( + { + method: 'post', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls`, + postParams: { + state: mockPR.state, + title: mockPR.title, + body: mockPR.body, + base: mockPR.base.ref, + head: mockPR.head.ref, + assignees: [mockUser.username], + labels: [mockLabel.id], + }, + }, + mockPR + ); + + const res = await helper.createPR(mockRepo.full_name, { + state: mockPR.state, + title: mockPR.title, + body: mockPR.body, + base: mockPR.base.ref, + head: mockPR.head.ref, + assignees: [mockUser.username], + labels: [mockLabel.id], + }); + expect(res).toEqual(mockPR); + }); + }); + + describe('updatePR', () => { + it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => { + const updatedMockPR: ght.PR = { + ...mockPR, + state: 'closed', + title: 'new-title', + body: 'new-body', + }; + + mockAPI<ght.PR, Required<ght.PRUpdateParams>>( + { + method: 'patch', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}`, + postParams: { + state: 'closed', + title: 'new-title', + body: 'new-body', + assignees: [otherMockUser.username], + labels: [otherMockLabel.id], + }, + }, + updatedMockPR + ); + + const res = await helper.updatePR(mockRepo.full_name, mockPR.number, { + state: 'closed', + title: 'new-title', + body: 'new-body', + assignees: [otherMockUser.username], + labels: [otherMockLabel.id], + }); + expect(res).toEqual(updatedMockPR); + }); + }); + + describe('closePR', () => { + it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => { + mockAPI<undefined, ght.PRUpdateParams>({ + method: 'patch', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}`, + postParams: { + state: 'closed', + }, + }); + + const res = await helper.closePR(mockRepo.full_name, mockPR.number); + expect(res).toBeUndefined(); + }); + }); + + describe('mergePR', () => { + it('should call /api/v1/repos/[repo]/pulls/[pull]/merge endpoint', async () => { + mockAPI<undefined, ght.PRMergeParams>({ + method: 'patch', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}/merge`, + postParams: { + Do: 'rebase', + }, + }); + + const res = await helper.mergePR( + mockRepo.full_name, + mockPR.number, + 'rebase' + ); + expect(res).toBeUndefined(); + }); + }); + + describe('getPR', () => { + it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => { + mockAPI<ght.PR>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}`, + }, + mockPR + ); + + const res = await helper.getPR(mockRepo.full_name, mockPR.number); + expect(res).toEqual(mockPR); + }); + }); + + describe('searchPRs', () => { + it('should call /api/v1/repos/[repo]/pulls endpoint', async () => { + mockAPI<ght.PR[]>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls`, + }, + [mockPR] + ); + + const res = await helper.searchPRs(mockRepo.full_name, {}); + expect(res).toEqual([mockPR]); + }); + + it('should construct proper query parameters', async () => { + mockAPI<ght.PR[], Required<PRSearchParams>>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls`, + queryParams: { + state: ['open'], + labels: [`${mockLabel.id}`, `${otherMockLabel.id}`], + }, + }, + [mockPR] + ); + + const res = await helper.searchPRs(mockRepo.full_name, { + state: 'open', + labels: [mockLabel.id, otherMockLabel.id], + }); + expect(res).toEqual([mockPR]); + }); + }); + + describe('createIssue', () => { + it('should call /api/v1/repos/[repo]/issues endpoint', async () => { + mockAPI<ght.Issue, Required<ght.IssueCreateParams>>( + { + method: 'post', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues`, + postParams: { + state: mockIssue.state, + title: mockIssue.title, + body: mockIssue.body, + assignees: [mockUser.username], + }, + }, + mockIssue + ); + + const res = await helper.createIssue(mockRepo.full_name, { + state: mockIssue.state, + title: mockIssue.title, + body: mockIssue.body, + assignees: [mockUser.username], + }); + expect(res).toEqual(mockIssue); + }); + }); + + describe('updateIssue', () => { + it('should call /api/v1/repos/[repo]/issues/[issue] endpoint', async () => { + const updatedMockIssue: ght.Issue = { + ...mockIssue, + state: 'closed', + title: 'new-title', + body: 'new-body', + assignees: [otherMockUser], + }; + + mockAPI<ght.Issue, Required<ght.IssueUpdateParams>>( + { + method: 'patch', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}`, + postParams: { + state: 'closed', + title: 'new-title', + body: 'new-body', + assignees: [otherMockUser.username], + }, + }, + updatedMockIssue + ); + + const res = await helper.updateIssue( + mockRepo.full_name, + mockIssue.number, + { + state: 'closed', + title: 'new-title', + body: 'new-body', + assignees: [otherMockUser.username], + } + ); + expect(res).toEqual(updatedMockIssue); + }); + }); + + describe('closeIssue', () => { + it('should call /api/v1/repos/[repo]/issues/[issue] endpoint', async () => { + mockAPI<ght.IssueUpdateParams>({ + method: 'patch', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}`, + postParams: { + state: 'closed', + }, + }); + + const res = await helper.closeIssue(mockRepo.full_name, mockIssue.number); + expect(res).toBeUndefined(); + }); + }); + + describe('searchIssues', () => { + it('should call /api/v1/repos/[repo]/issues endpoint', async () => { + mockAPI<ght.Issue[]>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues`, + }, + [mockIssue] + ); + + const res = await helper.searchIssues(mockRepo.full_name, {}); + expect(res).toEqual([mockIssue]); + }); + + it('should construct proper query parameters', async () => { + mockAPI<ght.Issue[], Required<PRSearchParams>>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues`, + queryParams: { + state: ['open'], + }, + }, + [mockIssue] + ); + + const res = await helper.searchIssues(mockRepo.full_name, { + state: 'open', + }); + expect(res).toEqual([mockIssue]); + }); + }); + + describe('getRepoLabels', () => { + it('should call /api/v1/repos/[repo]/labels endpoint', async () => { + mockAPI<ght.Label[]>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/labels`, + }, + [mockLabel, otherMockLabel] + ); + + const res = await helper.getRepoLabels(mockRepo.full_name); + expect(res).toEqual([mockLabel, otherMockLabel]); + }); + }); + + describe('unassignLabel', () => { + it('should call /api/v1/repos/[repo]/issues/[issue]/labels/[label] endpoint', async () => { + mockAPI({ + method: 'delete', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}/labels/${mockLabel.id}`, + }); + + const res = await helper.unassignLabel( + mockRepo.full_name, + mockIssue.number, + mockLabel.id + ); + expect(res).toBeUndefined(); + }); + }); + + describe('createComment', () => { + it('should call /api/v1/repos/[repo]/issues/[issue]/comments endpoint', async () => { + mockAPI<ght.Comment, Required<ght.CommentCreateParams>>( + { + method: 'post', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}/comments`, + postParams: { + body: mockComment.body, + }, + }, + mockComment + ); + + const res = await helper.createComment( + mockRepo.full_name, + mockIssue.number, + mockComment.body + ); + expect(res).toEqual(mockComment); + }); + }); + + describe('updateComment', () => { + it('should call /api/v1/repos/[repo]/issues/comments/[comment] endpoint', async () => { + const updatedMockComment: ght.Comment = { + ...mockComment, + body: 'new-body', + }; + + mockAPI<ght.Comment, Required<ght.CommentUpdateParams>>( + { + method: 'patch', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/comments/${mockComment.id}`, + postParams: { + body: 'new-body', + }, + }, + updatedMockComment + ); + + const res = await helper.updateComment( + mockRepo.full_name, + mockComment.id, + 'new-body' + ); + expect(res).toEqual(updatedMockComment); + }); + }); + + describe('deleteComment', () => { + it('should call /api/v1/repos/[repo]/issues/comments/[comment] endpoint', async () => { + mockAPI({ + method: 'delete', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/comments/${mockComment.id}`, + }); + + const res = await helper.deleteComment( + mockRepo.full_name, + mockComment.id + ); + expect(res).toBeUndefined(); + }); + }); + + describe('getComments', () => { + it('should call /api/v1/repos/[repo]/issues/[issue]/comments endpoint', async () => { + mockAPI<ght.Comment[]>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}/comments`, + }, + [mockComment] + ); + + const res = await helper.getComments( + mockRepo.full_name, + mockIssue.number + ); + expect(res).toEqual([mockComment]); + }); + }); + + describe('createCommitStatus', () => { + it('should call /api/v1/repos/[repo]/statuses/[commit] endpoint', async () => { + mockAPI<ght.CommitStatus, Required<ght.CommitStatusCreateParams>>( + { + method: 'post', + urlPattern: `/api/v1/repos/${mockRepo.full_name}/statuses/${mockCommitHash}`, + postParams: { + state: mockCommitStatus.status, + context: mockCommitStatus.context, + description: mockCommitStatus.description, + target_url: mockCommitStatus.target_url, + }, + }, + mockCommitStatus + ); + + const res = await helper.createCommitStatus( + mockRepo.full_name, + mockCommitHash, + { + state: mockCommitStatus.status, + context: mockCommitStatus.context, + description: mockCommitStatus.description, + target_url: mockCommitStatus.target_url, + } + ); + expect(res).toEqual(mockCommitStatus); + }); + }); + + describe('getCombinedCommitStatus', () => { + it('should call /api/v1/repos/[repo]/commits/[branch]/statuses endpoint', async () => { + mockAPI<ght.CommitStatus[]>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/commits/${mockBranch.name}/statuses`, + }, + [mockCommitStatus, otherMockCommitStatus] + ); + + const res = await helper.getCombinedCommitStatus( + mockRepo.full_name, + mockBranch.name + ); + expect(res.worstStatus).not.toEqual('unknown'); + expect(res.statuses).toEqual([mockCommitStatus, otherMockCommitStatus]); + }); + + it('should properly determine worst commit status', async () => { + const statuses: ght.CommitStatusType[] = [ + 'unknown', + 'success', + 'pending', + 'warning', + 'failure', + 'error', + ]; + + const commitStatuses: ght.CommitStatus[] = [ + { ...mockCommitStatus, status: 'unknown' }, + ]; + + for (const status of statuses) { + // Add current status ot list of commit statuses, then mock the API to return the whole list + commitStatuses.push({ ...mockCommitStatus, status }); + mockAPI<ght.CommitStatus[]>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/commits/${mockBranch.name}/statuses`, + }, + commitStatuses + ); + + // Expect to get the current state back as the worst status, as all previous commit statuses + // should be less important than the one which just got added + const res = await helper.getCombinedCommitStatus( + mockRepo.full_name, + mockBranch.name + ); + expect(res.worstStatus).toEqual(status); + } + }); + }); + + describe('getBranch', () => { + it('should call /api/v1/repos/[repo]/branches/[branch] endpoint', async () => { + mockAPI<ght.Branch>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/branches/${mockBranch.name}`, + }, + mockBranch + ); + + const res = await helper.getBranch(mockRepo.full_name, mockBranch.name); + expect(res).toEqual(mockBranch); + }); + + it('should properly escape branch names', async () => { + const escapedBranchName = encodeURIComponent(otherMockBranch.name); + + mockAPI<ght.Branch>( + { + urlPattern: `/api/v1/repos/${mockRepo.full_name}/branches/${escapedBranchName}`, + }, + otherMockBranch + ); + + const res = await helper.getBranch( + mockRepo.full_name, + otherMockBranch.name + ); + expect(res).toEqual(otherMockBranch); + }); + }); +}); diff --git a/test/platform/gitea/index.spec.ts b/test/platform/gitea/index.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a280c96a1f3b976e83463cff008b069dcb1b6c42 --- /dev/null +++ b/test/platform/gitea/index.spec.ts @@ -0,0 +1,1550 @@ +import { partial } from '../../util'; +import * as ght from '../../../lib/platform/gitea/gitea-helper'; +import { + REPOSITORY_ACCESS_FORBIDDEN, + REPOSITORY_ARCHIVED, + REPOSITORY_BLOCKED, + REPOSITORY_CHANGED, + REPOSITORY_DISABLED, + REPOSITORY_EMPTY, + REPOSITORY_MIRRORED, +} from '../../../lib/constants/error-messages'; +import { + BranchStatus, + BranchStatusConfig, + GotResponse, + RepoConfig, + RepoParams, +} from '../../../lib/platform'; +import { logger as _logger } from '../../../lib/logger'; +import { + BRANCH_STATUS_FAILED, + BRANCH_STATUS_PENDING, + BRANCH_STATUS_SUCCESS, +} from '../../../lib/constants/branch-constants'; +import { GiteaGotApi } from '../../../lib/platform/gitea/gitea-got-wrapper'; +import { CommitFilesConfig, File } from '../../../lib/platform/git/storage'; + +describe('platform/gitea', () => { + let gitea: typeof import('../../../lib/platform/gitea'); + let helper: jest.Mocked<typeof import('../../../lib/platform/gitea/gitea-helper')>; + let api: jest.Mocked<GiteaGotApi>; + let logger: jest.Mocked<typeof _logger>; + let GitStorage: jest.Mocked< + typeof import('../../../lib/platform/git/storage').Storage + > & + jest.Mock; + + const mockCommitHash = '0d9c7726c3d628b7e28af234595cfd20febdbf8e'; + + const mockUser: ght.User = { + id: 1, + username: 'renovate', + full_name: 'Renovate Bot', + email: 'renovate@example.com', + }; + + const mockRepo = partial<ght.Repo>({ + allow_rebase: true, + clone_url: 'https://gitea.renovatebot.com/some/repo.git', + default_branch: 'master', + full_name: 'some/repo', + permissions: { + pull: true, + push: true, + admin: false, + }, + }); + + const mockRepos: ght.Repo[] = [ + partial<ght.Repo>({ full_name: 'a/b' }), + partial<ght.Repo>({ full_name: 'c/d' }), + ]; + + const mockPRs: ght.PR[] = [ + partial<ght.PR>({ + 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: null, + mergeable: true, + base: { ref: 'some-base-ref' }, + head: { + ref: 'some-head-ref', + sha: 'some-head-sha', + repo: partial<ght.Repo>({ full_name: mockRepo.full_name }), + }, + }), + partial<ght.PR>({ + 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-ref' }, + head: { + ref: 'other-head-ref', + sha: 'other-head-sha', + repo: partial<ght.Repo>({ full_name: mockRepo.full_name }), + }, + }), + ]; + + const mockIssues: ght.Issue[] = [ + { + number: 1, + title: 'open-issue', + state: 'open', + body: 'some-content', + assignees: [], + }, + { + number: 2, + title: 'closed-issue', + state: 'closed', + body: 'other-content', + assignees: [], + }, + { + number: 3, + title: 'duplicate-issue', + state: 'open', + body: 'duplicate-content', + assignees: [], + }, + { + number: 4, + title: 'duplicate-issue', + state: 'open', + body: 'duplicate-content', + assignees: [], + }, + { + number: 5, + title: 'duplicate-issue', + state: 'open', + body: 'duplicate-content', + assignees: [], + }, + ]; + + const mockComments: ght.Comment[] = [ + { id: 1, body: 'some-body' }, + { id: 2, body: 'other-body' }, + { id: 3, body: '### some-topic\n\nsome-content' }, + ]; + + const mockLabels: ght.Label[] = [ + { id: 1, name: 'some-label', description: 'its a me', color: '#000000' }, + { id: 2, name: 'other-label', description: 'labelario', color: '#ffffff' }, + ]; + + const gsmInitRepo = jest.fn(); + const gsmCleanRepo = jest.fn(); + const gsmSetBaseBranch = jest.fn(); + const gsmGetCommitMessages = jest.fn(); + const gsmGetAllRenovateBranches = jest.fn(); + const gsmGetFileList = jest.fn(); + const gsmGetRepoStatus = jest.fn(); + const gsmGetFile = jest.fn(); + const gsmGetBranchLastCommitTime = jest.fn(); + const gsmMergeBranch = jest.fn(); + const gsmBranchExists = jest.fn(); + const gsmSetBranchPrefix = jest.fn(); + const gsmCommitFilesToBranch = jest.fn(); + const gsmDeleteBranch = jest.fn(); + const gsmIsBranchStale = jest.fn(() => false); + const gsmGetBranchCommit = jest.fn(() => mockCommitHash); + + beforeEach(async () => { + jest.resetModules(); + jest.clearAllMocks(); + jest.mock('../../../lib/platform/gitea/gitea-helper'); + jest.mock('../../../lib/platform/gitea/gitea-got-wrapper'); + jest.mock('../../../lib/platform/git/storage'); + jest.mock('../../../lib/logger'); + + gitea = await import('../../../lib/platform/gitea'); + helper = (await import('../../../lib/platform/gitea/gitea-helper')) as any; + api = (await import('../../../lib/platform/gitea/gitea-got-wrapper')) + .api as any; + logger = (await import('../../../lib/logger')).logger as any; + GitStorage = (await import('../../../lib/platform/git/storage')) + .Storage as any; + + GitStorage.mockImplementation(() => ({ + initRepo: gsmInitRepo, + cleanRepo: gsmCleanRepo, + setBaseBranch: gsmSetBaseBranch, + getCommitMessages: gsmGetCommitMessages, + getAllRenovateBranches: gsmGetAllRenovateBranches, + getFileList: gsmGetFileList, + getRepoStatus: gsmGetRepoStatus, + getFile: gsmGetFile, + getBranchLastCommitTime: gsmGetBranchLastCommitTime, + mergeBranch: gsmMergeBranch, + branchExists: gsmBranchExists, + setBranchPrefix: gsmSetBranchPrefix, + isBranchStale: gsmIsBranchStale, + getBranchCommit: gsmGetBranchCommit, + commitFilesToBranch: gsmCommitFilesToBranch, + deleteBranch: gsmDeleteBranch, + })); + + global.gitAuthor = { name: 'Renovate', email: 'renovate@example.com' }; + }); + + function initFakeRepo( + repo?: Partial<ght.Repo>, + config?: Partial<RepoParams> + ): Promise<RepoConfig> { + helper.getRepo.mockResolvedValueOnce({ ...mockRepo, ...repo }); + + return gitea.initRepo({ + repository: mockRepo.full_name, + localDir: '', + optimizeForDisabled: false, + ...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 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).toMatchSnapshot(); + }); + }); + + describe('initRepo', () => { + const initRepoCfg: RepoParams = { + repository: mockRepo.full_name, + localDir: '', + optimizeForDisabled: false, + }; + + it('should propagate API errors', async () => { + helper.getRepo.mockRejectedValueOnce(new Error('getRepo()')); + + await expect(gitea.initRepo(initRepoCfg)).rejects.toThrow('getRepo()'); + }); + + it('should abort when disabled and optimizeForDisabled is enabled', async () => { + helper.getRepoContents.mockResolvedValueOnce( + partial<ght.RepoContents>({ + contentString: JSON.stringify({ enabled: false }), + }) + ); + + await expect( + initFakeRepo({}, { optimizeForDisabled: true }) + ).rejects.toThrow(REPOSITORY_DISABLED); + }); + + 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(); + }); + }); + + describe('cleanRepo', () => { + it('does not throw an error with uninitialized repo', async () => { + await gitea.cleanRepo(); + expect(gsmCleanRepo).not.toHaveBeenCalled(); + }); + + it('propagates call to storage class with initialized repo', async () => { + await initFakeRepo(); + await gitea.cleanRepo(); + expect(gsmCleanRepo).toHaveBeenCalledTimes(1); + }); + }); + + describe('setBranchStatus', () => { + const setBranchStatus = async (bsc?: Partial<BranchStatusConfig>) => { + await initFakeRepo(); + await gitea.setBranchStatus({ + branchName: 'some-branch', + state: 'some-state', + 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: 'some-state', + 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: 'some-state', + 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<BranchStatus> => { + await initFakeRepo(); + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial<ght.CombinedCommitStatus>({ + worstStatus: state as ght.CommitStatusType, + }) + ); + + return gitea.getBranchStatus('some-branch', []); + }; + + it('should return success if requiredStatusChecks null', async () => { + expect(await gitea.getBranchStatus('some-branch', null)).toEqual( + BRANCH_STATUS_SUCCESS + ); + }); + + it('should return failed if unsupported requiredStatusChecks', async () => { + expect(await gitea.getBranchStatus('some-branch', ['foo'])).toEqual( + BRANCH_STATUS_FAILED + ); + }); + + it('should return pending state for unknown result', async () => { + expect(await getBranchStatus('unknown')).toEqual(BRANCH_STATUS_PENDING); + }); + + it('should return pending state for pending result', async () => { + expect(await getBranchStatus('pending')).toEqual(BRANCH_STATUS_PENDING); + }); + + it('should return success state for success result', async () => { + expect(await getBranchStatus('success')).toEqual(BRANCH_STATUS_SUCCESS); + }); + + it('should return failed state for all other results', async () => { + expect(await getBranchStatus('invalid')).toEqual(BRANCH_STATUS_FAILED); + }); + + it('should abort when branch status returns 404', async () => { + helper.getCombinedCommitStatus.mockRejectedValueOnce({ statusCode: 404 }); + + await expect(gitea.getBranchStatus('some-branch', [])).rejects.toThrow( + REPOSITORY_CHANGED + ); + }); + + it('should propagate any other errors', async () => { + helper.getCombinedCommitStatus.mockRejectedValueOnce( + new Error('getCombinedCommitStatus()') + ); + + await expect(gitea.getBranchStatus('some-branch', [])).rejects.toThrow( + 'getCombinedCommitStatus()' + ); + }); + }); + + describe('getBranchStatusCheck', () => { + it('should return null with no results', async () => { + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial<ght.CombinedCommitStatus>({ + statuses: [], + }) + ); + + expect( + await gitea.getBranchStatusCheck('some-branch', 'some-context') + ).toBeNull(); + }); + + it('should return null with no matching results', async () => { + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial<ght.CombinedCommitStatus>({ + statuses: [partial<ght.CommitStatus>({ context: 'other-context' })], + }) + ); + + expect( + await gitea.getBranchStatusCheck('some-branch', 'some-context') + ).toBeNull(); + }); + + it('should return status of matching result', async () => { + helper.getCombinedCommitStatus.mockResolvedValueOnce( + partial<ght.CombinedCommitStatus>({ + statuses: [ + partial<ght.CommitStatus>({ + status: 'success', + context: 'some-context', + }), + ], + }) + ); + + expect( + await gitea.getBranchStatusCheck('some-branch', 'some-context') + ).toEqual('success'); + }); + }); + + describe('setBranchStatus', () => { + it('should set default base branch', async () => { + await initFakeRepo(); + await gitea.setBaseBranch(); + + expect(gsmSetBaseBranch).toHaveBeenCalledTimes(1); + expect(gsmSetBaseBranch).toHaveBeenCalledWith(mockRepo.default_branch); + }); + + it('should set custom base branch', async () => { + await initFakeRepo(); + await gitea.setBaseBranch('devel'); + + expect(gsmSetBaseBranch).toHaveBeenCalledTimes(1); + expect(gsmSetBaseBranch).toHaveBeenCalledWith('devel'); + }); + }); + + 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 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', async () => { + const mockPR = mockPRs[1]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + helper.getBranch.mockResolvedValueOnce( + partial<ght.Branch>({ + commit: { + id: mockCommitHash, + author: partial<ght.CommitUser>({ email: global.gitAuthor.email }), + }, + }) + ); + 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); + 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); + helper.getBranch.mockResolvedValueOnce( + partial<ght.Branch>({ + commit: { + id: mockCommitHash, + author: partial<ght.CommitUser>({ + email: 'not-a-robot@renovatebot.com', + }), + }, + }) + ); + await initFakeRepo(); + + const res = await gitea.getPr(mockPR.number); + expect(res).toHaveProperty('number', mockPR.number); + expect(res).toHaveProperty('isModified', true); + }); + }); + + 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.ref }); + expect(res).toHaveProperty('branchName', mockPR.head.ref); + }); + + 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.ref, + prTitle: mockPR.title, + }); + expect(res).toHaveProperty('branchName', mockPR.head.ref); + 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.ref, + state: mockPR.state, + }); + expect(res).toHaveProperty('branchName', mockPR.head.ref); + 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.ref, + state: `!${mockPR.state}` as ght.PRState, + }) + ).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.ref, + prTitle: mockPR.title, + state: mockPR.state, + }); + expect(res).toHaveProperty('branchName', mockPR.head.ref); + expect(res).toHaveProperty('title', mockPR.title); + 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: ght.PR = { + number: 42, + state: 'open', + head: { + ref: 'pr-branch', + sha: mockCommitHash, + repo: partial<ght.Repo>({ 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(); + await gitea.setBaseBranch('devel'); + const res = await gitea.createPr({ + branchName: mockNewPR.head.ref, + 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.ref, + title: mockNewPR.title, + body: mockNewPR.body, + labels: [], + }); + }); + + it('should use default branch if requested', async () => { + helper.createPR.mockResolvedValueOnce(mockNewPR); + + await initFakeRepo(); + await gitea.setBaseBranch('devel'); + const res = await gitea.createPr({ + branchName: mockNewPR.head.ref, + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + useDefaultBranch: 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.ref, + title: mockNewPR.title, + body: mockNewPR.body, + labels: [], + }); + }); + + it('should resolve and apply optional labels to pull request', async () => { + helper.createPR.mockResolvedValueOnce(mockNewPR); + helper.getRepoLabels.mockResolvedValueOnce(mockLabels); + + await initFakeRepo(); + await gitea.createPr({ + branchName: mockNewPR.head.ref, + 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.ref, + 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({ + branchName: mockNewPR.head.ref, + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + useDefaultBranch: true, + }); + 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({ + branchName: mockNewPR.head.ref, + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + useDefaultBranch: true, + }); + + 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({ + branchName: mockNewPR.head.ref, + prTitle: 'new-title', + prBody: 'new-body', + useDefaultBranch: true, + }); + + 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<ght.PR>({})); + + await initFakeRepo(); + await expect( + gitea.createPr({ + branchName: mockNewPR.head.ref, + prTitle: mockNewPR.title, + prBody: mockNewPR.body, + }) + ).rejects.toThrow(); + }); + }); + + describe('updatePr', () => { + it('should update pull request with title', async () => { + await initFakeRepo(); + await gitea.updatePr(1, 'New Title'); + + expect(helper.updatePR).toHaveBeenCalledTimes(1); + expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, { + title: 'New Title', + }); + }); + + it('should update pull request with title and body', async () => { + await initFakeRepo(); + await gitea.updatePr(1, 'New Title', 'New Body'); + + expect(helper.updatePR).toHaveBeenCalledTimes(1); + expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, { + title: 'New Title', + body: 'New Body', + }); + }); + }); + + describe('mergePr', () => { + it('should return true when merging succeeds', async () => { + await initFakeRepo(); + + expect(await gitea.mergePr(1, 'some-branch')).toEqual(true); + expect(helper.mergePR).toHaveBeenCalledTimes(1); + expect(helper.mergePR).toHaveBeenCalledWith( + mockRepo.full_name, + 1, + 'rebase' + ); + }); + + it('should return false when merging fails', async () => { + helper.mergePR.mockRejectedValueOnce(new Error()); + await initFakeRepo(); + + expect(await gitea.mergePr(1, 'some-branch')).toEqual(false); + }); + }); + + describe('getPrFiles', () => { + it('should return empty list without passing a pull request', async () => { + await initFakeRepo(); + + expect(await gitea.getPrFiles(undefined)).toEqual([]); + }); + + it('should return modified files when passing a pull request', async () => { + const mockPR = mockPRs[0]; + const mockDiff = ` +diff --git a/test b/test +deleted file mode 100644 +index 60fffd1..0000000 +--- a/test ++++ /dev/null +@@ -1 +0,0 @@ +-previously +diff --git a/this is spaces b/this is spaces +new file mode 100644 +index 0000000..2173594 +--- /dev/null ++++ b/this is spacey +@@ -0,0 +1 @@ ++nowadays +`; + + helper.getPR.mockResolvedValueOnce(mockPR); + api.get.mockResolvedValueOnce( + partial<GotResponse>({ + body: mockDiff, + }) + ); + await initFakeRepo(); + + expect(await gitea.getPrFiles(mockPR.number)).toEqual([ + 'test', + 'this is spaces', + ]); + }); + }); + + describe('findIssue', () => { + it('should return existing open issue', async () => { + const mockIssue = mockIssues.find(i => i.title === 'open-issue'); + helper.searchIssues.mockResolvedValueOnce(mockIssues); + 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<ght.Issue>({ number: 42 }) + ); + + await initFakeRepo(); + const res = await gitea.ensureIssue(mockIssue); + + expect(res).toEqual('created'); + expect(helper.createIssue).toHaveBeenCalledTimes(1); + expect(helper.createIssue).toHaveBeenCalledWith(mockRepo.full_name, { + body: mockIssue.body, + title: mockIssue.title, + }); + }); + + it('should not reopen closed issue by default', async () => { + const closedIssue = mockIssues.find(i => i.title === 'closed-issue'); + helper.searchIssues.mockResolvedValueOnce(mockIssues); + + await initFakeRepo(); + const res = await gitea.ensureIssue({ + title: closedIssue.title, + body: closedIssue.body, + shouldReOpen: false, + once: false, + }); + + expect(res).toEqual('updated'); + expect(helper.updateIssue).toHaveBeenCalledTimes(1); + expect(helper.updateIssue).toHaveBeenCalledWith( + mockRepo.full_name, + closedIssue.number, + { + body: closedIssue.body, + state: closedIssue.state, + } + ); + }); + + it('should reopen closed issue if desired', async () => { + const closedIssue = mockIssues.find(i => i.title === 'closed-issue'); + helper.searchIssues.mockResolvedValueOnce(mockIssues); + + await initFakeRepo(); + const res = await gitea.ensureIssue({ + title: closedIssue.title, + body: closedIssue.body, + shouldReOpen: true, + once: false, + }); + + expect(res).toEqual('updated'); + expect(helper.updateIssue).toHaveBeenCalledTimes(1); + expect(helper.updateIssue).toHaveBeenCalledWith( + mockRepo.full_name, + closedIssue.number, + { + body: closedIssue.body, + state: 'open', + } + ); + }); + + it('should not update existing closed issue if desired', async () => { + const closedIssue = mockIssues.find(i => i.title === 'closed-issue'); + helper.searchIssues.mockResolvedValueOnce(mockIssues); + + await initFakeRepo(); + const res = await gitea.ensureIssue({ + title: closedIssue.title, + body: closedIssue.body, + shouldReOpen: false, + once: true, + }); + + expect(res).toBeNull(); + expect(helper.updateIssue).not.toHaveBeenCalled(); + }); + + it('should close all open duplicate issues except first one when updating', async () => { + const duplicates = mockIssues.filter(i => i.title === 'duplicate-issue'); + const firstDuplicate = duplicates[0]; + helper.searchIssues.mockResolvedValueOnce(duplicates); + + await initFakeRepo(); + const res = await gitea.ensureIssue({ + title: firstDuplicate.title, + body: firstDuplicate.body, + shouldReOpen: false, + once: false, + }); + + expect(res).toBeNull(); + expect(helper.closeIssue).toHaveBeenCalledTimes(duplicates.length - 1); + for (const issue of duplicates) { + if (issue.number !== firstDuplicate.number) { + expect(helper.closeIssue).toHaveBeenCalledWith( + mockRepo.full_name, + issue.number + ); + } + } + expect(helper.updateIssue).not.toHaveBeenCalled(); + }); + + it('should reset issue cache when creating an issue', async () => { + helper.searchIssues.mockResolvedValueOnce(mockIssues); + helper.searchIssues.mockResolvedValueOnce(mockIssues); + helper.createIssue.mockResolvedValueOnce( + partial<ght.Issue>({ number: 42 }) + ); + + await initFakeRepo(); + await gitea.ensureIssue({ + title: 'new-title', + body: 'new-body', + shouldReOpen: false, + once: false, + }); + await gitea.getIssueList(); + + expect(helper.searchIssues).toHaveBeenCalledTimes(2); + }); + + it('should gracefully fail with warning', async () => { + helper.searchIssues.mockRejectedValueOnce(new Error()); + await initFakeRepo(); + await gitea.ensureIssue({ + title: 'new-title', + body: 'new-body', + shouldReOpen: false, + once: false, + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + }); + + describe('ensureIssueClosing', () => { + it('should close issues with matching title', async () => { + const mockIssue = mockIssues[0]; + helper.searchIssues.mockResolvedValueOnce(mockIssues); + await initFakeRepo(); + await gitea.ensureIssueClosing(mockIssue.title); + + expect(helper.closeIssue).toHaveBeenCalledTimes(1); + expect(helper.closeIssue).toHaveBeenCalledWith( + mockRepo.full_name, + mockIssue.number + ); + }); + }); + + describe('deleteLabel', () => { + it('should delete a label which exists', async () => { + const mockLabel = mockLabels[0]; + helper.getRepoLabels.mockResolvedValueOnce(mockLabels); + await initFakeRepo(); + await gitea.deleteLabel(42, mockLabel.name); + + expect(helper.unassignLabel).toHaveBeenCalledTimes(1); + expect(helper.unassignLabel).toHaveBeenCalledWith( + mockRepo.full_name, + 42, + mockLabel.id + ); + }); + + it('should gracefully fail with warning if label is missing', async () => { + helper.getRepoLabels.mockResolvedValueOnce(mockLabels); + await initFakeRepo(); + await gitea.deleteLabel(42, 'missing'); + + expect(helper.unassignLabel).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + }); + + describe('getRepoForceRebase', () => { + it('should return false - unsupported by platform', async () => { + expect(await gitea.getRepoForceRebase()).toEqual(false); + }); + }); + + describe('ensureComment', () => { + it('should add comment with topic if not found', async () => { + helper.getComments.mockResolvedValueOnce(mockComments); + helper.createComment.mockResolvedValueOnce( + partial<ght.Comment>({ id: 42 }) + ); + + await initFakeRepo(); + const res = await gitea.ensureComment({ + number: 1, + topic: 'other-topic', + content: 'other-content', + }); + const body = '### other-topic\n\nother-content'; + + expect(res).toEqual(true); + expect(helper.updateComment).not.toHaveBeenCalled(); + expect(helper.createComment).toHaveBeenCalledTimes(1); + expect(helper.createComment).toHaveBeenCalledWith( + mockRepo.full_name, + 1, + body + ); + }); + + it('should add comment without topic if not found', async () => { + helper.getComments.mockResolvedValueOnce(mockComments); + helper.createComment.mockResolvedValueOnce( + partial<ght.Comment>({ id: 42 }) + ); + + await initFakeRepo(); + const res = await gitea.ensureComment({ + number: 1, + content: 'other-content', + topic: undefined, + }); + + expect(res).toEqual(true); + expect(helper.updateComment).not.toHaveBeenCalled(); + expect(helper.createComment).toHaveBeenCalledTimes(1); + expect(helper.createComment).toHaveBeenCalledWith( + mockRepo.full_name, + 1, + 'other-content' + ); + }); + + it('should update comment with topic if found', async () => { + helper.getComments.mockResolvedValueOnce(mockComments); + helper.updateComment.mockResolvedValueOnce( + partial<ght.Comment>({ id: 42 }) + ); + + await initFakeRepo(); + const res = await gitea.ensureComment({ + number: 1, + topic: 'some-topic', + content: 'some-new-content', + }); + const body = '### some-topic\n\nsome-new-content'; + + expect(res).toEqual(true); + expect(helper.createComment).not.toHaveBeenCalled(); + expect(helper.updateComment).toHaveBeenCalledTimes(1); + expect(helper.updateComment).toHaveBeenCalledWith( + mockRepo.full_name, + 1, + body + ); + }); + + it('should skip if comment is up-to-date', async () => { + helper.getComments.mockResolvedValueOnce(mockComments); + await initFakeRepo(); + const res = await gitea.ensureComment({ + number: 1, + topic: 'some-topic', + content: 'some-content', + }); + + expect(res).toEqual(true); + expect(helper.createComment).not.toHaveBeenCalled(); + expect(helper.updateComment).not.toHaveBeenCalled(); + }); + + it('should skip comments with topic "Renovate Ignore Notification"', async () => { + helper.getComments.mockResolvedValueOnce(mockComments); + + await initFakeRepo(); + const res = await gitea.ensureComment({ + number: 1, + topic: 'Renovate Ignore Notification', + content: 'this-should-be-ignored-as-a-workaround', + }); + + expect(res).toEqual(false); + expect(helper.createComment).not.toHaveBeenCalled(); + expect(helper.updateComment).not.toHaveBeenCalled(); + }); + + it('should gracefully fail with warning', async () => { + helper.getComments.mockRejectedValueOnce(new Error()); + await initFakeRepo(); + const res = await gitea.ensureComment({ + number: 1, + topic: 'some-topic', + content: 'some-content', + }); + + expect(res).toEqual(false); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + }); + + describe('ensureCommentRemoval', () => { + it('should remove existing comment', async () => { + helper.getComments.mockResolvedValueOnce(mockComments); + await initFakeRepo(); + await gitea.ensureCommentRemoval(1, 'some-topic'); + + expect(helper.deleteComment).toHaveBeenCalledTimes(1); + expect(helper.deleteComment).toHaveBeenCalledWith( + mockRepo.full_name, + expect.any(Number) + ); + }); + + it('should gracefully fail with warning', async () => { + helper.getComments.mockResolvedValueOnce(mockComments); + helper.deleteComment.mockRejectedValueOnce(new Error()); + await initFakeRepo(); + await gitea.ensureCommentRemoval(1, 'some-topic'); + + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('should abort silently if comment is missing', async () => { + helper.getComments.mockResolvedValueOnce(mockComments); + await initFakeRepo(); + await gitea.ensureCommentRemoval(1, 'missing'); + + expect(helper.deleteComment).not.toHaveBeenCalled(); + }); + }); + + describe('getBranchPr', () => { + it('should return existing pull request for branch', async () => { + const mockPR = mockPRs[0]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + expect(await gitea.getBranchPr(mockPR.head.ref)).toHaveProperty( + 'number', + mockPR.number + ); + }); + + it('should return null if no pull request exists', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + + expect(await gitea.getBranchPr('missing')).toBeNull(); + }); + }); + + describe('deleteBranch', () => { + it('should propagate call to storage class', async () => { + await initFakeRepo(); + await gitea.deleteBranch('some-branch'); + + expect(gsmDeleteBranch).toHaveBeenCalledTimes(1); + expect(gsmDeleteBranch).toHaveBeenCalledWith('some-branch'); + }); + + it('should not close pull request by default', async () => { + await initFakeRepo(); + await gitea.deleteBranch('some-branch'); + + expect(helper.closePR).not.toHaveBeenCalled(); + }); + + it('should close existing pull request if desired', async () => { + const mockPR = mockPRs[0]; + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + await gitea.deleteBranch(mockPR.head.ref, true); + + expect(helper.closePR).toHaveBeenCalledTimes(1); + expect(helper.closePR).toHaveBeenCalledWith( + mockRepo.full_name, + mockPR.number + ); + expect(gsmDeleteBranch).toHaveBeenCalledTimes(1); + expect(gsmDeleteBranch).toHaveBeenCalledWith(mockPR.head.ref); + }); + + it('should skip closing pull request if missing', async () => { + helper.searchPRs.mockResolvedValueOnce(mockPRs); + await initFakeRepo(); + await gitea.deleteBranch('missing', true); + + expect(helper.closePR).not.toHaveBeenCalled(); + expect(gsmDeleteBranch).toHaveBeenCalledTimes(1); + expect(gsmDeleteBranch).toHaveBeenCalledWith('missing'); + }); + }); + + describe('addAssignees', () => { + it('should add assignees to the issue', async () => { + await initFakeRepo(); + await gitea.addAssignees(1, ['me', 'you']); + + expect(helper.updateIssue).toHaveBeenCalledTimes(1); + expect(helper.updateIssue).toHaveBeenCalledWith(mockRepo.full_name, 1, { + assignees: ['me', 'you'], + }); + }); + }); + + describe('addReviewers', () => { + it('should do nothing - unsupported by platform', async () => { + const mockPR = mockPRs[0]; + await gitea.addReviewers(mockPR.number, ['me', 'you']); + }); + }); + + describe('commitFilesToBranch', () => { + it('should propagate call to storage class with default parent branch', async () => { + const commitConfig: CommitFilesConfig = { + branchName: 'some-branch', + files: [partial<File>({})], + message: 'some-message', + }; + + await initFakeRepo(); + await gitea.commitFilesToBranch(commitConfig); + + expect(gsmCommitFilesToBranch).toHaveBeenCalledTimes(1); + expect(gsmCommitFilesToBranch).toHaveBeenCalledWith({ + ...commitConfig, + parentBranch: mockRepo.default_branch, + }); + }); + + it('should propagate call to storage class with custom parent branch', async () => { + const commitConfig: CommitFilesConfig = { + branchName: 'some-branch', + files: [partial<File>({})], + message: 'some-message', + parentBranch: 'some-parent-branch', + }; + + await initFakeRepo(); + await gitea.commitFilesToBranch(commitConfig); + + expect(gsmCommitFilesToBranch).toHaveBeenCalledTimes(1); + expect(gsmCommitFilesToBranch).toHaveBeenCalledWith(commitConfig); + }); + }); + + describe('getPrBody', () => { + it('should truncate body to 1000000 characters', () => { + const excessiveBody = '*'.repeat(1000001); + + expect(gitea.getPrBody(excessiveBody)).toHaveLength(1000000); + }); + }); + + describe('isBranchStale', () => { + it('propagates call to storage class', async () => { + await initFakeRepo(); + await gitea.isBranchStale('some-branch'); + + expect(gsmIsBranchStale).toHaveBeenCalledTimes(1); + expect(gsmIsBranchStale).toHaveBeenCalledWith('some-branch'); + }); + }); + + describe('setBranchPrefix', () => { + it('should propagate call to storage class', async () => { + await initFakeRepo(); + await gitea.setBranchPrefix('some-branch'); + + expect(gsmSetBranchPrefix).toHaveBeenCalledTimes(1); + expect(gsmSetBranchPrefix).toHaveBeenCalledWith('some-branch'); + }); + }); + + describe('branchExists', () => { + it('should propagate call to storage class', async () => { + await initFakeRepo(); + await gitea.branchExists('some-branch'); + + expect(gsmBranchExists).toHaveBeenCalledTimes(1); + expect(gsmBranchExists).toHaveBeenCalledWith('some-branch'); + }); + }); + + describe('mergeBranch', () => { + it('should propagate call to storage class', async () => { + await initFakeRepo(); + await gitea.mergeBranch('some-branch'); + + expect(gsmMergeBranch).toHaveBeenCalledTimes(1); + expect(gsmMergeBranch).toHaveBeenCalledWith('some-branch'); + }); + }); + + describe('getBranchLastCommitTime', () => { + it('should propagate call to storage class', async () => { + await initFakeRepo(); + await gitea.getBranchLastCommitTime('some-branch'); + + expect(gsmGetBranchLastCommitTime).toHaveBeenCalledTimes(1); + expect(gsmGetBranchLastCommitTime).toHaveBeenCalledWith('some-branch'); + }); + }); + + describe('getFile', () => { + it('should propagate call to storage class', async () => { + await initFakeRepo(); + await gitea.getFile('some-file', 'some-branch'); + + expect(gsmGetFile).toHaveBeenCalledTimes(1); + expect(gsmGetFile).toHaveBeenCalledWith('some-file', 'some-branch'); + }); + }); + + describe('getRepoStatus', () => { + it('should propagate call to storage class', async () => { + await initFakeRepo(); + await gitea.getRepoStatus(); + + expect(gsmGetRepoStatus).toHaveBeenCalledTimes(1); + }); + }); + + describe('getFileList', () => { + it('propagates call to storage class', async () => { + await initFakeRepo(); + await gitea.getFileList(); + + expect(gsmGetFileList).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAllRenovateBranches', () => { + it('should propagate call to storage class', async () => { + await initFakeRepo(); + await gitea.getAllRenovateBranches('some-prefix'); + + expect(gsmGetAllRenovateBranches).toHaveBeenCalledTimes(1); + expect(gsmGetAllRenovateBranches).toHaveBeenCalledWith('some-prefix'); + }); + }); + + describe('getCommitMessages', () => { + it('should propagate call to storage class', async () => { + await initFakeRepo(); + await gitea.getCommitMessages(); + + expect(gsmGetCommitMessages).toHaveBeenCalledTimes(1); + }); + }); + + describe('getVulnerabilityAlerts', () => { + it('should return an empty list - unsupported by platform', async () => { + expect(await gitea.getVulnerabilityAlerts()).toEqual([]); + }); + }); +}); diff --git a/test/platform/index.spec.ts b/test/platform/index.spec.ts index 1885ce82b87db70c0ed46f55e270d57020aae66f..bd96a4bb3757952a019291e3354d27965d094003 100644 --- a/test/platform/index.spec.ts +++ b/test/platform/index.spec.ts @@ -1,5 +1,6 @@ import * as github from '../../lib/platform/github'; import * as gitlab from '../../lib/platform/gitlab'; +import * as gitea from '../../lib/platform/gitea'; import * as azure from '../../lib/platform/azure'; import * as bitbucket from '../../lib/platform/bitbucket'; import * as bitbucketServer from '../../lib/platform/bitbucket-server'; @@ -50,6 +51,11 @@ describe('platform', () => { expect(gitlabMethods).toMatchSnapshot(); }); + it('has a list of supported methods for gitea', () => { + const giteaMethods = Object.keys(gitea).sort(); + expect(giteaMethods).toMatchSnapshot(); + }); + it('has a list of supported methods for azure', () => { const azureMethods = Object.keys(azure).sort(); expect(azureMethods).toMatchSnapshot(); @@ -61,6 +67,12 @@ describe('platform', () => { expect(githubMethods).toMatchObject(gitlabMethods); }); + it('has same API for github and gitea', () => { + const githubMethods = Object.keys(github).sort(); + const giteaMethods = Object.keys(gitea).sort(); + expect(githubMethods).toMatchObject(giteaMethods); + }); + it('has same API for github and azure', () => { const githubMethods = Object.keys(github).sort(); const azureMethods = Object.keys(azure).sort();