TMS_Library

util lib for TMS related scripts

Ce script ne devrait pas être installé directement. C'est une librairie créée pour d'autres scripts. Elle doit être inclus avec la commande // @require https://update.greasyfork.org/scripts/486123/1324712/TMS_Library.js

// ==UserScript==
// @name         TMS_Library
// @namespace    http://tampermonkey.net/
// @version      1.1.0
// @description  util lib for TMS related scripts
// @author       bliushtein
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        GM_xmlhttpRequest
// ==/UserScript==

class Constants {
    static DESIGN_GAP = "DesignGap";
    static CROSS_STREAM_GAP = "CrossStreamGap";
    static OVERHEAD_CATEGORIES = [Constants.DESIGN_GAP, Constants.CROSS_STREAM_GAP];
    static DEV_STORY = "DevStory";
    static DESIGN_STORY = "DesignChapter";
    static BA_COMMUNICATION = "BACommunication";
    static CROSS_STREAM_COMMUNICATION = "CrossStreamCommunication";
    static DEV_TEST = "DevTest";
    static IMPLEMENTATION_CATEGORY = "Implementation";
    static TASK_CATEGORIES = [Constants.BA_COMMUNICATION, Constants.CROSS_STREAM_COMMUNICATION, Constants.DEV_TEST, Constants.IMPLEMENTATION_CATEGORY];
    static LINK_TYPE_IMPLEMENTATION = "10300";
    static ISSUE_TYPE_STORY = "17";
    static ISSUE_TYPE_EPIC = "16";
    static ISSUE_TYPE_TASK = "15";
    static ISSUE_TYPE_DEV_TASK = "9";
    static DU_PROJECT_ID = "37307";
}

function delay(milliseconds) {
    return new Promise(resolve => {
        setTimeout(resolve, milliseconds);
    });
}

class TmsApi {
    static TMS_URL = "https://tms.netcracker.com";
    static ISSUE_LINK_PREFIX = TmsApi.TMS_URL + `/browse/`;
    static REST_API_URL_PREFIX = TmsApi.TMS_URL + `/rest/api/latest`;

    static sendRequest(url, method = 'GET', body = null) {
        console.log(url, method, body);
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: method,
                timeout: 5000,
                onerror: reject,
                ontimeout: reject,
                onload: resolve,
                headers: {
                    Accept: 'application/json',
                    'Content-Type': 'application/json',
                    // If a user agent is not passed - a POST request fails with 403 error
                    'User-Agent': 'Any',
                },
                data: body,
                url: url,
            });
        }).then(response => {
            if ([200, 201].indexOf(response.status) !== -1) {
                if (response.responseText == null) {
                    return {};
                }
                return JSON.parse(response.responseText);
            }
            throw new Error(response.status + ' ' + response.statusText + ' ' + response.responseText);
        });
    }

    static async getIssue(key, fields = ["issuelinks","timetracking","components","issuetype","labels","status"]) {
        return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/issue/${key}?fields=${fields.join(",")}`);
    }

    static async getIssues(keys, fields = ["timetracking", "components", "issuetype", "labels", "priority", "customfield_10200", "customfield_10201", "customfield_10006", "summary", "status", "issuelinks"]) {
        if (keys.length == 0) {
            return {issues: []};
        }
        return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/search?jql=issuekey IN (${keys.join(",")})&fields=${fields.join(",")}`);
    }

    static async getSubtasks(keys, fields = ["timetracking", "components", "issuetype", "labels", "status", "parent"]) {
        if (keys.length == 0) {
            return {issues: []};
        }
        return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/search?jql=parent IN (${keys.join(",")})&fields=${fields.join(",")}`);
    }

    static async createIssueLink(issue1, issue2, linkType) {
        const request = {
            type: {id: linkType},
            inwardIssue: {key: issue1},
            outwardIssue: {key: issue2}
        };
        await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/issueLink`, "POST", JSON.stringify(request));
    }

    static async createDevStoryFromDesignStory(designStory, epicKey, assignee = null) {
        const request = {
            fields : {
                priority: {id: designStory.fields.priority.id},
                labels: [Constants.DEV_STORY],
                assignee: {name: assignee},
                components: [{id: designStory.fields.components[0].id}],
                customfield_10200: designStory.fields.customfield_10200, //external issue link
                customfield_10201: designStory.fields.customfield_10201, //external issue key
                issuetype: {id: Constants.ISSUE_TYPE_STORY},
                project: {id: Constants.DU_PROJECT_ID},
                customfield_10006: designStory.fields.customfield_10006, //Epic Link
                summary: designStory.fields.summary.replace("[BA]", "[DEV]")
            }
        };
        const issue_info = await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/issue`, "POST", JSON.stringify(request));
        await TmsApi.createIssueLink(issue_info.key, designStory.key, Constants.LINK_TYPE_IMPLEMENTATION);
        await TmsApi.createIssueLink(issue_info.key, epicKey, Constants.LINK_TYPE_IMPLEMENTATION);
        return issue_info.key;
    }

    static async getRelatedStories(keys, fields = ["timetracking", "components", "issuetype", "labels", "priority", "customfield_10200", "customfield_10201", "customfield_10006", "summary", "status", "issuelinks"]) {
        if (keys.length == 0) {
            return {issues: []};
        }
        const keysStr = keys.join(",");
        return await TmsApi.sendRequest(`${TmsApi.REST_API_URL_PREFIX}/search?jql=(issue in linkedIssuesOf("issuekey in (${keysStr})", "is implemented by") or parent in (${keysStr})) and type = Story&fields=${fields.join(",")}`);
    }

    static getLinkToIssue(key) {
        return TmsApi.ISSUE_LINK_PREFIX + key;
    }
}

class TmsTask {
    #errors;
    #issueInfo;
    #component;
    #category;
    #overheadCategory;

    constructor(issueInfo) {
        this.#errors = [];
        this.#issueInfo = issueInfo;
        if (issueInfo.fields.components == null) {
            this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should have 1 component. Actual amount = 0`});
        } else if (issueInfo.fields.components.length != 1) {
            this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should have 1 component. Actual amount = ${issueInfo.fields.components.length}`});
        }
        if (issueInfo.fields.components == null || issueInfo.fields.components.length == 0) {
            this.#component = null;
        } else {
            this.#component = issueInfo.fields.components[0].name;
        }
        const categoryLabels = this.#issueInfo.fields.labels.filter(label => Constants.TASK_CATEGORIES.includes(label));
        if (categoryLabels.length > 1) {
            this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should't have more then one task category. Actual amount = ${categoryLabels.length}`});
            this.#category = categoryLabels[0];
        } else if (categoryLabels.length == 1) {
            this.#category = categoryLabels[0];
        } else {
            this.#category = Constants.IMPLEMENTATION_CATEGORY;
        }
        const overheadCategoryLabels = this.#issueInfo.fields.labels.filter(label => Constants.OVERHEAD_CATEGORIES.includes(label));
        if (overheadCategoryLabels.length > 1) {
            this.#errors.push({ issue: this.key, message: `Task ${issueInfo.key} should't have more then one overhead category. Actual amount = ${categoryLabels.length}`});
            this.#overheadCategory = overheadCategoryLabels[0];
        } else if (overheadCategoryLabels.length == 1) {
            this.#overheadCategory = overheadCategoryLabels[0];
        } else {
            this.#overheadCategory = null;
        }
    }

    get errors() {
        return this.#errors;
    }

    get key() {
        return this.#issueInfo.key;
    }

    get component() {
        return this.#component;
    }

    get category() {
        return this.#category;
    }

    get overheadCategory() {
        return this.#overheadCategory;
    }

    get parent() {
        if (this.#issueInfo.fields.parent == null) {
            return null;
        } else {
            return this.#issueInfo.fields.parent.key;
        }
    }

    get originalEstimate() {
        if (this.#issueInfo.fields.timetracking == null || this.#issueInfo.fields.timetracking.originalEstimateSeconds == null) {
            return 0;
        } else {
            return this.#issueInfo.fields.timetracking.originalEstimateSeconds;
        }
    }

    get loggedTime() {
        if (this.#issueInfo.fields.timetracking == null || this.#issueInfo.fields.timetracking.timeSpentSeconds == null) {
            return 0;
        } else {
            return this.#issueInfo.fields.timetracking.timeSpentSeconds;
        }
    }

    get type() {
        return this.#issueInfo.fields.issuetype.id;
    }

}

class TmsStory {
    #errors;
    #stotyType;
    #issueInfo;
    #isDevStory;
    #isDesignStory;
    #component;
    #subtasks;
    #subtasksFilled;
    #timeTracking;
    #timeTrackingFilled;

    constructor(issueInfo) {
        this.#issueInfo = issueInfo;
        this.#isDevStory = this.hasLabel(Constants.DEV_STORY);
        this.#isDesignStory = this.hasLabel(Constants.DESIGN_STORY);
        this.#errors = [];
        this.#timeTrackingFilled = false;
        this.#subtasksFilled = false;
        if (issueInfo.fields.components == null) {
            this.#errors.push({ issue: this.key, message: `Story ${issueInfo.key} should have 1 component. Actual amount = 0`});
        } else if (issueInfo.fields.components.length != 1) {
            this.#errors.push({ issue: this.key, message: `Story ${issueInfo.key} should have 1 component. Actual amount = ${issueInfo.fields.components.length}`});
        }
        if (issueInfo.fields.components == null || issueInfo.fields.components.length == 0) {
            this.#component = null;
        } else {
            this.#component = issueInfo.fields.components[0].name;
        }
    }

    get errors() {
        return this.#errors;
    }

    get key() {
        return this.#issueInfo.key;
    }

    get epicKey() {
        return this.#issueInfo.fields.customfield_10006;
    }

    get isDevStory() {
        return this.#isDevStory;
    }

    get isDesignStory() {
        return this.#isDesignStory;
    }

    get component() {
        return this.#component;
    }

    get issueInfo() {//TODO avoid direct usage of json
        return this.#issueInfo;
    }

    get timeTracking() {
        if (this.#timeTrackingFilled) {
            return this.#timeTracking;
        } else {
            throw new Error(`Time tracking info for ${this.key} is not calculated yet`);
        }
    }

    get subtasks() {
        if (this.#subtasksFilled) {
            return this.#subtasks;
        } else {
            throw new Error(`Time subtasks for ${this.key} are not filled yet`);
        }
    }

    fillSubtasks(subtasks) {
        this.#subtasks = [];
        for (const taskInfo of subtasks) {
            const task = new TmsTask(taskInfo);
            if (task.parent != this.key) {
                continue;
            }
            this.#subtasks.push(task);
        }
        this.#subtasksFilled = true;
        if (!this.isDevStory) {
            this.#timeTrackingFilled = false;
            return;
        }
        this.#timeTracking = new TimeTracking();
        let originalEstimate = 0;
        for (const task of this.#subtasks) {
            if (task.type != Constants.ISSUE_TYPE_DEV_TASK && task.type != Constants.ISSUE_TYPE_TASK) {
                continue;
            }
            if (task.loggedTime > 0) {
                this.#timeTracking.logTime(task.category, task.loggedTime);
            }
            if (task.overheadCategory == null) {
                originalEstimate += task.originalEstimate;
            }
        }
        this.#timeTracking.originalEstimate = originalEstimate;
        this.#timeTrackingFilled = true;
    }

    get status() {
        return this.#issueInfo.fields.status.name;
    }

    hasLabel(label) {
        return this.#issueInfo.fields.labels.includes(label);
    }

    getAllErrors() {
        const errors = [... this.#errors];
        if (this.#subtasksFilled) {
            for (const subtask of this.#subtasks) {
                errors.push(...subtask.errors);
            }
        }
        return errors;
    }
}

class ComponentDetails {
    #devStoryExists;
    #designStoryExists;
    #devStory;
    #designStory;

    constructor() {
        this.#devStoryExists = false;
        this.#designStoryExists = false;
        this.#devStory = null;
        this.#designStory = null;
    }

    get devStoryExists() {
        return this.#devStoryExists;
    }

    get designStoryExists() {
        return this.#designStoryExists;
    }

    get devStory() {
        return this.#devStory;
    }

    set devStory(story) {
        if (this.#devStoryExists) {
            throw new Error("Dev story is already filled");
        }
        this.#devStoryExists = true;
        this.#devStory = story;
    }

    get designStory() {
        return this.#designStory;
    }

    set designStory(story) {
        if (this.#designStoryExists) {
            throw new Error("Design story is already filled");
        }
        this.#designStoryExists = true;
        this.#designStory = story;
    }

    setStory(story, type) {
        if (type == Constants.DEV_STORY) {
            this.devStory = story;
        } else if (type == Constants.DESIGN_STORY) {
            this.designStory = story;
        }
    }

    isStoryExists(type) {
        if (type == Constants.DEV_STORY) {
            return this.devStoryExists;
        } else if (type == Constants.DESIGN_STORY) {
            return this.designStoryExists;
        }
    }

}

class TimeTracking {
    #originalEstimate;
    #loggedTimeByCategory;
    constructor() {
        this.#originalEstimate = null;
        this.#loggedTimeByCategory = {};
    }

    get originalEstimate() {
        return this.#originalEstimate;
    }

    set originalEstimate(estimate) {
        if (estimate >= 0) {
            this.#originalEstimate = estimate;
        } else {
            throw new Error(`Original Estimate should be positive. Current value = ${estimate}`);
        }
    }

    getExistingCategories() {
        return Object.keys(this.#loggedTimeByCategory);
    }

    getLoggedTimeByCategory(category) {
        return this.#loggedTimeByCategory[category];
    }

    logTime(category, time) {
        if (time >= 0) {
            if (this.#loggedTimeByCategory[category] == null) {
                this.#loggedTimeByCategory[category] = 0;
            }
            this.#loggedTimeByCategory[category] += time;
        } else {
            throw new Error(`Logged time should be positive. Current value = ${time}`);
        }
    }

    get total() {
        let total = 0;
        for (const category of this.getExistingCategories()) {
            total += this.#loggedTimeByCategory[category];
        }
        return total;
    }
}

class TmsEpic {
    #errors;
    #issueInfo;
    #components;
    #componentsDetails;
    #componentsDetailsFilled;
    #linkedStoryKeys;
    constructor(issueInfo, relatedStories = null) {
        this.#issueInfo = issueInfo;
        this.#errors = [];
        this.#components = [];
        this.#componentsDetails = {};
        this.#componentsDetailsFilled = false;
        this.#linkedStoryKeys = [];
        if (issueInfo.fields.components != null) {
            for (const comp of issueInfo.fields.components) {
                this.#componentsDetails[comp.name] = new ComponentDetails();
                this.#components.push(comp.name);
            }
        }
        for (const link of issueInfo.fields.issuelinks) {
            if (link.type.id == Constants.LINK_TYPE_IMPLEMENTATION && link.inwardIssue != null && link.inwardIssue.fields.issuetype.id == Constants.ISSUE_TYPE_STORY) {
                this.#linkedStoryKeys.push(link.inwardIssue.key);
            }
        }
        if (relatedStories != null) {
            this.fillComponentsDetails(relatedStories);
        }
    }

    get errors() {
        return this.#errors;
    }

    get key() {
        return this.#issueInfo.key;
    }

    get components() {
        return this.#components.slice();
    }

    get componentsDetails() {
        if (!this.#componentsDetailsFilled) {
            throw new Error(`Components details for epic ${this.key} are not calculated yet`);
        }
        return this.#componentsDetails;//Need to clone object
    }

    isRelatedStory(story) {
        if (story.component == null) {
            return false;
        }
        if (story.epicKey == this.key) {
            return true;
        }
        return this.#linkedStoryKeys.includes(story.key);
    }

    fillComponentsDetails(relatedStories) {
        if (this.#componentsDetailsFilled) {
            throw new Error(`Components details for epic ${this.key} are already calculated`);
        }
        for (const story of relatedStories) {
            if (this.isRelatedStory(story)) {
                if (!this.#components.includes(story.component)) {
                    this.#componentsDetails[story.component] = new ComponentDetails();
                    this.#errors.push({issue: this.key, message: `Component ${story.component} of linked story ${story.key} is not added in epic`});
                }
                const componentDetails = this.#componentsDetails[story.component];
                if (story.isDevStory) {
                    if (componentDetails.devStoryExists) {
                        this.#errors.push({issue: this.key, message: `More than one dev story with component ${story.component} is linked to epic`});
                    } else {
                        componentDetails.devStory = story;
                    }
                }
                if (story.isDesignStory) {
                    if (componentDetails.designStoryExists) {
                        this.#errors.push({issue: this.key, message: `More than one design story with component ${story.component} is linked to epic`});
                    } else {
                        componentDetails.designStory = story;
                    }
                }
            }
        }
        this.#componentsDetailsFilled = true;
    }

    fillSubtasks(subtasks) {
        if (!this.#componentsDetailsFilled) {
            throw new Error(`Components details for epic ${this.key} are not calculated yet`);
        }
        for (const component of Object.keys(this.#componentsDetails)) {
            const componentDetails = this.#componentsDetails[component];
            if (componentDetails.devStoryExists) {
                componentDetails.devStory.fillSubtasks(subtasks);
            }
        }
    }

    getAllErrors() {
        const errors = [... this.#errors];
        if (this.#componentsDetailsFilled) {
            for (const component of Object.keys(this.#componentsDetails)) {
                const componentDetails = this.#componentsDetails[component];
                if (componentDetails.devStoryExists) {
                    errors.push(...componentDetails.devStory.getAllErrors());
                }
                if (componentDetails.designStoryExists) {
                    errors.push(...componentDetails.designStory.getAllErrors());
                }
            }
        }
        return errors;
    }
}