grigori-gru/jira-to-matrix

View on GitHub
src/task-trackers/gitlab/index.ts

Summary

Maintainability
D
2 days
Test Coverage
import { setupCache } from 'axios-cache-adapter';
import { Projects } from '@gitbeaker/node';
import * as R from 'ramda';
import axios, { AxiosRequestConfig, AxiosInstance } from 'axios';
import querystring from 'querystring';
import { TaskTracker, Issue, Project, Config, IssueWithComments, DefaultLabel } from '../../types';
import { TIMEOUT } from '../../lib/consts';
import * as messages from '../../lib/messages';
import { getLogger } from '../../modules/log';
import {
    GitlabIssue,
    GitlabProject,
    GitlabUserData,
    Notes,
    GitlabSelectors,
    GitlabIssueHook,
    HookUser,
    GitlabLabel,
    HookTypes,
    GitlabLabelHook,
    Milestone,
    Colors,
    Groups,
    GitlabUserDataShort,
} from './types';
import { GitlabParser } from './parser.gtilab';
import { selectors } from './selectors';
import { DateTime } from 'luxon';

const logger = getLogger(module);

export class Gitlab implements TaskTracker {
    private slashCommandsList = [
        '/approve',
        '/assign',
        '/assign',
        '/assign',
        '/award',
        '/child_epic',
        '/clear_weight',
        '/close',
        '/confidential',
        '/copy_metadata',
        '/copy_metadata',
        '/create_merge_request',
        '/done',
        '/due',
        '/duplicate',
        '/epic',
        '/estimate',
        '/iteration',
        '/label',
        '/lock',
        '/merge',
        '/milestone',
        '/move',
        '/parent_epic',
        '/promote',
        '/publish',
        '/reassign',
        '/relabel',
        '/relate',
        '/remove_child_epic',
        '/remove_due_date',
        '/remove_epic',
        '/remove_estimate',
        '/remove_iteration',
        '/remove_milestone',
        '/remove_parent_epic',
        '/remove_time_spent',
        '/remove_zoom',
        '/reopen',
        '/shrug',
        '/spend',
        '/spend',
        '/submit_review',
        '/subscribe',
        '/tableflip',
        '/target_branch',
        '/title',
        '/todo',
        '/unassign',
        '/unassign',
        '/unlabel',
        '/unlock',
        '/unsubscribe',
        '/weight',
        '/wip',
        '/zoom',
    ];
    // export class Gitlab {
    url: string;
    user: string;
    password: string;
    inviteIgnoreUsers: string[];
    restVersion = 'api/v4';
    pingInterval: number;
    pingCount: number;
    expandParams: { expand: string };
    public selectors: GitlabSelectors;
    public parser: GitlabParser;
    milestone: Milestone;
    api: AxiosInstance;
    defaultLabel?: DefaultLabel;

    constructor(
        private options: {
            url: string;
            user: string;
            inviteIgnoreUsers?: string[];
            password: string;
            features: Config['features'];
            interval?: number;
            count?: number;
            defaultLabel?: DefaultLabel;
        },
    ) {
        this.url = options.url;
        this.user = options.user;
        this.password = options.password;
        this.inviteIgnoreUsers = options.inviteIgnoreUsers || [];
        this.pingInterval = options.interval || 500;
        this.pingCount = options.count || 10;
        this.selectors = selectors;
        this.parser = new GitlabParser(options.features, selectors);
        this.defaultLabel = options.defaultLabel;
        const cache = setupCache({
            maxAge: 5 * 1000,
        });
        this.api = axios.create({
            adapter: cache.adapter,
        });
    }

    init() {
        return new Gitlab(this.options);
    }

    async request(url: string, newOptions?: AxiosRequestConfig, contentType = 'application/json'): Promise<any> {
        const options: AxiosRequestConfig = {
            method: 'GET',
            headers: { 'private-token': this.password, 'content-type': contentType },
            timeout: TIMEOUT,
            ...newOptions,
            url,
        };
        try {
            const response = await this.api(options);
            logger.debug(`${options.method} request to gitlab with Url ${url} suceeded`);

            return response.data;
        } catch (err) {
            // TODO remove it
            // logger.error(err);
            throw messages.getRequestErrorLog(url, err?.response?.status, options.method, err?.response?.statusText);
        }
    }

    getMilestoneColors(milestone: Milestone): string[] {
        if (!milestone.due_date || !milestone.start_date) {
            return [Colors.gray];
        }
        if (milestone.state === 'closed') {
            return [Colors.gray];
        }
        const currentDate = DateTime.local();
        const startDate = DateTime.fromISO(milestone.start_date!);
        const endDate = DateTime.fromISO(milestone.due_date!);

        // https://stackoverflow.com/questions/60058489/compare-only-dates-with-luxon-datetime
        if (startDate.startOf('day') > currentDate.startOf('day')) {
            return [Colors.yellow];
        }

        if (currentDate.startOf('day') > endDate.startOf('day')) {
            return [Colors.gray];
        }

        const allDays = endDate.diff(startDate, ['days']).days + 1;
        const grayDays = Math.floor(currentDate.diff(startDate, ['days']).days);
        const futureDays = allDays - grayDays;

        return [...Array(grayDays).fill(Colors.gray), ...Array(futureDays).fill(Colors.green)];
    }

    getMilestoneUrl(body: GitlabIssue): string | undefined {
        const milestone = body.milestone;
        if (milestone) {
            if (milestone.project_id) {
                return this.getRestUrl('projects', milestone.project_id, 'milestones', milestone.id, 'issues');
            }
            if (milestone.group_id) {
                return this.getRestUrl('groups', milestone.group_id, 'milestones', milestone.id, 'issues');
            }
        }
    }

    // async getLabels();

    getStatusColor({
        issueKey,
        hookLabels = [],
    }: {
        issueKey: string;
        hookLabels?: GitlabLabelHook[];
    }): Promise<string[]> {
        return this.getCurrentIssueColor(issueKey, hookLabels);
    }

    requestPost(url: string, options: AxiosRequestConfig, contentType?: string): Promise<any> {
        const _options: AxiosRequestConfig = {
            ...options,
            method: 'POST',
        };

        return this.request(url, _options, contentType);
    }

    requestPut(url: string, options: AxiosRequestConfig, contentType?: string): Promise<any> {
        const _options: AxiosRequestConfig = {
            ...options,
            method: 'PUT',
        };

        return this.request(url, _options, contentType);
    }

    private async getProjectIdByNamespace(namespaceWithProjectName: string): Promise<number> {
        const project = await this.getBaseProject(namespaceWithProjectName);

        return project.id as number;
    }

    private async getGroupIdByNamespace(namespaceWithProjectName: string): Promise<number | undefined> {
        const [groupName] = namespaceWithProjectName.split('/');
        const groupUrl = this.getRestUrl(`groups?search=${groupName}`);
        const groups: Groups[] = await this.request(groupUrl);

        const group = groups.find(el => el.path === groupName);

        return group?.id;
    }

    // key is like namespace/project-123
    async getIssue(key: string): Promise<Issue & GitlabIssue> {
        const { namespaceWithProject, issueId } = this.selectors.transformFromIssueKey(key);
        const projectId = await this.getProjectIdByNamespace(namespaceWithProject);

        const url = this.getRestUrl('projects', projectId, 'issues', issueId);
        const issue: GitlabIssue = await this.request(url);

        return { ...issue, key };
    }

    async getMilestoneIssues(url: string): Promise<Array<GitlabIssue>> {
        const issues: GitlabIssue[] = await this.request(url);

        return issues;
    }

    async getMilestoneWatchers(url: string): Promise<string[]> {
        const issues = await this.getMilestoneIssues(url);
        const milestoneMembers = issues.map(el => this.selectors.getAssigneeDisplayName(el)).flat();

        return R.uniq(milestoneMembers);
    }

    getRestUrl(...args: (string | number)[]) {
        return [this.url, this.restVersion, ...args].join('/');
    }

    private isSlashCommand(text: string): boolean {
        const [firstWord] = text.split(' ');

        return this.slashCommandsList.includes(firstWord);
    }

    getPostCommentBody(sender: string, bodyText: string): string {
        if (this.isSlashCommand(bodyText)) {
            logger.debug(`Sended text "${bodyText}" is gitlab slash command`);
            return bodyText;
        }

        return `@${sender}:\n${bodyText}`;
    }

    async getIssueFieldsValues(key: string, fields: (keyof GitlabIssueHook['changes'])[]): Promise<any> {
        try {
            const issue = await this.getIssue(key);

            const renderedValues = R.pipe(
                R.pick(fields),
                R.filter(value => !!value),
                R.toPairs,
                R.map(([key, value]) => {
                    switch (key) {
                        case 'assignees':
                            return { [key]: (value as HookUser)[0].name };
                        case 'labels':
                            return { [key]: (value as string)[0] };
                        default:
                            return { [key]: value };
                    }
                }),
            )(issue);

            return renderedValues;
        } catch (err) {
            throw ['getIssueFieldsValues error', err].join('\n');
        }
    }

    private isBadRequest(errMessage: string): boolean {
        const badRequestErrorPart = 'status is 400';

        return errMessage.includes(badRequestErrorPart);
    }

    createLink(urlRoom: string, body: string): string {
        return `[${body}](${urlRoom})`;
    }

    async postComment(gitlabIssueKey: string, { sender }, bodyText: string): Promise<string> {
        const body = this.getPostCommentBody(sender, bodyText);

        return await this.sendMessage(gitlabIssueKey, body);
    }

    async sendMessage(gitlabIssueKey: string, body: string): Promise<string> {
        const { namespaceWithProject, issueId } = this.selectors.transformFromIssueKey(gitlabIssueKey);
        const params = querystring.stringify({ body });
        const projectId = await this.getProjectIdByNamespace(namespaceWithProject);
        // TODO make correct query params passing
        const url = this.getRestUrl('projects', projectId, 'issues', issueId, 'notes');

        try {
            await this.requestPost(url, { data: params }, 'application/x-www-form-urlencoded');

            return body;
        } catch (error) {
            if (typeof error === 'string' && this.isBadRequest(error)) {
                // https://gitlab.com/gitlab-org/gitlab/-/issues/35627
                logger.warn('Post command has got 400 status, but it will be skipped because of gitlab bug');
                return body;
            }

            throw error;
        }
    }

    async getIssueSafety(key: string): Promise<Issue | false> {
        try {
            const issue = await this.getIssue(key);

            return issue;
        } catch (error) {
            logger.warn(error);

            return false;
        }
    }

    private async getBaseProject(namespaceWithProjectName: string): Promise<GitlabProject> {
        const queryPararms = namespaceWithProjectName.split('/').join('%2F');
        // TODO make correct query params passing
        const url = this.getRestUrl('projects/' + queryPararms);
        const foundProjects: GitlabProject = await this.request(url);
        const project: boolean = foundProjects.path_with_namespace === namespaceWithProjectName;
        if (!project) {
            throw new Error(`Not found project by namespace ${namespaceWithProjectName}`);
        }

        return foundProjects;
    }

    private async getProjectMembers(projectId): Promise<GitlabUserData[]> {
        const url = this.getRestUrl('projects', projectId, 'members', 'all');

        return await this.request(url);
    }

    async getProjectLead(projectId): Promise<GitlabUserData> {
        const projectMembers = await this.getProjectMembers(projectId);

        return projectMembers.reduce((acc, val) => (acc.access_level > val.access_level ? acc : val));
    }

    private parseProject(project: GitlabProject & { lead: string }): Project {
        return project;
    }

    async getProject(namespaceWithProjectName: string): Promise<Project> {
        const project = await this.getBaseProject(namespaceWithProjectName);
        const projectLead = await this.getProjectLead(project.id);

        return this.parseProject({ ...project, lead: projectLead.name });
    }

    async getIssueWatchers(key): Promise<{ userId: string; displayName: string }[]> {
        const issue = await this.getIssue(key);
        const members = [issue.assignee, issue.author];

        return members
            .filter((el): el is GitlabUserDataShort => Boolean(el))
            .map(el => ({
                displayName: el.name,
                userId: el.username,
            }));
    }

    // TODO fix for projects
    getViewUrl(key: string) {
        const keyData = this.selectors.transformFromKey(key);

        return keyData.issueId
            ? [this.url, keyData.namespaceWithProject, 'issues', keyData.issueId].join('/')
            : [this.url, keyData.namespaceWithProject, 'milestones', keyData.milestoneId].join('/');
    }

    async getIssueComments(key): Promise<IssueWithComments> {
        const { namespaceWithProject, issueId } = this.selectors.transformFromIssueKey(key);
        const projectId = await this.getProjectIdByNamespace(namespaceWithProject);

        const url = this.getRestUrl('projects', projectId, 'issues', issueId, 'notes');

        const commentsBody: Notes[] = await this.request(url);
        const comments = commentsBody.map(el => ({ id: el.id, body: el.body }));

        return {
            key,
            comments,
        };
    }

    testJiraRequest() {
        return Promise.resolve();
    }

    async hasIssue(key) {
        const res = await this.getIssueSafety(key);

        return Boolean(res);
    }

    checkIgnoreList(ignoreList, taskType): boolean {
        return ignoreList.includes(taskType);
    }

    getKeyOrIdForCheckIgnore(body): string[] | string {
        return this.selectors.keysForCheckIgnore(body);
    }

    isIgnoreHook(body) {
        return this.selectors.isIgnoreHookType(body);
    }

    isAvoidHookType(type) {
        return type === HookTypes.Push;
    }

    async upload(
        issueKey: string,
        fileOptions: { url: string; fileName: string },
    ): Promise<{ fullUrl: string; markdown: string }> {
        const response = await axios.get(fileOptions.url, { responseType: 'arraybuffer' });
        const imageType = response.headers['content-type'];
        const { namespaceWithProject } = this.selectors.transformFromIssueKey(issueKey);
        const projectId = await this.getProjectIdByNamespace(namespaceWithProject);

        const projects = new Projects({ token: this.password, host: this.url });
        const uploadInfo: any = await projects.upload(projectId, response.data, {
            metadata: { contentType: imageType, filename: fileOptions.fileName },
        });

        const fullUrl = this.getRestUrl('projects', projectId) + uploadInfo.url;

        return {
            fullUrl,
            markdown: uploadInfo.markdown,
        };
    }

    async getProjectLabels(namespaceWithProject: string): Promise<GitlabLabel[]> {
        const projectId = await this.getProjectIdByNamespace(namespaceWithProject);
        const url = this.getRestUrl('projects', projectId, 'labels?per_page=100');

        return await this.request(url);
    }

    async getGroupLabels(namespaceWithProject: string): Promise<GitlabLabel[]> {
        const groupId = await this.getGroupIdByNamespace(namespaceWithProject);
        if (!groupId) {
            logger.warn(
                `Group by id with namespace ${namespaceWithProject} is not found. Return empty array of labels`,
            );

            return [];
        }
        const url = this.getRestUrl('groups', groupId, 'labels?per_page=100');

        return await this.request(url);
    }

    async getAllAvailalbleLabels(namespaceWithProject): Promise<GitlabLabel[]> {
        const projectLabels = await this.getProjectLabels(namespaceWithProject);
        const groupLabels = await this.getGroupLabels(namespaceWithProject);

        return [...projectLabels, ...groupLabels];
    }

    async setDefaultLabelForIssue(gitlabIssueKey: string, labelName: string): Promise<string> {
        const { namespaceWithProject, issueId } = this.selectors.transformFromIssueKey(gitlabIssueKey);
        const params = querystring.stringify({ labels: labelName });
        const projectId = await this.getProjectIdByNamespace(namespaceWithProject);

        // TODO make correct query params passing
        const url = this.getRestUrl('projects', projectId, 'issues', `${issueId}?${params}`);

        try {
            await this.requestPut(url, {});

            return labelName;
        } catch (error) {
            if (typeof error === 'string' && this.isBadRequest(error)) {
                // https://gitlab.com/gitlab-org/gitlab/-/issues/35627
                logger.warn('Post command has got 400 status, but it will be skipped because of gitlab bug');
                return labelName;
            }

            throw error;
        }
    }

    async createDefaultLabelInGroup(groupId: number): Promise<string | undefined> {
        if (!this.defaultLabel) {
            return;
        }
        // TODO make correct query params passing
        const url = this.getRestUrl('groups', groupId, 'labels');

        try {
            const data = await this.requestPost(url, { data: this.defaultLabel });
            return data;
        } catch (error) {
            throw error;
        }
    }

    async getLabelColorInGroup(namespaceWithProject: string, labelName: string): Promise<string | undefined> {
        const groupLabels = await this.getGroupLabels(namespaceWithProject);
        const labelData = groupLabels.find(el => el.name === labelName);

        return labelData?.color;
    }

    async getCurrentIssueColor(key: string, hookLabels?: GitlabLabelHook[]): Promise<string[]> {
        const issue = await this.getIssue(key);
        if (issue.state === 'closed') {
            return [Colors.gray];
        }

        const { namespaceWithProject } = this.selectors.transformFromIssueKey(key);
        if (!hookLabels) {
            const labels = await this.getAllAvailalbleLabels(namespaceWithProject);
            const colors = labels.filter(label => issue.labels?.includes(label.name)).map(label => label.color);

            return [...new Set(colors)];
        }
        if (hookLabels.length == 0) {
            if (this.defaultLabel) {
                const labelColor = await this.getLabelColorInGroup(namespaceWithProject, this.defaultLabel.name);

                if (labelColor) {
                    await this.setDefaultLabelForIssue(key, this.defaultLabel.name);

                    return [labelColor];
                }
            }
        }

        const colors = hookLabels.map(label => label.color).sort();

        return [...new Set(colors)];
    }
}