UNPKG

@codefresh-io/cf-git-providers

Version:

An NPM module/CLI for interacting with various git providers

677 lines 25.7 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 querystring_1 = __importDefault(require("querystring")); const types_1 = require("./types"); const https_1 = require("https"); const helpers_1 = require("../helpers"); const url_1 = require("url"); 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:gitlab'); const MAX_PER_PAGE = 100; const MAX_CONCURRENT_REQUESTS = 4; // requests X pages each time const MAX_RESULTS = 10000; const DEFAULT_GROUP = 'GitLab Instance'; const ApiVersions = { v4: 'api/v4/', }; const statesMap = { pending: 'pending', running: 'running', success: 'success', failure: 'failed', error: 'failed', }; const _extractErrorFromResponse = (res) => { const message = (0, lodash_1.get)(res, 'body.error', '') || _extractMessageFromObject(res.body?.message); return new types_1.HttpError(res.statusCode, message); }; const _extractMessageFromObject = (message) => { if (typeof message !== 'object') { return message; } let finalMessage = ''; Object.keys(message).map((key) => finalMessage += message[key]); return finalMessage; }; const _toOrderBy = (orderBy) => { switch (orderBy) { case 'name': return 'name'; case 'created': return 'created_at'; case 'pushed': return 'last_activity_at'; default: return 'last_activity_at'; } }; const _encodePath = (path) => { return encodeURIComponent(path).replace(/\./g, '%2E'); }; const _encodeProjectPath = (group, project) => { return _encodePath(`${group}/${project}`); }; const _toBranch = (rawBranch) => { return { name: rawBranch.name, id: rawBranch.name, commit: { sha: rawBranch.commit.id, commiter_name: rawBranch.commit.author_name, message: rawBranch.commit.message, url: rawBranch.commit.web_url, } }; }; const _toUser = (user, email) => { return { login: user.username, email: email || user.email, avatar_url: user.avatar_url, web_url: user.web_url, }; }; const _toRepo = (rawRepo) => { const isGroup = (0, lodash_1.get)(rawRepo, 'namespace.kind') === 'group'; // is it a group/org repository return { id: rawRepo.id.toString(), provider: 'gitlab', name: rawRepo.name, full_name: rawRepo.path_with_namespace, private: rawRepo.visibility === 'private', pushed_at: rawRepo.last_activity_at, open_issues: rawRepo.open_issues_count, clone_url: rawRepo.http_url_to_repo, ssh_url: rawRepo.ssh_url_to_repo, owner: { login: (0, lodash_1.get)(rawRepo, isGroup ? 'namespace.full_path' : 'owner.username', ''), avatar_url: (0, lodash_1.get)(rawRepo, isGroup ? 'namespace.avatar_url' : 'owner.avatar_url', '') || '', creator: (0, lodash_1.get)(rawRepo, isGroup ? 'namespace.name' : 'owner.name', null), }, org: isGroup ? (0, lodash_1.get)(rawRepo, 'namespace.name') : null, default_branch: rawRepo.default_branch, permissions: { admin: (0, lodash_1.get)(rawRepo, `permissions.${isGroup ? 'group_access' : 'project_access'}.access_level`, 0) >= 40, }, webUrl: rawRepo.web_url || '', }; }; const _toWebhook = (owner, repo, rawWebhook) => { return { id: rawWebhook.id, name: String(rawWebhook.id), endpoint: rawWebhook.url, repo: `${owner}/${repo}`, events: Object.keys({ ...(rawWebhook.push_events && { push_events: true }), ...(rawWebhook.tag_push_events && { tag_push_events: true }), ...(rawWebhook.merge_requests_events && { merge_requests_events: true }), ...(rawWebhook.issues_events && { issues_events: true }), ...(rawWebhook.releases_events && { releases_events: true }), }), }; }; class Gitlab { baseUrl; authenticationHeader; timeout; agent; auth; refreshTokenHandler; retryConfig; constructor(opts) { const url = new url_1.URL(opts.apiURL || opts.apiUrl || 'https://gitlab.com/'); if (url.pathname === '/') { url.pathname = ApiVersions.v4; } this.baseUrl = url.href; if (!this.baseUrl.endsWith('/')) { this.baseUrl = `${this.baseUrl}/`; } this.timeout = opts.timeout || 10000; this.refreshTokenHandler = opts.refreshTokenHandler; // works for both oauth2 and private token authentication methods // eslint-disable-next-line @typescript-eslint/naming-convention this.authenticationHeader = opts.password ? { Authorization: `Bearer ${opts.password}` } : {}; this.auth = { username: 'oauth2', 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; } async createRepository(opt) { let namespace_id; try { namespace_id = await this.getGroupId({ groupName: opt.owner }); } catch { namespace_id = await this.getGroupId({ groupName: DEFAULT_GROUP }); } const res = await this.performAPICall({ api: `projects`, json: true, method: 'POST', body: { name: opt.repo, namespace_id } }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to get repository: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return _toRepo(res.body); } async shouldRefreshToken(requestOptions) { const validateTokenRequestOptions = (0, lodash_1.cloneDeep)(requestOptions); validateTokenRequestOptions.qs = {}; validateTokenRequestOptions.url = `${this.baseUrl}user`; 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.api}`, qs: opts.qs || {}, headers: requestHeaders, json: opts.json, timeout: this.timeout, form: opts.data, resolveWithFullResponse: true, agent: this.agent, simple: false, body: opts.body, retryConfig: this.retryConfig, }; let requestStr = `${method} ${requestOptions.url}`; if (Object.keys(requestOptions.qs).length) { requestStr += `?${querystring_1.default.stringify(requestOptions.qs)}`; } logger.debug(`request: ${requestStr}`); return request_retry_1.RpRetry.rpRetry(requestOptions, logger) .then(async (res) => { if (res.statusCode === 401 && this.refreshTokenHandler && this.auth.refreshToken) { 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) => { logger.debug(`request: ${requestStr} status: ${res.statusCode}`); return res; }); } async getPaginatedResults(apiFunc, args, limit = MAX_RESULTS, page = 1) { const finalResults = []; let startingPage = page; let shouldStop = false; while (!shouldStop) { const neededResults = limit - finalResults.length; // make up to MAX_PAGES_PER_CYCLE requests in parallel const neededPages = Math.min((0, lodash_1.ceil)(neededResults / MAX_PER_PAGE), MAX_CONCURRENT_REQUESTS); const pagePromises = (0, lodash_1.range)(startingPage, startingPage + neededPages).map((pageNumber) => apiFunc((0, lodash_1.merge)(args, { qs: { page: pageNumber, per_page: MAX_PER_PAGE, } }))); // harvest results const pagesResults = await Promise.all(pagePromises); for (const res of pagesResults) { if (res.statusCode >= 400) { return res; } const currentNeededResults = limit - finalResults.length; const data = res.body; finalResults.push(...data.slice(0, currentNeededResults)); if (limit === finalResults.length) { shouldStop = true; break; // got all the results we need, no reason to process other requests } if (data.length < MAX_PER_PAGE) { shouldStop = true; break; // last page, no reason to process other requests } } // next batch startingPage += neededPages; } return { data: finalResults, statusCode: 200, }; } getName() { return 'gitlab'; } async fetchRawFile(opt) { const res = await this.performAPICall({ api: `projects/${_encodeProjectPath(opt.owner, opt.repo)}/repository/files/${(0, helpers_1.cleanEncodedFilePath)(opt.path)}`, qs: { ref: opt.ref }, json: true, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to retrieve file: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } const base64Content = res.body.content.replace(/\n/gi, ''); return Buffer.from(base64Content, 'base64').toString(); } async getBranch(opt) { const res = await this.performAPICall({ api: `projects/${_encodeProjectPath(opt.owner, opt.repo)}/repository/branches/${opt.branch}`, json: true }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to retrieve branch: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return _toBranch(res.body); } async getRepository(opt) { const res = await this.performAPICall({ api: `projects/${_encodeProjectPath(opt.owner, opt.repo)}`, json: true, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to get repository: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res) }); } return _toRepo(res.body); } async listBranches(opt) { const res = await this.performAPICall({ api: `projects/${_encodeProjectPath(opt.owner, opt.repo)}/repository/branches`, json: true, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to list repository branches: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return res.body.map(_toBranch); } async createBranch() { throw new Error('Method createBranch not implemented.'); } async listRepositoriesForOwner(opt) { const { owner, sort, direction = 'desc', limit = 20, page = 1, } = opt; const res = await this.performAPICall({ api: `users/${owner}/projects`, qs: { order_by: _toOrderBy(sort), sort: direction, page: String(page), per_page: String(limit), }, json: true, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to list repositories for owner: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return res.body.map(_toRepo); } async listWebhooks(opt) { const { owner, repo } = opt; const res = await this.performAPICall({ api: `projects/${_encodeProjectPath(owner, repo)}/hooks`, json: true, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to list repository webhooks: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return res.body.map(_toWebhook.bind(undefined, owner, repo)); } async createRepositoryWebhook(opt) { const { owner, repo, endpoint, secret } = opt; const res = await this.performAPICall({ api: `projects/${_encodeProjectPath(owner, repo)}/hooks`, method: 'POST', json: true, body: { url: endpoint, token: secret, push_events: true, merge_requests_events: true, tag_push_events: true, description: 'Codefresh', } }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to create repository webhook: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return _toWebhook(owner, repo, res.body); } async deleteRepositoryWebhook(opt) { const { owner, repo, hookId } = opt; const res = await this.performAPICall({ api: `projects/${_encodeProjectPath(owner, repo)}/hooks/${hookId}`, method: 'DELETE', }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to delete repository webhook: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } } async listRepositoriesWithAffiliation(opt) { const { affiliation, sort, direction, limit, page } = opt; const [err, res] = await (0, helpers_1.to)(this.getPaginatedResults(this.performAPICall.bind(this), { api: 'projects/', json: true, qs: { membership: true, order_by: _toOrderBy(sort), simple: false, ...(direction && { sort: direction }), ...(affiliation === 'owner' && { owned: true }) }, }, limit, page)); if (err) { throw new CFError({ message: `Failed to list repositories: ${JSON.stringify(opt)}`, cause: err, }); } if (res.statusCode >= 400) { throw new CFError({ message: `Failed to list repositories: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return res.data.map(_toRepo); } async listOrganizations(opt) { const { limit = 20, page = 1 } = opt; const res = await this.performAPICall({ api: `groups`, qs: { all_available: 'true', page: String(page), perPage: String(limit), }, json: true, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to get repository: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return res.body.map((group) => group.name); } async listRepositoriesForOrganization(opt) { const { organization, sort, direction = 'desc', limit = 20, page = 1, } = opt; const res = await this.performAPICall({ api: `groups/${_encodePath(organization)}/projects`, qs: { order_by: _toOrderBy(sort), sort: direction, page: String(page), per_page: String(limit), }, json: true, }); if (res.statusCode >= 400) { throw new CFError({ message: `Failed to list repositores for organization: ${JSON.stringify(opt)}`, cause: _extractErrorFromResponse(res), }); } return res.body.map(_toRepo); } async createCommitStatus(opt) { const res = await this.performAPICall({ api: `projects/${_encodeProjectPath(opt.owner, opt.repo)}/statuses/${opt.sha}`, method: 'POST', json: true, body: { context: opt.context, description: opt.description, target_url: opt.targetUrl, state: statesMap[opt.state], } }); const error = _extractErrorFromResponse(res); if (res.statusCode === 400 && error.message.includes('running') && error.message.includes('Cannot transition status')) { /* for fixing pipelines running forever, avoid retry mechanism in case of error that cause by change commit status from running to running https://codefresh-io.atlassian.net/browse/CR-5024?atlOrigin=eyJpIjoiNmNkODA2NzdhNmVlNGVhNWFiYjljZjE3OTc3ZmNlZDkiLCJwIjoiaiJ9 */ return; } if (res.statusCode >= 400) { throw new CFError({ message: `Failed to create commit status: ${JSON.stringify(opt)}`, cause: error, }); } } async getUser(opt) { const user = opt?.username ? opt.username : 'authenticated user'; const [err, res] = await (0, helpers_1.to)(this.performAPICall({ api: opt?.username ? 'users' : 'user', json: true, qs: opt?.username ? { username: opt.username } : {} })); if (err) { throw new CFError({ message: `Failed to get user "${user}"`, cause: err, }); } else if (res.statusCode >= 400) { throw new CFError({ message: `Failed to get user "${user}"`, cause: _extractErrorFromResponse(res), }); } const userObj = Array.isArray(res.body) ? res.body[0] : res.body; return _toUser(userObj); } async getUserByEmail(email) { const [err, res] = await (0, helpers_1.to)(this.performAPICall({ api: 'users', json: true, qs: { search: email } })); if (err) { throw new CFError({ message: `Failed to get user with email "${email}"`, cause: err, }); } else if (res.statusCode >= 400) { throw new CFError({ message: `Failed to get user with email "${email}"`, cause: _extractErrorFromResponse(res), }); } if (res.body.length === 0) { throw new CFError(`No user with email: ${email} was found on ${this.getName()}`); } return _toUser(res.body[0], email); } 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.'); } // check read permissions for both user and token and write permissions for user only async getRepositoryPermissions(opt) { const repoPermissions = { read: false, write: false, }; const res = await this.performAPICall({ api: `projects/${_encodeProjectPath(opt.owner, opt.repo)}`, json: true, }); if (res.statusCode == 200) { const repo = res.body; const isGroup = (0, lodash_1.get)(repo, 'namespace.kind') === 'group'; const accessLevel = (0, lodash_1.get)(repo, `permissions.${isGroup ? 'group_access' : 'project_access'}.access_level`, 0); repoPermissions.write = accessLevel >= 30; repoPermissions.read = true; } return repoPermissions; } async assertApiScopes(opt) { if (opt.scopes.includes('admin_repo_hook')) { await this.assertAdminScope(); return; } if ((opt.scopes.includes('repo_write') || opt.scopes.includes('repo_create')) && opt.repoUrl) { await this.assertWriteScope(opt.repoUrl); return; } if (opt.scopes.includes('repo_read')) { await this.assertReadScope(); } } 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 }; } async assertReadScope() { const projectsRes = await this.performAPICall({ api: `projects`, json: true, }); if (projectsRes.statusCode >= 400) { throw new CFError({ message: `ValidationError: check your token permissions, failed assert read scopes`, cause: _extractErrorFromResponse(projectsRes), }); } } async assertWriteScope(repoUrl) { // there is no existing endpoint to validate write scopes permissions 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: `projects`, method: 'POST', json: true, }); if (res.statusCode !== 400) { throw new CFError({ message: `ValidationError: failed to get project, check your token permissions, expected Project Admin`, cause: _extractErrorFromResponse(res), }); } } async getGroupId(opt) { const groupRes = await this.performAPICall({ api: `groups/${_encodePath(opt.groupName)}`, json: true, method: 'GET' }); if (groupRes.statusCode == 200) { return groupRes.body.id; } throw new CFError({ message: `Failed to get group id of group "${opt.groupName}"`, cause: _extractErrorFromResponse(groupRes), }); } toOwnerRepo(fullRepoName) { const repoName = fullRepoName.split('/'); const repo = repoName.pop(); // groups is part of the owner const owner = repoName.join('/'); if (!repo) { throw new CFError(`Failed to get repo from repository name ${fullRepoName}`); } return [owner, repo]; } getAuth() { return (0, lodash_1.omit)(this.auth, 'refreshToken'); } isTokenMutable() { return false; } requiresRepoToCheckTokenScopes() { return true; } useAdminForUserPermission() { return false; } } exports.default = Gitlab; //# sourceMappingURL=gitlab.js.map