UNPKG

@codefresh-io/cf-git-providers

Version:

An NPM module/CLI for interacting with various git providers

623 lines 24.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const lodash_1 = require("lodash"); const request_promise_1 = __importDefault(require("request-promise")); const types_1 = require("./types"); const https_1 = require("https"); const helpers_1 = require("../helpers"); const url_1 = require("url"); const querystring_1 = __importDefault(require("querystring")); 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'); const LIMIT_PER_PAGE = 100; const MAX_PAGE = 200; const MAX_PAGES = 30; const repository_read_union = ['repository:read', 'repository:write', 'repository:admin']; const repository_write_union = ['repository:write', 'repository:admin']; const repository_admin_union = ['repository:admin']; const account_read_union = ['account:read', 'account:write']; const ws_read_union = ['team', 'team:write']; const basic_read_scopes = [account_read_union, ws_read_union]; const raw_user_regexp = /.* <(?<email>.*@.*)>/; const scopesMap = { repo_read: [repository_read_union, ...basic_read_scopes], repo_write: [repository_write_union], repo_create: [repository_admin_union], admin_repo_hook: [['webhook']], }; const sortMap = { name: 'full_name', pushed: 'updated_on', created: 'created_on', }; const _extractErrorFromResponse = (response) => { let message = (0, lodash_1.get)(response, 'body.error.message', ''); const errorId = (0, lodash_1.get)(response, 'body.error.id'); if (errorId) { message = `${message} [${errorId}]`; } return new types_1.HttpError(response.statusCode, message); }; const _toRepo = (rawRepo, issues) => { // Updating this mapper, please adjust `fields` query string accordingly in all dependent API calls. const isGroup = (0, lodash_1.get)(rawRepo, 'owner.type') === 'team'; const httpsClone = rawRepo.links.clone.find((link) => link.name === 'https'); const sshClone = rawRepo.links.clone.find((link) => link.name === 'ssh'); return { id: rawRepo.uuid, provider: 'bitbucket', name: rawRepo.slug || rawRepo.name, full_name: rawRepo.full_name, private: rawRepo.is_private, pushed_at: rawRepo.updated_on, open_issues: issues ? issues : 0, clone_url: (0, lodash_1.get)(httpsClone, 'href', ''), ssh_url: (0, lodash_1.get)(sshClone, 'href', ''), owner: { login: (0, lodash_1.get)(rawRepo, 'workspace.slug') || (0, lodash_1.get)(rawRepo, 'owner.display_name', ''), avatar_url: (0, lodash_1.get)(rawRepo, 'owner.links.avatar.href', ''), creator: null, }, org: isGroup ? (0, lodash_1.get)(rawRepo, 'owner.username') : null, default_branch: (0, lodash_1.get)(rawRepo, 'mainbranch.name', ''), permissions: { admin: true, // this value is hard to get with bitbucket, for now use 'true' }, webUrl: (0, lodash_1.get)(rawRepo, 'links.html.href', ''), }; }; const _toBranch = (rawBranch) => { return { name: rawBranch.name, id: rawBranch.name, commit: { sha: rawBranch.target.hash, commiter_name: (0, lodash_1.get)(rawBranch, 'target.author.user.display_name', ''), message: (0, lodash_1.get)(rawBranch, 'target.message', ''), url: (0, lodash_1.get)(rawBranch, 'target.links.html.href', ''), } }; }; const _toUser = (rawUser, rawEmails) => { let isLastPrimary = false; let lastEmailInfo; for (let i = 0; i < rawEmails.length; i++) { const emailInfo = rawEmails[i]; isLastPrimary = lastEmailInfo?.is_primary; if (emailInfo.is_confirmed && emailInfo.is_primary) { lastEmailInfo = emailInfo; break; } if (emailInfo.is_primary) { lastEmailInfo = emailInfo; } if ((lastEmailInfo && !isLastPrimary || !lastEmailInfo) && emailInfo.is_confirmed) { lastEmailInfo = emailInfo; } } return { login: rawUser.username, avatar_url: rawUser?.links?.avatar?.href || '', email: lastEmailInfo?.email || '', web_url: lastEmailInfo?.links?.html?.href || '', }; }; const _toUserFromCommit = (commit) => { const { author: { raw, user, }, } = commit; const match = raw.match(raw_user_regexp); if (!match) { throw new CFError(`failed to get user from commit ${commit}`); } return { login: user.display_name, avatar_url: user.links.avatar.href, email: match.groups?.email, web_url: user.links.html.href, }; }; class Bitbucket { baseUrl; apiPrefix = 'api/2.0/'; authenticationHeader; timeout; agent; retryConfig; auth; refreshTokenHandler; constructor(opts) { this.baseUrl = opts.apiURL || 'https://bitbucket.org/'; if (!this.baseUrl.endsWith('/')) { this.baseUrl = `${this.baseUrl}/`; } if (this.baseUrl === 'https://api.bitbucket.org/') { this.apiPrefix = '2.0/'; } else if (this.baseUrl.endsWith('2.0/')) { // already has apiPrefix in url this.apiPrefix = ''; } this.timeout = opts.timeout || 10000; this.refreshTokenHandler = opts.refreshTokenHandler; if (opts.username && opts.username !== 'x-token-auth') { // eslint-disable-next-line @typescript-eslint/naming-convention this.authenticationHeader = { 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 (this.baseUrl.startsWith('https') && (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' || opts.insecure)) { this.agent = new https_1.Agent({ rejectUnauthorized: false }); } this.retryConfig = opts.retryConfig; } getName() { return 'bitbucket'; } async shouldRefreshToken(requestOptions) { const validateTokenRequestOptions = (0, lodash_1.cloneDeep)(requestOptions); validateTokenRequestOptions.qs = {}; validateTokenRequestOptions.url = `${this.baseUrl}${this.apiPrefix}repositories?limit=1`; const response = await (0, request_promise_1.default)(validateTokenRequestOptions); return response.statusCode === 401; } 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}` } : {}; } doRefreshToken = (self) => async (requestOptions, response) => { return Promise.resolve(response) .then(async (res) => { if (self.refreshTokenHandler && self.auth.refreshToken && self.auth.type !== 'basic') { // eslint-disable-next-line unicorn/no-lonely-if logger.debug(`Checking conditions for token refreshing`); if (await self.shouldRefreshToken(requestOptions)) { logger.debug(`Refreshing access token`); const auth = await self.refreshTokenHandler(self.auth.refreshToken); if (!auth) { throw new Error(`Refresh token error`); } this.updateAuth(auth); requestOptions.headers = (0, lodash_1.merge)(requestOptions.headers, self.authenticationHeader); logger.debug(`Using new access token for repeating request`); return (0, request_promise_1.default)(requestOptions); } } return res; }) .then((res) => { const curLogger = res.statusCode >= 400 ? logger.error.bind(logger) : logger.debug.bind(logger); curLogger(`${requestOptions.method} ${requestOptions.url} qs: ${JSON.stringify(requestOptions.qs)} status: ${res.statusCode}`); return res; }); }; 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}${this.apiPrefix}${opts.api}`, qs: opts.qs || {}, headers: requestHeaders, json: opts.json, timeout: this.timeout, form: opts.data, resolveWithFullResponse: true, agent: this.agent, simple: false, retryConfig: this.retryConfig, }; return request_retry_1.RpRetry.rpRetry(requestOptions, logger, this.doRefreshToken(this)); } async paginate(opts, predicate) { let start = opts.page || 1; let isLastPage = false; const results = []; const limit = opts.limit || Number.MAX_SAFE_INTEGER; while (!isLastPage && start <= MAX_PAGES && results.length < limit) { opts.qs = (0, lodash_1.merge)(opts.qs, { page: '' + start, pagelen: '' + (limit - results.length > LIMIT_PER_PAGE ? LIMIT_PER_PAGE : limit - results.length), }); const res = await this.performAPICall(opts); if (res.statusCode >= 400) { return res; } if (predicate) { const predicateResult = (0, lodash_1.find)(res.body.values, predicate); if (predicateResult) { // found the wanted result return { statusCode: 200, body: predicateResult, }; } } res.body.values.forEach((result) => { results.push(result); }); isLastPage = !(0, lodash_1.has)(res.body, 'next'); start += 1; } return { statusCode: 200, body: results, }; } async paginateWithNext(opts) { let isLastPage = false; const results = []; const limit = opts.limit || Number.MAX_SAFE_INTEGER; opts.qs = (0, lodash_1.merge)(opts.qs, { pagelen: limit }); const nextOpts = { ...opts }; for (let i = 1; i <= MAX_PAGE && !isLastPage; i++) { const res = await this.performAPICall(nextOpts); const nextUrl = res.body?.next; isLastPage = !nextUrl; if (nextUrl) { const url = new url_1.URL(nextUrl); nextOpts.qs = querystring_1.default.parse(url.searchParams.toString()); url.search = ''; nextOpts.url = url.toString(); } if (res.statusCode >= 400) { return res; } if (opts.page && i < opts.page) { // skip all pages until we reach our desired page continue; } const resultsLeft = limit - results.length; res.body.values.slice(0, resultsLeft).forEach((result) => results.push(result)); if (results.length >= limit) { break; } } return { statusCode: 200, body: results, }; } async createRepository(opt) { const repoRes = await this.performAPICall({ api: `repositories/${opt.owner}/${opt.repo}`, method: 'POST', json: true, data: { is_private: opt.private, } }); if (repoRes.statusCode >= 400) { throw new CFError({ message: `Failed to create repository: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(repoRes), }); } return _toRepo(repoRes.body, 0); } async fetchRawFile(opts) { const res = await this.performAPICall({ api: `repositories/${opts.owner}/${opts.repo}/src/${opts.ref}/${(0, helpers_1.cleanEncodedFilePath)(opts.path)}`, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to retrieve file: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(res), }); } return res.body; } async getBranch(opts) { const res = await this.performAPICall({ api: `repositories/${opts.owner}/${opts.repo}/refs/branches/${opts.branch}`, json: true }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to retrieve branch: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(res), }); } return _toBranch(res.body); } async getRepository(opts) { const repoPromise = this.performAPICall({ api: `repositories/${opts.owner}/${opts.repo}`, json: true, }); const issuesPromise = this.performAPICall({ api: `repositories/${opts.owner}/${opts.repo}/issues`, json: true, }); const [repoResponse, issuesResponse] = await Promise.all([repoPromise, issuesPromise]); if (repoResponse.statusCode >= 400) { throw new CFError({ message: `Failed to get repository: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(repoResponse), }); } if (issuesResponse.statusCode >= 400) { logger.warn(`Failed to get repository issues: ${JSON.stringify(opts)}, ${_extractErrorFromResponse(issuesResponse)}`); issuesResponse.body.size = 0; // have 0 issues } return _toRepo(repoResponse.body, issuesResponse.body.size); } async listBranches(opts) { const res = await this.paginate({ api: `repositories/${opts.owner}/${opts.repo}/refs/branches`, json: true, page: opts.page, limit: opts.limit, qs: { ...(opts.branchMatchingName && { q: `name~"${opts.branchMatchingName}"` }) }, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to get branches list: ${JSON.stringify(opts)}`, cause: _extractErrorFromResponse(res), }); } return res.body.map(_toBranch); } async createBranch() { throw new Error('Method createBranch not implemented.'); } async listRepositoriesForOwner() { throw new Error('Method listRepositoriesForOwner not implemented.'); } async createRepositoryWebhook() { throw new Error('Method createRepositoryWebhook not implemented.'); } async listWebhooks() { throw new Error('Method listWebhooks not implemented.'); } async deleteRepositoryWebhook() { throw new Error('Method deleteRepositoryWebhook not implemented.'); } async listRepositoriesWithAffiliation(opt) { const { limit = 100, page = 1, direction = 'desc', sort, affiliation = 'member', filters } = opt; // Updating fields list, please update _toRepo mapper accordingly. const fields = [ 'size', 'page', 'pagelen', 'next', 'previous', 'values.owner.type', 'values.links.clone', 'values.uuid', 'values.slug', 'values.name', 'values.full_name', 'values.is_private', 'values.updated_on', 'values.workspace.slug', 'values.owner.display_name', 'values.owner.links.avatar.href', 'values.owner.username', 'values.mainbranch.name', 'values.links.html.href', ].join(','); const res = await this.paginateWithNext({ api: `repositories`, page: page, limit: limit, json: true, qs: { role: affiliation, fields, ...(filters?.name && { q: `full_name~"${filters.name}"` }), ...(sort && { sort: `${direction === 'desc' ? '-' : ''}${sortMap[sort]}` }), } }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to get repositories list: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return res.body.map(_toRepo); } async listOrganizations(opt) { const { limit = 25, page = 1 } = opt; const res = await this.paginate({ api: 'user/permissions/workspaces', page: page, limit: limit, json: true, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to get organization list: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return res.body .filter((i) => i.permission !== 'member') // filter workspaces where you can't create repos .map((i) => i.workspace.slug); } async listRepositoriesForOrganization() { throw new Error('Method listRepositoriesForOrganization not implemented.'); } async createCommitStatus() { throw new Error('Method createCommitStatus not implemented.'); } async getUser(opt) { if (!opt?.username) { return await this.getCurrentUser(); } if (!opt.orgRepo || !opt.commitHash) { throw new CFError({ message: 'bitbucket does not support getting user by username, missing required parameters orgRepo and commitHash', statusCode: 400, statusText: 'Bad Request', }); } return await this.getUserFromCommit(opt.orgRepo, opt.commitHash); } async getUserByEmail(email, orgRepo, commitHash) { if (!commitHash || !orgRepo) { throw new CFError({ message: 'bitbucket does not support getting user by email, missing required parameters orgRepo and commitHash', statusCode: 400, statusText: 'Bad Request', }); } return await this.getUserFromCommit(orgRepo, commitHash); } 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 createPullRequest not implemented.'); } async getRepositoryPermissions(opt) { const permission = { read: false, write: false }; const res = await this.performAPICall({ api: `user/permissions/repositories`, json: true, qs: { q: `repository.name="${opt.repo}"`, sort: `repository.name` } }); if (res.statusCode >= 400) { if (res.statusCode != 401 && res.statusCode != 403) { throw new CFError({ message: `Failed to get repository permissions: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return permission; } if (res.body.values.length === 0) { return permission; } const repoPermission = res.body.values.find((repo) => repo.repository.name === opt.repo); permission.read = ['admin', 'write', 'read'].includes(repoPermission.permission); permission.write = ['admin', 'write'].includes(repoPermission.permission); return permission; } async assertApiScopes(opt) { const userRes = await this.performAPICall({ api: `user`, json: true, }); if (userRes.statusCode >= 400) { throw new CFError({ message: `ValidationError: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(userRes) }); } if (!userRes.headers['x-oauth-scopes']) { throw new CFError(`ValidationError: missing scopes: ${opt.scopes.toString()}`); } const originalScopes = userRes.headers['x-oauth-scopes'].replace(/ /g, '').split(','); const isValid = opt.scopes.every(val => { const scopesInfo = scopesMap[val]; let isCurrScopeValid = true; for (let i = 0; i < scopesInfo.length; i++) { const scopeOpts = scopesInfo[i]; isCurrScopeValid = scopeOpts.some(scope => originalScopes.includes(scope)); } return isCurrScopeValid; }); if (!isValid) { throw new CFError(`ValidationError: got scopes ${userRes.headers['x-oauth-scopes'].toString()} while expected: ${opt.scopes.toString()}`); } } async validateToken() { const res = await this.performAPICall({ api: `user`, json: true, }); if (res.statusCode == 401) { throw new CFError(`ValidationError: token is invalid`); } } skipPermissionsValidation() { return { skip: false }; } toOwnerRepo(fullRepoName) { const [owner, ...repoParts] = fullRepoName.split('/'); return [owner, repoParts.join('/')]; } getAuth() { return { headers: this.authenticationHeader, }; } isTokenMutable() { return false; } requiresRepoToCheckTokenScopes() { return false; } async getCurrentUser() { const userRes = await this.performAPICall({ api: `user`, json: true, }); if (userRes.statusCode >= 400) { throw new CFError({ message: `Failed to get current user, ${_extractErrorFromResponse(userRes)}`, cause: _extractErrorFromResponse(userRes), statusCode: userRes.statusCode, statusText: userRes.statusText, }); } const emailsRes = await this.performAPICall({ api: `user/emails`, json: true, }); if (emailsRes.statusCode >= 400) { throw new CFError({ message: `Failed to get current user emails, ${_extractErrorFromResponse(emailsRes)}`, cause: _extractErrorFromResponse(emailsRes), statusCode: emailsRes.statusCode, statusText: emailsRes.statusText, }); } return _toUser(userRes.body, emailsRes.body.values); } async getUserFromCommit(orgRepo, commitHash) { const [owner, repo] = this.toOwnerRepo(orgRepo); const res = await this.performAPICall({ api: `repositories/${owner}/${repo}/commit/${commitHash}`, json: true, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to get user from commit: ${owner}/${repo}/commit/${commitHash}`, cause: _extractErrorFromResponse(res), }); } return _toUserFromCommit(res.body); } useAdminForUserPermission() { return false; } } exports.default = Bitbucket; //# sourceMappingURL=bitbucket.js.map