@codefresh-io/cf-git-providers
Version:
An NPM module/CLI for interacting with various git providers
623 lines • 24.4 kB
JavaScript
;
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