UNPKG

@codefresh-io/cf-git-providers

Version:

An NPM module/CLI for interacting with various git providers

790 lines 30.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.avatarSize = void 0; /* eslint-disable max-lines */ const lodash_1 = require("lodash"); const https_1 = require("https"); const types_1 = require("./types"); const helpers_1 = require("../helpers"); const request_retry_1 = require("../helpers/request-retry"); // eslint-disable-next-line @typescript-eslint/no-var-requires const CFError = require('cf-errors'); const logger = (0, helpers_1.createNewLogger)('codefresh:infra:git-providers:bitbucket-server'); const BITBUCKET_SERVER_RESPONSE_LIMIT = 1000; const LIMIT_PER_PAGE = 500; const MAX_PAGES = 20; const ApiVersions = { // eslint-disable-next-line @typescript-eslint/naming-convention V1: 'rest/api/1.0/', }; const COMMIT_METADATA_PATH = 'com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata'; const statesMap = { pending: 'INPROGRESS', running: 'INPROGRESS', success: 'SUCCESSFUL', failure: 'FAILED', error: 'FAILED', }; const ownerRegex = /(scm\/)?(?<noun>~)?(?<projOrUser>[^/]*)\/(?<repo>[^/]*)$/; // avatarSize adds avatar URLs for commit authors and specifying the desired size in pixels exports.avatarSize = 64; const _extractErrorFromResponse = (response) => { let message = (0, lodash_1.get)(response, 'body.errors[0].message', ''); const exceptionName = (0, lodash_1.get)(response, 'body.errors[0].exceptionName'); if (exceptionName) { message = `${message} [${exceptionName}]`; } return new types_1.HttpError(response.statusCode, message); }; const _toBranch = (rawBranch) => ({ name: rawBranch.displayId, id: rawBranch.id, commit: { sha: rawBranch.metadata[COMMIT_METADATA_PATH].id, commiter_name: rawBranch.metadata[COMMIT_METADATA_PATH].author.name, message: rawBranch.metadata[COMMIT_METADATA_PATH].message, url: '', }, }); const _toRepo = (rawRepo, lastCommit, defaultBranch) => { const isPersonal = (0, lodash_1.get)(rawRepo, 'project.type') === 'PERSONAL'; let fullName = ''; if (isPersonal) { fullName = `${(0, lodash_1.get)(rawRepo, 'project.owner.name')}/${rawRepo.slug}`; } else { fullName = `${(0, lodash_1.get)(rawRepo, 'project.name')}/${rawRepo.slug}`; } const httpClone = (0, lodash_1.find)((0, lodash_1.get)(rawRepo, 'links.clone', []), (l) => l.name === 'http' || l.name === 'https'); const sshClone = (0, lodash_1.find)((0, lodash_1.get)(rawRepo, 'links.clone', []), (l) => l.name === 'ssh'); return { id: rawRepo.hierarchyId, provider: 'bitbucket-server', name: rawRepo.name, full_name: fullName, private: !rawRepo.public, pushed_at: lastCommit?.committerTimestamp && new Date(lastCommit.committerTimestamp), open_issues: 0, clone_url: (0, lodash_1.get)(httpClone, 'href', ''), ssh_url: (0, lodash_1.get)(sshClone, 'href', ''), owner: { login: (0, lodash_1.get)(rawRepo, isPersonal ? 'project.owner.name' : 'project.name', ''), avatar_url: (0, lodash_1.get)(rawRepo, isPersonal ? 'project.owner.avatarUrl' : 'project.avatarUrl', ''), creator: null, }, org: isPersonal ? null : (0, lodash_1.get)(rawRepo, 'project.name', null), default_branch: (0, lodash_1.get)(defaultBranch, 'displayId', ''), permissions: { admin: true, // this value is hard to get with bitbucket, for now use 'true' }, webUrl: (0, lodash_1.get)(rawRepo, 'links.self[0].href', ''), }; }; const _toWebhook = (owner, repo, rawWebhook) => { return { id: rawWebhook.id, name: String(rawWebhook.url), endpoint: rawWebhook.url, repo: `${owner}/${repo}`, events: rawWebhook.events, }; }; const _toUser = (user) => { return { login: user.name, email: user.emailAddress, avatar_url: user.avatarUrl, web_url: user.links.self[0].href, }; }; const getRepoInfo = (fullRepoName) => { const groups = fullRepoName.match(ownerRegex)?.groups; if (!ownerRegex.test(fullRepoName) || !groups) { throw new CFError(`${fullRepoName} is invalid`); } return { noun: groups.noun || '', owner: groups.projOrUser, repo: groups.repo }; }; class BitbucketServer { baseUrl; authenticationHeader; timeout; agent; auth; refreshTokenHandler; retryConfig; constructor(opts) { const apiUrl = opts.apiURL || opts.apiUrl; if (!apiUrl) { throw new CFError('Cannot create bitbucket-server provider without api url'); } this.baseUrl = apiUrl; if (!this.baseUrl.endsWith('/')) { this.baseUrl = `${this.baseUrl}/`; } this.timeout = opts.timeout || 10000; this.refreshTokenHandler = opts.refreshTokenHandler; if (opts.type === 'basic') { this.authenticationHeader = { // eslint-disable-next-line @typescript-eslint/naming-convention Authorization: `Basic ${Buffer.from(`${opts.username}:${opts.password}`).toString('base64')}`, }; } else { // eslint-disable-next-line @typescript-eslint/naming-convention this.authenticationHeader = { Authorization: `Bearer ${opts.password}` }; } this.auth = { type: opts.type, username: opts.username, password: opts.password, refreshToken: opts.refreshToken, }; if (apiUrl.startsWith('https') && (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' || opts.insecure)) { logger.warn('using insecure mode'); this.agent = new https_1.Agent({ rejectUnauthorized: false }); } this.retryConfig = opts.retryConfig; } async createRepository(opts) { const isOwner = opts.owner.startsWith('~'); const projectKey = await this.getProjectKey(opts.owner); const [err, res] = await (0, helpers_1.to)(this.performAPICall({ api: `${isOwner ? 'users' : 'projects'}/${projectKey}/repos?avatarSize=${exports.avatarSize}`, method: 'POST', json: true, data: { name: opts.repo, scmId: 'git' } })); if (err) { throw new CFError({ message: `Failed to create repository ${opts.repo} in ${projectKey}`, cause: err, }); } return _toRepo(res.body); } async shouldRefreshToken(requestOptions) { const validateTokenRequestOptions = (0, lodash_1.cloneDeep)(requestOptions); validateTokenRequestOptions.qs = {}; validateTokenRequestOptions.url = `${this.baseUrl}${ApiVersions.V1}profile/recent/repos?limit=1`; const response = await request_retry_1.RpRetry.rpRetry(validateTokenRequestOptions, logger); switch (true) { case response.statusCode === 401: return true; case response.statusCode < 400: return false; default: throw new Error(`Refresh token error. StatusCode: ${response.statusCode}. Message: ${response.statusMessage}`); } } updateAuth(auth) { this.auth.password = auth.accessToken; this.auth.refreshToken = auth.refreshToken; // eslint-disable-next-line @typescript-eslint/naming-convention this.authenticationHeader = auth.accessToken ? { Authorization: `Bearer ${auth.accessToken}` } : {}; } async performAPICall(opts) { const method = opts.method || 'GET'; const requestHeaders = (0, lodash_1.merge)(opts.noAuth ? {} : this.authenticationHeader, opts.headers || {}); const requestOptions = { method, url: `${this.baseUrl}${opts.apiVersion || ApiVersions.V1}${opts.api}`, qs: opts.qs || {}, headers: requestHeaders, json: opts.json, timeout: this.timeout, body: opts.data, resolveWithFullResponse: true, agent: this.agent, simple: false, retryConfig: this.retryConfig, }; logger.debug(`${method} ${requestOptions.url} qs: ${JSON.stringify(requestOptions.qs)}`); return request_retry_1.RpRetry.rpRetry(requestOptions, logger) .then(async (res) => { if (res.statusCode === 401 && this.refreshTokenHandler && this.auth.refreshToken && this.auth.type !== 'basic') { try { // eslint-disable-next-line unicorn/no-lonely-if if (await this.shouldRefreshToken(requestOptions)) { const auth = await this.refreshTokenHandler(this.auth.refreshToken); if (!auth) { throw new Error(`Refresh token error`); } this.updateAuth(auth); requestOptions.headers = (0, lodash_1.merge)(opts.noAuth ? {} : this.authenticationHeader, opts.headers || {}); return request_retry_1.RpRetry.rpRetry(requestOptions, logger); } } catch (error) { logger.error(error.message); return Promise.reject(error); } } return Promise.resolve(res); }) .then((res) => { const curLogger = res.statusCode >= 400 ? logger.error.bind(logger) : logger.debug.bind(logger); curLogger(`${method} ${requestOptions.url} qs: ${JSON.stringify(requestOptions.qs)} status: ${res.statusCode}`); return res; }); } // this func is performing all the pagination inside (up to MAX_PAGES, or until predicate is true) async paginateForResult(opts, predicate) { let start = 0; let page = 0; let isLastPage = false; const allRes = []; while (!isLastPage && page < MAX_PAGES) { opts.qs = (0, lodash_1.merge)(opts.qs, { start: '' + start, limit: '' + LIMIT_PER_PAGE, }); const res = await this.performAPICall(opts); if (res.statusCode >= 400) { throw new CFError({ message: `Failed pagination`, cause: _extractErrorFromResponse(res), }); } isLastPage = res.body.isLastPage; const predicateResult = predicate && (0, lodash_1.find)(res.body.values, predicate); if (predicateResult) { return predicateResult; // found the wanted result } allRes.push(...res.body.values); start += LIMIT_PER_PAGE; page += 1; } return predicate ? undefined : allRes; } async getProjectKey(projectName) { if (projectName.startsWith('~')) { return projectName; } const nameExists = await this.verifyProjectKey(projectName); if (nameExists) { return projectName; } const isUsername = await this.verifyProjectKey('~' + projectName); if (isUsername) { return '~' + projectName; } const proj = await this.paginateForResult({ api: `projects`, json: true, qs: { name: projectName, }, }, (project) => project.name === projectName); if (!proj) { throw new CFError(`Project "${projectName}" was not found`); } return proj.key; } async verifyProjectKey(projectKey) { try { const res = await this.performAPICall({ api: `projects/${projectKey}`, json: true, }); return res.statusCode === 200; } catch (error) { throw new CFError({ message: `Failed to get project with key "${projectKey}"`, cause: error, }); } } getName() { return 'bitbucket-server'; } async fetchRawFile(opts) { const projectKey = await this.getProjectKey(opts.owner); const lines = []; let start = 0; let page = 0; let isLastPage = false; while (!isLastPage && page < MAX_PAGES) { const res = await this.performAPICall({ apiVersion: ApiVersions.V1, api: `projects/${projectKey}/repos/${opts.repo}/browse/${(0, helpers_1.cleanEncodedFilePath)(opts.path, { preserveSlashes: true })}`, json: true, qs: { at: opts.ref, raw: 'true', start: '' + start, limit: '' + LIMIT_PER_PAGE, }, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to retrieve file: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(res) }); } isLastPage = res.body.isLastPage; lines.push(...(0, lodash_1.map)(res.body.lines, (line) => line.text)); start += LIMIT_PER_PAGE; page += 1; } return lines.join('\n'); } async getBranch(opts) { const projectKey = await this.getProjectKey(opts.owner); const [getBranchErr, branch] = await (0, helpers_1.to)(this.paginateForResult({ apiVersion: ApiVersions.V1, api: `projects/${projectKey}/repos/${opts.repo}/branches`, json: true, qs: { filterText: opts.branch, details: 'true', }, }, (b) => (0, lodash_1.isEqual)(opts.branch, b.displayId ? b.displayId : (0, lodash_1.last)(b.id.split('/'))))); if (getBranchErr) { throw new CFError({ message: `Failed to retrieve branch, opts: ${JSON.stringify(opts)}`, cause: getBranchErr, }); } if (!branch) { throw new CFError(`Failed to retrieve branch: ${JSON.stringify(opts)}, branch not found`); } return _toBranch(branch); } async getRepository(opts) { const projectKey = await this.getProjectKey(opts.owner); const [repo, lastCommit, defaultBranch] = await Promise.all([ this.performAPICall({ apiVersion: ApiVersions.V1, api: `projects/${projectKey}/repos/${opts.repo}?avatarSize=${exports.avatarSize}`, json: true, }), this.performAPICall({ apiVersion: ApiVersions.V1, api: `projects/${projectKey}/repos/${opts.repo}/commits`, json: true, }), this.performAPICall({ apiVersion: ApiVersions.V1, api: `projects/${projectKey}/repos/${opts.repo}/branches/default`, json: true, }), ]); if (repo.statusCode >= 400) { throw new CFError({ message: `Failed to get repository: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(repo), }); } if (lastCommit.statusCode >= 400) { throw new CFError({ message: `Failed to get repository lastest commit: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(lastCommit), }); } if (defaultBranch.statusCode >= 400) { throw new CFError({ message: `Failed to get repository default branch: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(defaultBranch), }); } return _toRepo(repo.body, (0, lodash_1.get)(lastCommit.body, 'values[0]', {}), defaultBranch.body); } async listBranches(opts) { try { const projectKey = await this.getProjectKey(opts.owner); const branches = await this.paginateForResult({ api: `projects/${projectKey}/repos/${opts.repo}/branches`, qs: { details: String(true), }, json: true, }); return branches.map(_toBranch); } catch (error) { throw new CFError({ message: `Failed list branches, opts: ${JSON.stringify(opts)}`, cause: error, }); } } async createBranch() { throw new Error('Method createBranch not implemented.'); } async listRepositoriesForOwner() { throw new Error('Method listRepositoriesForOwner not implemented.'); } async createRepositoryWebhook(opts) { const projectKey = await this.getProjectKey(opts.owner); const [err, res] = await (0, helpers_1.to)(this.performAPICall({ api: `projects/${projectKey}/repos/${opts.repo}/webhooks`, method: 'POST', json: true, data: { events: [ 'repo:refs_changed', 'pr:opened', 'pr:merged', 'pr:modified', 'pr:deleted', 'pr:declined', 'pr:reviewer:unapproved', 'pr:reviewer:approved', 'pr:reviewer:needs_work', 'pr:comment:edited', 'pr:comment:added', 'pr:comment:deleted', ], configuration: { secret: opts.secret, }, url: opts.endpoint, active: true, }, })); if (err) { throw new CFError({ message: `Failed to create repository webhooks: ${JSON.stringify(opts)}`, cause: err }); } if (res.statusCode >= 400) { throw new CFError({ message: `Failed to create repository webhooks: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(res), }); } return _toWebhook(opts.owner, opts.repo, res.body); } async listWebhooks(opts) { const projectKey = await this.getProjectKey(opts.owner); const [getHooksErr, hooks] = await (0, helpers_1.to)(this.paginateForResult({ api: `projects/${projectKey}/repos/${opts.repo}/webhooks`, json: true, })); if (getHooksErr) { throw new CFError({ message: `Failed to list repository webhooks: ${JSON.stringify(opts)}`, cause: getHooksErr, }); } if (hooks.statusCode >= 400) { throw new CFError({ message: `Failed to list repository webhooks: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(hooks), }); } return hooks.map(_toWebhook.bind(null, opts.owner, opts.repo)); } async deleteRepositoryWebhook(opts) { const projectKey = await this.getProjectKey(opts.owner); const [err, res] = await (0, helpers_1.to)(this.performAPICall({ api: `projects/${projectKey}/repos/${opts.repo}/webhooks/${opts.hookId}`, method: 'DELETE', json: true, })); if (err) { throw new CFError({ message: `Failed to delete webhook: ${JSON.stringify(opts)}`, cause: err, }); } if (res.statusCode >= 400) { throw new CFError({ message: `Failed to delete webhook: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(res), }); } } async listRepositoriesWithAffiliation(opts) { const { limit = 2000 } = opts; let { page = 1 } = opts; const pageSize = Math.min(BITBUCKET_SERVER_RESPONSE_LIMIT, limit); let repoList = []; do { const [getReposErr, repos] = await (0, helpers_1.to)(this.paginateReposResult({ api: `repos?avatarSize=${exports.avatarSize}`, json: true, qs: { permission: 'REPO_READ', }, limit: pageSize, page, })); if (getReposErr) { throw new CFError({ message: `Failed to list repos: ${JSON.stringify(opts)}`, cause: getReposErr, }); } repoList = [...repoList, ...repos]; if (repos.length < pageSize) break; page++; } while (((page - 1) * pageSize < limit)); return repoList.map(_toRepo); } // this func is performing pagination that only return a specific page with limit || LIMIT_PER_PAGE async paginateReposResult(opts) { const limit = opts.limit || LIMIT_PER_PAGE; // bitbucket-server api is 0-based and our api is 1-based const start = (opts.page - 1) * limit; opts.qs = (0, lodash_1.merge)(opts.qs, { start: start.toString(), limit: limit.toString(), }); const res = await this.performAPICall(opts); if (res.statusCode >= 400) { throw new CFError({ message: `Failed pagination`, cause: _extractErrorFromResponse(res), }); } return res.body.values; } async listOrganizations(opts) { const { limit = 25, page = 0 } = opts; const start = page * limit; const [err, res] = await (0, helpers_1.to)(this.performAPICall({ api: `projects`, qs: { start: String(start), limit: String(limit), }, json: true, })); if (err) { throw new CFError({ message: `Failed to list organization`, cause: err, }); } // no organizations if (!res.body.values || res.body.values.length == 0) { return []; } return res.body.values.map((project) => project.name); } async getRepositoryPermissions(opts) { const projectKey = await this.getProjectKey(opts.owner); const [reposReadResErr, reposReadRes] = await (0, helpers_1.to)(this.performAPICall({ apiVersion: ApiVersions.V1, api: `projects/${projectKey}/repos?permissions=REPO_READ`, json: true, })); if (reposReadResErr) { throw new CFError({ message: `Failed to get repository permissions: ${opts.owner}/${opts.repo}`, cause: reposReadResErr, }); } const read = !!reposReadRes.body?.values?.find((repo) => repo.name == opts.repo); const [reposWriteResErr, reposWriteRes] = await (0, helpers_1.to)(this.performAPICall({ apiVersion: ApiVersions.V1, api: `projects/${projectKey}/repos?permissions=REPO_WRITE`, json: true, })); if (reposWriteResErr) { throw new CFError({ message: `Failed to get repository permissions: ${opts.owner}/${opts.repo}`, cause: reposWriteResErr, }); } const write = !!reposWriteRes.body?.values?.find((repo) => repo.name == opts.repo); return { read, write }; } async listRepositoriesForOrganization() { throw new Error('Method listRepositoriesForOrganization not implemented.'); } async createCommitStatus(opts) { const projectKey = await this.getProjectKey(opts.owner); const [err, res] = await (0, helpers_1.to)(this.performAPICall({ api: `projects/${projectKey}/repos/${opts.repo}/commits/${opts.sha}/builds`, method: 'POST', json: true, data: { key: opts.context, name: opts.context, description: opts.description, url: opts.targetUrl, state: statesMap[opts.state], }, })); if (err) { throw new CFError({ message: `Failed to create commit status: ${JSON.stringify(opts)}`, cause: err, }); } if (res.statusCode >= 400) { throw new CFError({ message: `Failed to create commit status: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(res), }); } } async getUser(opts) { let username = opts?.username; if (!username) { const res = await this.performAPICall({ api: `application-properties`, method: 'HEAD', json: true, }); if (!res.headers['x-ausername']) { throw new CFError(`ValidationError: check your token permissions, failed assert read scopes`); } username = res.headers['x-ausername']; } const [err, res] = await (0, helpers_1.to)(this.performAPICall({ api: `users/${username}?avatarSize=${exports.avatarSize}`, method: 'GET', json: true, })); if (err) { throw new CFError({ message: `Failed to get ${username}`, cause: err, }); } if (res.body?.errors) { throw new CFError(`Failed to get ${username}, err: ${JSON.stringify(res.body.errors)}`); } return _toUser(res.body); } async getUserByEmail(email) { const [err, user] = await (0, helpers_1.to)(this.paginateForResult({ api: `users?avatarSize=${exports.avatarSize}`, json: true, }, (users) => users.emailAddress === email)); if (err) { throw new CFError({ message: `Failed to get user connected to email ${email}`, cause: err, }); } return _toUser(user); } async getPullRequestFiles() { throw new Error('Method getPullRequestFiles not implemented.'); } async getPullRequest() { throw new Error('Method getPullRequest not implemented.'); } async searchMergedPullRequestByCommitSha() { throw new Error('Method searchMergedPullRequestByCommitSha not implemented.'); } async createPullRequest() { throw new Error('Method getPullRequest not implemented.'); } async closePullRequest() { throw new Error('Method closePullRequest not implemented.'); } async assertApiScopes(opts) { if (opts.scopes.includes('admin_repo_hook')) { await this.assertAdminScope(); return; } if ((opts.scopes.includes('repo_write') || opts.scopes.includes('repo_create')) && opts.repoUrl) { await this.assertWriteScope(opts.repoUrl); return; } if (opts.scopes.includes('repo_read')) { await this.assertReadScope(); } } async validateToken() { const res = await this.performAPICall({ api: `application-properties`, method: 'HEAD', json: true, }); if (res.statusCode == 401) { throw new CFError(`ValidationError: token is invalid`); } } skipPermissionsValidation() { return { skip: false }; } async assertReadScope() { const res = await this.performAPICall({ api: `application-properties`, method: 'HEAD', json: true, }); if (!res.headers['x-ausername']) { throw new CFError(`ValidationError: check your token permissions, failed assert read scopes`); } } async assertWriteScope(repoUrl) { try { await (0, helpers_1.assertPatWritePermission)(repoUrl, this.getAuth()); } catch (error) { throw new CFError({ message: `ValidationError: check your token permissions, failed assert write scopes`, cause: error }); } } async assertAdminScope() { const res = await this.performAPICall({ api: `application-properties`, method: 'HEAD', json: true, }); if (!res.headers['x-ausername']) { throw new CFError(`ValidationError: check your token permissions, failed assert admin scopes`); } const username = res.headers['x-ausername']; const [projErr, projRes] = await (0, helpers_1.to)(this.performAPICall({ api: `users/${username}/repos`, method: 'POST', json: true, data: { name: '!Invalid' } })); if (projRes.statusCode !== 400) { throw new CFError({ message: `ValidationError: failed to get project, check your token permissions, expected Project Admin`, cause: projErr }); } } toOwnerRepo(fullRepoName) { const repoInfo = getRepoInfo(fullRepoName); const repo = repoInfo.repo; const owner = repoInfo.owner; const noun = repoInfo.noun; if (!owner || !repo) { throw new CFError(`Failed to get repo and owner from repository name ${fullRepoName}`); } return [noun + owner, repo]; } getAuth() { return { headers: this.authenticationHeader }; } isTokenMutable() { return false; } requiresRepoToCheckTokenScopes() { return true; } useAdminForUserPermission() { return false; } isRepoNotFoundError(error) { const errorMessage = error.message || ''; const causeMessage = error.cause?.message || ''; const fullMessage = `${errorMessage} ${causeMessage}`; return fullMessage.includes('no longer exists'); } async getGitAuth() { return this.auth; } } exports.default = BitbucketServer; //# sourceMappingURL=bitbucket-server.js.map